Browse Source

Merge branch '3.1.x' into ajuste_data_votacoes_nominais

pull/3834/head
cristian-longhi 21 hours ago
committed by GitHub
parent
commit
58edf5ed21
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 38
      CHANGES.md
  2. 2
      docker/Dockerfile
  3. 64
      docker/config/nginx/sapl.conf
  4. 2
      docker/docker-compose.yaml
  5. 70
      frontend/src/__apps/compilacao/js/old/compilacao.js
  6. 4
      frontend/src/__apps/compilacao/js/old/compilacao_edit.js
  7. 9
      frontend/src/__apps/compilacao/scss/compilacao.scss
  8. 25
      frontend/src/__global/js/tinymce/index.js
  9. 1
      requirements/requirements.txt
  10. 26
      sapl/api/serializers.py
  11. 19
      sapl/api/tests/test_serializers_sessao.py
  12. 28
      sapl/api/views_sessao.py
  13. 20
      sapl/audiencia/forms.py
  14. 59
      sapl/audiencia/migrations/0021_auto_20260623_1938.py
  15. 18
      sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py
  16. 11
      sapl/audiencia/models.py
  17. 124
      sapl/base/media.py
  18. 19
      sapl/base/migrations/0061_casalegislativa_informacao_geral.py
  19. 4
      sapl/base/models.py
  20. 45
      sapl/base/receivers.py
  21. 12
      sapl/base/templatetags/common_tags.py
  22. 27
      sapl/base/tests/test_form.py
  23. 384
      sapl/base/tests/test_media.py
  24. 228
      sapl/base/tests/test_sanitize.py
  25. 25
      sapl/comissoes/forms.py
  26. 18
      sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py
  27. 3
      sapl/comissoes/models.py
  28. 11
      sapl/comissoes/views.py
  29. 28
      sapl/compilacao/forms.py
  30. 13
      sapl/compilacao/utils.py
  31. 48
      sapl/compilacao/views.py
  32. 12
      sapl/crispy_layout_mixin.py
  33. 5
      sapl/crud/base.py
  34. 4
      sapl/materia/forms.py
  35. 90
      sapl/materia/migrations/0088_fix_view_materiaemtramitacao.py
  36. 22
      sapl/materia/models.py
  37. 64
      sapl/materia/tests/test_acessorio_em_lote.py
  38. 98
      sapl/materia/tests/test_proposicao_confirmar.py
  39. 126
      sapl/materia/views.py
  40. 4
      sapl/middleware/check_password.py
  41. 365
      sapl/painel/tests/tests.py
  42. 12
      sapl/painel/urls.py
  43. 275
      sapl/painel/views.py
  44. 18
      sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py
  45. 4
      sapl/parlamentares/models.py
  46. 28
      sapl/protocoloadm/migrations/0046_auto_20260626_1220.py
  47. 13
      sapl/protocoloadm/models.py
  48. 163
      sapl/protocoloadm/tests/test_docadm_acesso.py
  49. 38
      sapl/protocoloadm/views.py
  50. 4
      sapl/relatorios/forms.py
  51. 35
      sapl/relatorios/templates/pdf_sessao_plenaria_gerar.py
  52. 125
      sapl/relatorios/views.py
  53. 182
      sapl/sanitize.py
  54. 31
      sapl/sessao/admin.py
  55. 4
      sapl/sessao/forms.py
  56. 40
      sapl/sessao/migrations/0070_presenca_unica_por_sessao.py
  57. 54
      sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py
  58. 45
      sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py
  59. 63
      sapl/sessao/models.py
  60. 94
      sapl/sessao/tests/test_sessao_oculta_api.py
  61. 220
      sapl/sessao/tests/test_sessao_view.py
  62. 689
      sapl/sessao/tests/test_votacao_nominal.py
  63. 1096
      sapl/sessao/views.py
  64. 2
      sapl/settings.py
  65. 4
      sapl/templates/audiencia/layouts.yaml
  66. 9
      sapl/templates/base.html
  67. 2
      sapl/templates/comissoes/materias_em_tramitacao.html
  68. 2
      sapl/templates/compilacao/dispositivo_form_alteracao.html
  69. 2
      sapl/templates/compilacao/dispositivo_form_edicao_basica.html
  70. 4
      sapl/templates/compilacao/dispositivo_form_search_fragment_child.html
  71. 2
      sapl/templates/compilacao/layout/dispositivo_checkbox.html
  72. 2
      sapl/templates/compilacao/layout/dispositivo_radio.html
  73. 10
      sapl/templates/compilacao/text_edit.html
  74. 2
      sapl/templates/compilacao/text_edit_bloco.html
  75. 2
      sapl/templates/compilacao/text_edit_blocoalteracao.html
  76. 2
      sapl/templates/compilacao/text_list_bloco.html
  77. 6
      sapl/templates/compilacao/text_list_blocoalteracao.html
  78. 2
      sapl/templates/compilacao/text_notificacoes.html
  79. 2
      sapl/templates/compilacao/textoarticulado_detail.html
  80. 2
      sapl/templates/materia/acompanhamento_materia.html
  81. 2
      sapl/templates/materia/materialegislativa_filter.html
  82. 2
      sapl/templates/materia/resumo_detail_materia.html
  83. 2
      sapl/templates/norma/normajuridica_filter.html
  84. 22
      sapl/templates/painel/index.html
  85. 120
      sapl/templates/painel/mensagem.html
  86. 128
      sapl/templates/painel/parlamentares.html
  87. 123
      sapl/templates/painel/votacao.html
  88. 71
      sapl/templates/painel/voto_individual.html
  89. 2
      sapl/templates/parlamentares/parlamentar_perfil_publico.html
  90. 2
      sapl/templates/protocoloadm/acompanhamento_documento.html
  91. 2
      sapl/templates/protocoloadm/documentoadministrativo_filter.html
  92. 2
      sapl/templates/relatorios/RelatorioMateriasPorAutor_filter.html
  93. 2
      sapl/templates/relatorios/RelatorioNormasPorAutor_filter.html
  94. 3
      sapl/templates/relatorios/blocos_sessao_plenaria/consideracoes_finais.html
  95. 3
      sapl/templates/relatorios/blocos_sessao_plenaria/expedientes.html
  96. 3
      sapl/templates/relatorios/blocos_sessao_plenaria/ocorrencias_da_sessao.html
  97. 2
      sapl/templates/relatorios/relatorio_doc_administrativos.html
  98. 2
      sapl/templates/relatorios/relatorio_materia_tramitacao.html
  99. 6
      sapl/templates/relatorios/relatorio_materias_por_autor.html
  100. 2
      sapl/templates/relatorios/relatorio_normas_por_autor.html

38
CHANGES.md

@ -1,4 +1,42 @@
3.1.165-RC4 / 2026-10-14
========================
* Restaura imports perdidos na resolução de conflitos de #3849/#3855 (#3867)
* Sanitiza HTML/JavaScript nos campos TextField (stored XSS) (#3846)
* Fix N+1 queries in sessão/relatórios and remove unused painel views (#3849)
* Corrige travas e concorrência na votação nominal do painel (#3855)
* Restringe acesso a arquivos de mídia (#3865)
* Converte títulos colados em parágrafos no editor de dispositivos (#3863)
* Permite ajustar e persistir o tamanho da letra no editor de textos articulados (#3862)
* Mantém visíveis ao público as sessões de hoje e de datas passadas (#3864)
* Melhorias de transparência (PNTP): correções de relatórios e novas funcionalidades (#3842)
* Impede presenças duplicadas de parlamentar na mesma sessão (#3850)
3.1.165-RC3 / 2026-09-01
========================
* Corrige SQL injection em DispositivoSearchFragmentFormView (#3856)
* Remove limite de caracteres do campo Informação Geral da Casa (#3848)
* Oculta sessões plenárias não iniciadas da consulta pública (#3845)
* hot-fix: Adiciona MIME Media Types opcionais para CSV.
* hot-fix: Remove alerta errado
* Corrige ordem dos expedientes no PDF do Resumo da Sessão (#3844)
* hot-fix: imprime somente contéudo da ocorrência
* Fix duplicate normas/matérias in author reports and prevent unfiltered queries
* Fix ValueError in audiencia form and AttributeError on HEAD requests
* Fix panel clock source and prevent overlapping polling
* hot-fix: recover epoch from server always
* Corrige polling excessivo e bloqueia não-votantes em /voto-individual/ (#3831)
* Hot-fix: corrige cópia de request.data
* Release: 3.1.165-RC2
* #3821 centralizar geração próximo número para create de matérias legislativas (#3822)
3.1.166-RC0 / 2026-09-01
========================
3.1.165-RC2 / 2026-03-26
========================

2
docker/Dockerfile

@ -51,7 +51,7 @@ ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
curl jq bash tzdata fontconfig tini libmagic1 \
curl jq bash tzdata fontconfig tini libmagic1 media-types \
libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 \
libharfbuzz0b libfreetype6 libjpeg62-turbo zlib1g fonts-dejavu-core; \
if [ "$WITH_GRAPHVIZ" = "1" ]; then apt-get install -y --no-install-recommends graphviz; fi; \

64
docker/config/nginx/sapl.conf

@ -49,8 +49,70 @@ server {
alias /var/interlegis/sapl/collected_static/;
}
# Arquivos que dependem de autorização vão ao Django (sapl.base.media),
# que devolve X-Accel-Redirect para /media-interna/. Manter a lista em
# sincronia com PROTEGIDO_RE e MODELOS_DE_SESSAO (teste em
# sapl/base/tests/test_media.py). Os dois regex ficam no nível do server
# e antes de location /media/: um regex aninhado em /media/ teria
# precedência sobre este.
location ~* ^/media/sapl/(private/|[^/]+/(documentoadministrativo|documentoacessorioadministrativo|proposicao|sessaoplenaria|orador|oradorexpediente|oradorordemdia|justificativaausencia)/) {
proxy_set_header X-Request-ID $req_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://sapl_server;
}
# Demais arquivos são públicos e servidos direto do disco; conteúdo que o
# navegador executaria como página é forçado a download.
location ~* ^/media/.+\.(html?|xhtml|xht|shtml|svgz?|xml|xsl|xslt|rdf|js|mjs|mht|mhtml|rss|atom|mml|kml|xspf)$ {
root /var/interlegis/sapl;
types { }
default_type application/octet-stream;
add_header Content-Disposition attachment always;
add_header X-Content-Type-Options nosniff always;
}
# Só os tipos abaixo saem com Content-Type próprio; o resto (inclusive
# +xml que o mime.types global entregaria inline) vai como octet-stream.
location /media/ {
alias /var/interlegis/sapl/media/;
root /var/interlegis/sapl;
types {
application/pdf pdf;
image/jpeg jpg jpeg jpe;
image/png png;
image/gif gif;
image/webp webp;
image/bmp bmp;
image/tiff tif tiff;
image/x-icon ico;
text/plain txt;
text/csv csv;
audio/mpeg mp3;
audio/ogg ogg;
video/mp4 mp4;
video/webm webm;
application/rtf rtf;
application/zip zip;
application/msword doc;
application/vnd.ms-excel xls;
application/vnd.ms-powerpoint ppt;
application/vnd.openxmlformats-officedocument.wordprocessingml.document docx;
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx;
application/vnd.openxmlformats-officedocument.presentationml.presentation pptx;
application/vnd.oasis.opendocument.text odt;
application/vnd.oasis.opendocument.spreadsheet ods;
application/vnd.oasis.opendocument.presentation odp;
}
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff always;
}
location /media-interna/ {
internal;
alias /var/interlegis/sapl/media/;
add_header X-Content-Type-Options nosniff always;
}
location / {

2
docker/docker-compose.yaml

@ -33,7 +33,7 @@ services:
networks:
- sapl-net
sapl:
image: interlegis/sapl:3.1.165-RC2
image: interlegis/sapl:3.1.165-RC4
# build:
# context: ../
# 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 () {
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
}

4
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()
}

9
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;

25
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)
}

1
requirements/requirements.txt

@ -38,6 +38,7 @@ django-prometheus==2.2.0
asn1crypto==1.5.1
XlsxWriter==3.2.0
nh3==0.2.22
setuptools==80.9.0

26
sapl/api/serializers.py

@ -12,7 +12,7 @@ from rest_framework.fields import SerializerMethodField
from sapl.base.models import Autor, CasaLegislativa, Metadata
from sapl.parlamentares.models import Parlamentar, Mandato, Legislatura
from sapl.sessao.models import OrdemDia, SessaoPlenaria
from sapl.sessao.models import ExpedienteMateria, OrdemDia, SessaoPlenaria
class SaplSerializerMixin(DrfAutoApiSerializerMixin):
@ -317,5 +317,25 @@ class SessaoPlenariaECidadaniaSerializer(serializers.ModelSerializer):
return self.casa().nome
def casa(self):
casa = CasaLegislativa.objects.first()
return casa
return CasaLegislativa.objects.first()
class OrdemDiaSerializer(SaplSerializerMixin):
"""
votacao_aberta/registro_aberto só podem ser alterados através do fluxo
de abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que
garante a invariante de no máximo uma matéria aberta por vez. Editáveis
aqui, a API contornaria essa garantia por completo.
"""
class Meta:
model = OrdemDia
fields = '__all__'
read_only_fields = ('votacao_aberta', 'registro_aberto')
class ExpedienteMateriaSerializer(SaplSerializerMixin):
"""Ver OrdemDiaSerializer — mesmo motivo para os mesmos dois campos."""
class Meta:
model = ExpedienteMateria
fields = '__all__'
read_only_fields = ('votacao_aberta', 'registro_aberto')

19
sapl/api/tests/test_serializers_sessao.py

@ -0,0 +1,19 @@
import pytest
from model_bakery import baker
from sapl.api.serializers import SessaoPlenariaECidadaniaSerializer
from sapl.base.models import CasaLegislativa
from sapl.sessao.models import SessaoPlenaria
@pytest.mark.django_db(transaction=False)
def test_sessao_ecidadania_serializa_dados_da_casa():
casa = baker.make(CasaLegislativa, nome='Câmara Municipal', sigla='CM',
endereco='Praça Central')
sessao = baker.make(SessaoPlenaria)
data = SessaoPlenariaECidadaniaSerializer(sessao).data
assert data['txtNomeOrgao'] == casa.nome
assert data['txtSiglaOrgao'] == casa.sigla
assert data['txtLocal'] == casa.endereco

28
sapl/api/views_sessao.py

@ -7,7 +7,8 @@ from drfautoapi.drfautoapi import ApiViewSetConstrutor, \
customize, wrapper_queryset_response_for_drf_action
from sapl.api.serializers import ChoiceSerializer,\
SessaoPlenariaECidadaniaSerializer
from sapl.sessao.models import SessaoPlenaria, ExpedienteSessao
from sapl.sessao.models import SessaoPlenaria, ExpedienteSessao,\
restringe_sessoes_visiveis
from sapl.utils import choice_anos_com_sessaoplenaria
@ -21,6 +22,13 @@ ApiViewSetConstrutor.build_class(
@customize(SessaoPlenaria)
class _SessaoPlenariaViewSet:
def get_queryset(self):
qs = super().get_queryset()
# actions com wrapper_queryset_response_for_drf_action trocam o model
if qs.model is SessaoPlenaria:
qs = restringe_sessoes_visiveis(qs, self.request.user)
return qs
@action(detail=False)
def years(self, request, *args, **kwargs):
years = choice_anos_com_sessaoplenaria()
@ -30,6 +38,7 @@ class _SessaoPlenariaViewSet:
@action(detail=True)
def expedientes(self, request, *args, **kwargs):
self.get_object()
return self.get_expedientes()
@wrapper_queryset_response_for_drf_action(model=ExpedienteSessao)
@ -45,3 +54,20 @@ class _SessaoPlenariaViewSet:
def ecidadania_list(self, request, *args, **kwargs):
self.serializer_class = SessaoPlenariaECidadaniaSerializer
return self.list(request, *args, **kwargs)
class _RestringeSessoesVisiveisMixin:
def get_queryset(self):
qs = super().get_queryset()
if self.request.user.is_authenticated:
return qs
return qs.filter(sessao_plenaria__in=restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), self.request.user))
for _model in apps.get_app_config('sessao').get_models():
if _model is not SessaoPlenaria and any(
f.name == 'sessao_plenaria' for f in _model._meta.fields):
customize(_model)(type('_%sViewSet' % _model.__name__,
(_RestringeSessoesVisiveisMixin,), {}))

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 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.")

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

124
sapl/base/media.py

@ -0,0 +1,124 @@
import mimetypes
import os
import re
from urllib.parse import quote
from django.conf import settings
from django.http import FileResponse, Http404, HttpResponse
from sapl.materia.models import Proposicao, pode_ver_proposicao
from sapl.protocoloadm.models import (DocumentoAcessorioAdministrativo,
DocumentoAdministrativo,
pode_ver_documento_adm)
from sapl.sessao.models import (JustificativaAusencia, Orador,
OradorExpediente, OradorOrdemDia,
SessaoPlenaria, restringe_sessoes_visiveis)
from sapl.utils import EXTENSOES_ATIVAS, tipo_ativo
X_ACCEL_PREFIX = '/media-interna/'
# O nginx do container só envia para cá os caminhos de PROTEGIDO_RE e de
# MODELOS_DE_SESSAO; o resto de /media/ sai direto do disco. Ao incluir um
# modelo aqui, incluí-lo também em docker/config/nginx/sapl.conf.
# Arquivos que dependem de autorização, em private/ ou em qualquer outra
# pasta (public/ guarda os enviados antes da criação de private/).
PROTEGIDO_RE = re.compile(
r'^sapl/[^/]+/(?:documentoadministrativo|'
r'documentoacessorioadministrativo|proposicao)/')
RESOLVEDORES = {
'documentoadministrativo': (
DocumentoAdministrativo, 'texto_integral', pode_ver_documento_adm),
'documentoacessorioadministrativo': (
DocumentoAcessorioAdministrativo, 'arquivo',
lambda user, obj: pode_ver_documento_adm(user, obj.documento)),
'proposicao': (
Proposicao, 'texto_original', pode_ver_proposicao),
}
# Arquivos gravados em sapl/public/<model>/<pk>/..., que seguem a
# visibilidade da sessão plenária a que pertencem.
MODELOS_DE_SESSAO = {m._meta.model_name: m for m in (
SessaoPlenaria, Orador, OradorExpediente, OradorOrdemDia,
JustificativaAusencia)}
def _de_sessao_oculta(request, partes):
model = MODELOS_DE_SESSAO.get(partes[2])
if request.user.is_authenticated or not model or len(partes) < 5 or \
partes[1] != 'public' or not re.fullmatch(r'[0-9]+', partes[3]):
return False
if model is SessaoPlenaria:
sessoes = SessaoPlenaria.objects.filter(pk=partes[3])
else:
sessoes = SessaoPlenaria.objects.filter(
pk__in=model.objects.filter(pk=partes[3]).values(
'sessao_plenaria_id'))
return sessoes.exists() and not restringe_sessoes_visiveis(
sessoes, request.user).exists()
def _nome_protegido(request, path):
"""Devolve o nome do arquivo gravado no objeto dono, se o usuário puder
vê-lo. O caminho servido vem do banco, nunca da URL."""
resolvedor = RESOLVEDORES.get(path.split('/')[2])
if not resolvedor:
raise Http404
model, campo, pode_ver = resolvedor
obj = model.objects.filter(
**{campo + '__in': [path, './' + path]}).first()
if not obj:
raise Http404
arquivo = getattr(obj, campo)
if not arquivo or not pode_ver(request.user, obj):
raise Http404
return arquivo.name[2:] if arquivo.name.startswith('./') else arquivo.name
def _caminho_em_media(nome):
raiz = os.path.realpath(settings.MEDIA_ROOT)
caminho = os.path.realpath(os.path.join(raiz, nome))
if not caminho.startswith(raiz + os.sep):
raise Http404
return caminho
def media(request, path):
partes = path.split('/')
if any(p in ('', '.', '..') for p in partes) or '\x00' in path:
raise Http404
protegido = path.startswith('sapl/private/') or bool(
PROTEGIDO_RE.match(path))
nome = _nome_protegido(request, path) if protegido else path
if len(partes) > 2 and _de_sessao_oculta(request, partes):
raise Http404
nome_arquivo = os.path.basename(nome)
content_type = (mimetypes.guess_type(nome_arquivo)[0] or
'application/octet-stream')
disposicao = 'inline'
if os.path.splitext(nome_arquivo)[1].lower() in EXTENSOES_ATIVAS or \
tipo_ativo(content_type):
content_type, disposicao = 'application/octet-stream', 'attachment'
caminho = _caminho_em_media(nome)
if settings.DEBUG:
try:
response = FileResponse(open(caminho, 'rb'),
content_type=content_type)
except (FileNotFoundError, IsADirectoryError):
raise Http404
else:
response = HttpResponse(content_type=content_type)
response['X-Accel-Redirect'] = X_ACCEL_PREFIX + quote(nome)
response['Content-Disposition'] = "%s; filename*=UTF-8''%s" % (
disposicao, quote(nome_arquivo))
response['X-Content-Type-Options'] = 'nosniff'
if protegido or (len(partes) > 2 and partes[2] in MODELOS_DE_SESSAO):
response['Cache-Control'] = 'private'
return response

19
sapl/base/migrations/0061_casalegislativa_informacao_geral.py

@ -0,0 +1,19 @@
# Generated by Django 2.2.28 on 2026-08-05 12:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('base', '0060_auto_20240812_1628'),
]
operations = [
migrations.AlterField(
model_name='casalegislativa',
name='informacao_geral',
field=models.TextField(blank=True,
verbose_name='Informação Geral'),
),
]

4
sapl/base/models.py

@ -73,8 +73,10 @@ class CasaLegislativa(models.Model):
max_length=100, blank=True, verbose_name=_('HomePage'))
email = models.EmailField(
max_length=100, blank=True, verbose_name=_('E-mail'))
# Campo editado via editor de texto rico (TinyMCE), portanto o valor
# armazenado é HTML. Não deve ter max_length: as tags consomem o limite
# e impedem o salvamento de textos curtos.
informacao_geral = models.TextField(
max_length=100,
blank=True,
verbose_name=_('Informação Geral'))

45
sapl/base/receivers.py

@ -8,6 +8,7 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core import serializers
from django.core.files.uploadedfile import InMemoryUploadedFile, UploadedFile
from django.db.models import TextField
from django.db.models.fields.files import FileField
from django.db.models.signals import post_delete, post_save, \
post_migrate, pre_save, pre_migrate
@ -22,11 +23,15 @@ from sapl.decorators import receiver_multi_senders
from sapl.materia.models import Tramitacao
from sapl.parlamentares.models import Parlamentar
from sapl.protocoloadm.models import TramitacaoAdministrativo
from sapl.sanitize import sanitize_field
from sapl.utils import get_base_url, models_with_gr_for_model
models_with_gr_for_autor = models_with_gr_for_model(Autor)
SAPL_APP_LABELS = frozenset(
app.rsplit('.', 1)[-1] for app in settings.SAPL_APPS)
@receiver_multi_senders(post_save, senders=models_with_gr_for_autor)
def handle_update_autor_related(sender, **kwargs):
@ -477,6 +482,46 @@ def signed_files_extraction_pre_save_signal(sender, instance, **kwargs):
signed_files_extraction_function(sender, instance, **kwargs)
# Cache dos TextField por modelo: a introspecção de _meta.fields a cada save
# pesa mais que a própria sanitização, e compilacao salva Dispositivo em laço.
_text_fields_cache = {}
def get_text_fields(model):
try:
return _text_fields_cache[model]
except KeyError:
fields = [f.name for f in model._meta.fields
if isinstance(f, TextField)]
_text_fields_cache[model] = fields
return fields
@receiver(pre_save, dispatch_uid='sanitize_textfields_pre_save_signal')
def sanitize_textfields_pre_save_signal(sender, instance, **kwargs):
"""Remove HTML/JavaScript perigoso de todo TextField dos modelos do SAPL.
Cobre forms, a API do drfautoapi, o admin e o shell num único ponto.
Ver sapl.sanitize para as políticas por campo.
"""
# loaddata (inclusive dentro de migrations) grava o objeto literalmente
if kwargs.get('raw'):
return
if sender._meta.app_label not in SAPL_APP_LABELS:
return
for fieldname in get_text_fields(sender):
value = getattr(instance, fieldname, None)
if not value:
continue
sanitized = sanitize_field(sender, fieldname, value)
if sanitized != value:
setattr(instance, fieldname, sanitized)
@receiver(pre_migrate, dispatch_uid='disconnect_signals_pre_migrate')
def disconnect_signals_pre_migrate(*args, **kwargs):
# sanitize_textfields_pre_save_signal não é desconectado aqui de propósito:
# é barato e idempotente, e desconectá-lo deixaria sem proteção qualquer
# migrate rodado no mesmo processo (é o caso da suíte de testes).
pre_save.disconnect(dispatch_uid='signed_files_extraction_pre_save_signal')

12
sapl/base/templatetags/common_tags.py

@ -10,6 +10,7 @@ from sapl.base.models import AppConfig
from sapl.materia.models import DocumentoAcessorio, MateriaLegislativa, Proposicao
from sapl.norma.models import NormaJuridica
from sapl.parlamentares.models import Filiacao
from sapl.sanitize import sanitize_html
from sapl.sessao.models import SessaoPlenaria
from sapl.utils import filiacao_data, SEPARADOR_HASH_PROPOSICAO, is_report_allowed
@ -396,6 +397,17 @@ def render_chunk_vendors(extension=None):
return ''
@register.filter(is_safe=True)
@stringfilter
def sanitize(value):
"""Renderiza HTML de campo rico (TinyMCE) sem script nem URL perigosa.
Substitui o |safe nos campos que legitimamente guardam HTML. Protege
também as linhas gravadas antes da sanitização no pre_save.
"""
return mark_safe(sanitize_html(value, rich=True))
@register.filter(is_safe=True)
@stringfilter
def dont_break_out(value):

27
sapl/base/tests/test_form.py

@ -39,3 +39,30 @@ def test_casa_legislativa_form_invalido():
})
assert not form.is_valid()
@pytest.mark.django_db(transaction=False)
def test_casa_legislativa_form_informacao_geral_sem_limite():
# O campo é editado por editor de texto rico, então o valor submetido é
# HTML: as tags não podem consumir um limite de caracteres.
html = ''.join('<p>%s</p>' % linha for linha in
['Câmara Municipal', 'Rua Osvaldo Cruz, 555 - Centro',
'Expediente das 8h às 12h e das 13h30 às 17h30'])
assert len(html) > 100
form = CasaLegislativaForm(data={'codigo': 'codigo',
'nome': 'nome',
'sigla': 'sg',
'endereco': 'endereco',
'cep': '70000-000',
'municipio': 'municipio',
'uf': 'DF',
'telefone': '33333333',
'fax': '33333333',
'endereco_web': '',
'email': '',
'informacao_geral': html
})
assert form.is_valid(), form.errors
assert form.cleaned_data['informacao_geral'] == html

384
sapl/base/tests/test_media.py

@ -0,0 +1,384 @@
import os
import re
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.http import Http404
from django.utils import timezone
from model_bakery import baker
import pytest
from sapl.base.media import MODELOS_DE_SESSAO, RESOLVEDORES, media
from sapl.base.models import AppConfig, Autor
from sapl.materia.models import Proposicao
from sapl.protocoloadm.models import (DocumentoAcessorioAdministrativo,
DocumentoAdministrativo)
from sapl.utils import EXTENSOES_ATIVAS
DOC_PATH = 'sapl/private/documentoadministrativo/2026/1/texto.pdf'
ACESSORIO_PATH = 'sapl/public/documentoacessorioadministrativo/2026/1/a.pdf'
PROPOSICAO_PATH = 'sapl/private/proposicao/2026/1/proposicao.pdf'
@pytest.fixture(autouse=True)
def limpa_cache_appconfig():
# o cache em arquivo sobrevive ao rollback do teste
cache.clear()
yield
cache.clear()
@pytest.fixture(autouse=True)
def sem_debug(settings):
settings.DEBUG = False
def _config(comportamento):
cache.clear()
AppConfig.objects.all().delete()
baker.make(AppConfig, documentos_administrativos=comportamento)
def _login(client, username, *perms):
user = baker.make(get_user_model(), username=username)
for perm in perms:
app_label, codename = perm.split('.')
perm_obj = Permission.objects.filter(
content_type__app_label=app_label, codename=codename).first()
if not perm_obj:
perm_obj = Permission.objects.create(
codename=codename, name=codename,
content_type=ContentType.objects.get_for_model(
DocumentoAdministrativo))
user.user_permissions.add(perm_obj)
client.force_login(user)
return user
def _get(client, path):
return client.get('/media/' + path)
def _assert_servido(response, path):
assert response.status_code == 200
assert response['X-Accel-Redirect'] == '/media-interna/' + path
assert response['X-Content-Type-Options'] == 'nosniff'
assert response['Cache-Control'] == 'private'
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('comportamento,restrito,perfil,pode_ver', [
('O', False, None, True),
('O', True, None, False),
('O', True, 'logado', True),
('R', False, None, False),
('R', False, 'logado', False),
('R', True, None, False),
('R', True, 'logado', False),
('R', True, 'com_permissao', True),
])
def test_documento_administrativo(client, comportamento, restrito, perfil,
pode_ver):
_config(comportamento)
baker.make(DocumentoAdministrativo, restrito=restrito,
texto_integral=DOC_PATH)
if perfil == 'logado':
_login(client, 'logado')
elif perfil == 'com_permissao':
_login(client, 'perm',
'protocoloadm.detail_documentoadministrativo')
response = _get(client, DOC_PATH)
if pode_ver:
_assert_servido(response, DOC_PATH)
assert response['Content-Type'] == 'application/pdf'
else:
assert response.status_code == 404
assert not response.has_header('X-Accel-Redirect')
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('restrito,pode_ver', [(False, True), (True, False)])
def test_documento_acessorio_segue_documento(client, restrito, pode_ver):
_config('O')
documento = baker.make(DocumentoAdministrativo, restrito=restrito)
baker.make(DocumentoAcessorioAdministrativo, documento=documento,
arquivo=ACESSORIO_PATH)
response = _get(client, ACESSORIO_PATH)
assert response.status_code == (200 if pode_ver else 404)
@pytest.mark.django_db(transaction=False)
def test_caminho_legado_e_prefixo_ponto_barra(client):
_config('O')
legado = 'sapl/public/documentoadministrativo/2010/7/antigo.pdf'
baker.make(DocumentoAdministrativo, texto_integral=legado)
baker.make(DocumentoAdministrativo, texto_integral='./' + DOC_PATH)
_assert_servido(_get(client, legado), legado)
_assert_servido(_get(client, DOC_PATH), DOC_PATH)
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('path', [
'sapl/private/documentoadministrativo/2026/1/inexistente.pdf',
'sapl/private/outromodelo/2026/1/texto.pdf',
'sapl/private/documentoadministrativo/../../public/x.pdf',
'sapl/private/documentoadministrativo/%2e%2e/x.pdf',
])
def test_caminho_invalido(client, path):
_config('O')
baker.make(DocumentoAdministrativo, texto_integral=DOC_PATH)
assert _get(client, path).status_code == 404
@pytest.mark.django_db(transaction=False)
def test_extensao_ativa_e_baixada(client):
_config('O')
path = 'sapl/private/documentoadministrativo/2026/1/x.html'
baker.make(DocumentoAdministrativo, texto_integral=path)
response = _get(client, path)
_assert_servido(response, path)
assert response['Content-Type'] == 'application/octet-stream'
assert response['Content-Disposition'].startswith('attachment;')
@pytest.mark.django_db(transaction=False)
def test_nome_nao_ascii_codificado(client):
_config('O')
path = 'sapl/private/documentoadministrativo/2026/1/ofício_nº1.pdf'
baker.make(DocumentoAdministrativo, texto_integral=path)
response = _get(client, path)
assert response.status_code == 200
assert response['X-Accel-Redirect'] == (
'/media-interna/sapl/private/documentoadministrativo/2026/1/'
'of%C3%ADcio_n%C2%BA1.pdf')
ESTADOS_PROPOSICAO = ('rascunho', 'enviada', 'devolvida', 'incorporada')
PERMS_DOCADM = ['protocoloadm.add_documentoadministrativo',
'protocoloadm.change_documentoadministrativo',
'protocoloadm.detail_documentoadministrativo']
def _proposicao(estado):
autor = baker.make(Autor)
operador = baker.make(get_user_model(), username='operador')
autor.operadores.add(operador)
agora = timezone.now()
# mesmos campos gravados pelo envio, devolução e incorporação
baker.make(Proposicao, autor=autor, texto_original=PROPOSICAO_PATH,
data_envio=agora if estado in ('enviada', 'incorporada')
else None,
data_recebimento=agora if estado == 'incorporada' else None,
data_devolucao=agora if estado == 'devolvida' else None)
return operador
def _login_proposicao(client, perfil, operador):
if perfil == 'operador':
client.force_login(operador)
elif perfil == 'docadm':
user = _login(client, 'docadm', *PERMS_DOCADM)
assert user.has_module_perms('protocoloadm')
elif perfil == 'outro':
_login(client, 'outro')
elif perfil:
_login(client, perfil, 'materia.detail_proposicao_' + perfil)
def _pode_ver_proposicao(estado, perfil):
return perfil == 'operador' or perfil == estado != 'rascunho'
GRADE_PROPOSICAO = [
(estado, perfil, _pode_ver_proposicao(estado, perfil))
for estado in ESTADOS_PROPOSICAO
for perfil in (None, 'operador', 'outro', 'docadm', 'enviada',
'devolvida', 'incorporada')]
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('estado,perfil,pode_ver', GRADE_PROPOSICAO)
def test_proposicao(client, estado, perfil, pode_ver):
_login_proposicao(client, perfil, _proposicao(estado))
response = _get(client, PROPOSICAO_PATH)
if pode_ver:
_assert_servido(response, PROPOSICAO_PATH)
else:
assert response.status_code == 404
@pytest.mark.django_db(transaction=False)
def test_arquivo_publico_passa_por_x_accel(client):
path = 'sapl/public/materialegislativa/2026/1/materia.pdf'
response = _get(client, path)
assert response.status_code == 200
assert response['X-Accel-Redirect'] == '/media-interna/' + path
assert response['Content-Type'] == 'application/pdf'
assert response['X-Content-Type-Options'] == 'nosniff'
assert not response.has_header('Cache-Control')
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('nome', ['x.svg', 'x.html', 'x.XML', 'x.js',
'x.xht', 'x.rdf', 'x.xslt', 'x.kml'])
def test_arquivo_publico_ativo_e_baixado(client, nome):
response = _get(client, 'sapl/public/normajuridica/2026/1/' + nome)
assert response['Content-Type'] == 'application/octet-stream'
assert response['Content-Disposition'].startswith('attachment;')
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('path', [
'sapl/public/../private/documentoadministrativo/2026/1/texto.pdf',
'sapl//private/documentoadministrativo/2026/1/texto.pdf',
'sapl/./private/documentoadministrativo/2026/1/texto.pdf',
'sapl/public/normajuridica/',
])
def test_caminho_nao_normalizado(client, path):
assert _get(client, path).status_code == 404
@pytest.mark.django_db(transaction=False)
def test_debug_serve_arquivo(rf, settings, tmp_path):
settings.DEBUG = True
settings.MEDIA_ROOT = str(tmp_path)
_config('R')
publico = tmp_path / 'sapl/public/normajuridica/1/n.pdf'
publico.parent.mkdir(parents=True)
publico.write_bytes(b'%PDF-1.4 norma')
baker.make(DocumentoAdministrativo, texto_integral=DOC_PATH)
def chama(path):
request = rf.get('/media/' + path)
request.user = AnonymousUser()
return media(request, path)
response = chama('sapl/public/normajuridica/1/n.pdf')
assert b''.join(response.streaming_content) == b'%PDF-1.4 norma'
assert not response.has_header('X-Accel-Redirect')
for path in (DOC_PATH, 'sapl/public/normajuridica/1/nao.pdf'):
with pytest.raises(Http404):
chama(path)
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('debug', [False, True])
def test_link_simbolico_para_fora_de_media(rf, settings, tmp_path, debug):
settings.DEBUG = debug
media_root = tmp_path / 'media'
fora = tmp_path / 'fora'
(media_root / 'sapl/public').mkdir(parents=True)
fora.mkdir()
(fora / 'segredo.txt').write_bytes(b'segredo')
(media_root / 'sapl/public/link').symlink_to(fora)
settings.MEDIA_ROOT = str(media_root)
request = rf.get('/media/sapl/public/link/segredo.txt')
request.user = AnonymousUser()
with pytest.raises(Http404):
media(request, 'sapl/public/link/segredo.txt')
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('path', ['/etc/passwd', 'sapl/public//etc/passwd'])
def test_caminho_absoluto(rf, path):
request = rf.get('/media/' + path)
request.user = AnonymousUser()
with pytest.raises(Http404):
media(request, path)
@pytest.mark.django_db(transaction=False)
def test_modelo_protegido_fora_de_private_e_public(client):
_config('O')
path = 'sapl/outra/documentoadministrativo/2026/1/texto.pdf'
baker.make(DocumentoAdministrativo, restrito=True, texto_integral=path)
assert _get(client, path).status_code == 404
@pytest.mark.django_db(transaction=False)
def test_senha_fraca_nao_redireciona_media(client):
_login(client, 'fraca')
session = client.session
session['weak_password'] = True
session.save()
response = _get(client, 'sapl/public/casalegislativa/logotipo.png')
assert response.status_code == 200
assert response.has_header('X-Accel-Redirect')
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('estado,perfil,pode_ver', GRADE_PROPOSICAO)
def test_proposicao_texto(client, settings, tmp_path, estado, perfil,
pode_ver):
settings.MEDIA_ROOT = str(tmp_path)
arquivo = tmp_path / PROPOSICAO_PATH
arquivo.parent.mkdir(parents=True)
arquivo.write_bytes(b'%PDF-1.4 proposicao')
_login_proposicao(client, perfil, _proposicao(estado))
pk = Proposicao.objects.get(texto_original=PROPOSICAO_PATH).pk
response = client.get('/proposicao/texto/%s' % pk)
if pode_ver:
assert response.status_code == 200
assert response.content == b'%PDF-1.4 proposicao'
else:
assert response.status_code == 302
assert response['Location'] == '/proposicao/%s' % pk
def test_nginx_envia_ao_django_todos_os_caminhos_protegidos():
caminho = os.path.join(settings.PROJECT_DIR, 'docker', 'config',
'nginx', 'sapl.conf')
with open(caminho) as f:
conf = f.read()
regex = re.search(r'location ~\*? \^/media/sapl/\(private/\|'
r'\[\^/\]\+/\(([a-z|]+)\)/\)', conf)
assert regex, 'location de arquivos protegidos não encontrada'
no_nginx = set(regex.group(1).split('|'))
assert set(RESOLVEDORES) | set(MODELOS_DE_SESSAO) <= no_nginx
assert conf.index(regex.group(0)) < conf.index('location /media/ {')
def test_nginx_forca_download_das_extensoes_ativas():
caminho = os.path.join(settings.PROJECT_DIR, 'docker', 'config',
'nginx', 'sapl.conf')
with open(caminho) as f:
conf = f.read()
regex = re.search(r'location ~\*? \^/media/\.\+\\\.\(([a-z?|]+)\)\$',
conf)
assert regex, 'location de extensões ativas não encontrada'
no_nginx = set()
for ext in regex.group(1).split('|'):
if ext.endswith('?'):
no_nginx |= {ext[:-2], ext[:-1]}
else:
no_nginx.add(ext)
assert {'.' + e for e in no_nginx} == EXTENSOES_ATIVAS

228
sapl/base/tests/test_sanitize.py

@ -0,0 +1,228 @@
import pytest
from django.db.models.signals import pre_save
from django.template import Context, Template
from model_bakery import baker
from sapl.compilacao.forms import TipoDispositivoForm
from sapl.compilacao.models import TipoDispositivo, TipoTextoArticulado
from sapl.crispy_layout_mixin import get_field_display
from sapl.lexml.models import LexmlProvedor
from sapl.parlamentares.models import Parlamentar
from sapl.protocoloadm.models import TramitacaoAdministrativo
from sapl.sanitize import (html_fragment_is_balanced, sanitize_field,
sanitize_html, sanitize_scope)
from sapl.sessao.models import ExpedienteSessao
def test_plain_remove_marcacao_e_preserva_texto():
assert sanitize_html('<script>alert(1)</script>Ciente') == 'Ciente'
assert sanitize_html('Encaminhado <b>ao</b> setor') == \
'Encaminhado ao setor'
assert sanitize_html('<img src=x onerror=alert(1)>') == ''
assert sanitize_html('<div>a</div><div>b</div>') == 'ab'
def test_plain_guarda_texto_puro_sem_escape():
"""O escape é da renderização; gravado escapado, apareceria &amp; na tela."""
assert sanitize_html('Valor < 10 & prazo > 5') == 'Valor < 10 & prazo > 5'
assert sanitize_html('ALFA &amp; BETA') == 'ALFA & BETA'
def test_plain_preserva_quebras_de_linha():
# get_field_display converte \n em <br/> depois de sanitizar
assert sanitize_html('linha1\nlinha2') == 'linha1\nlinha2'
def test_valores_vazios_atravessam():
assert sanitize_html('') == ''
assert sanitize_html(None) is None
assert sanitize_html('', rich=True) == ''
@pytest.mark.parametrize('valor', [
'<script>alert(1)</script>Ciente',
'Valor < 10 & prazo > 5',
'Encaminhado <b>ao</b> <a href="https://x">setor</a>',
'texto &amp; cia',
])
def test_plain_e_idempotente(valor):
"""Salvar de novo um registro já sanitizado não altera o texto."""
uma_vez = sanitize_html(valor)
assert sanitize_html(uma_vez) == uma_vez
@pytest.mark.parametrize('valor', [
'<a href="https://camara.gov.br" target="_blank">Portal</a>',
'<p style="text-align: center;">centro</p>',
'<table><tr><td colspan="2">c</td></tr></table>',
'<script>alert(1)</script><b>ok</b>',
])
def test_rich_e_idempotente(valor):
uma_vez = sanitize_html(valor, rich=True)
assert sanitize_html(uma_vez, rich=True) == uma_vez
def test_rich_preserva_links():
saida = sanitize_html(
'<a href="https://camara.gov.br" target="_blank">Portal</a>',
rich=True)
assert 'href="https://camara.gov.br"' in saida
assert 'target="_blank"' in saida
assert 'rel="noopener noreferrer"' in saida
assert '>Portal</a>' in saida
assert 'href="/materia/123"' in sanitize_html(
'<a href="/materia/123">Matéria</a>', rich=True)
assert 'href="mailto:a@b.c"' in sanitize_html(
'<a href="mailto:a@b.c">mail</a>', rich=True)
def test_rich_remove_href_perigosa_mas_mantem_o_texto():
saida = sanitize_html(
'<a href="javascript:alert(1)">clique</a>', rich=True)
assert 'javascript' not in saida
assert 'clique' in saida
def test_rich_remove_script_e_manipuladores_de_evento():
saida = sanitize_html('<script>alert(1)</script><b>ok</b>', rich=True)
assert saida == '<b>ok</b>'
assert 'onclick' not in sanitize_html(
'<a href="#" onclick="steal()">x</a>', rich=True)
assert 'onerror' not in sanitize_html(
'<img src="x" onerror="alert(1)">', rich=True)
def test_rich_preserva_formatacao_do_tinymce():
"""Protege contra regressão visual no conteúdo já cadastrado."""
assert 'style="text-align: center;"' in sanitize_html(
'<p style="text-align: center;">centro</p>', rich=True)
saida = sanitize_html(
'<table><tr><td colspan="2">c</td></tr></table>', rich=True)
assert '<table>' in saida and 'colspan="2"' in saida
assert sanitize_html('<ul><li>a</li><li>b</li></ul>', rich=True) == \
'<ul><li>a</li><li>b</li></ul>'
def test_sanitize_scope():
assert sanitize_scope(TramitacaoAdministrativo, 'texto') == 'plain'
assert sanitize_scope(ExpedienteSessao, 'conteudo') == 'rich'
assert sanitize_scope(LexmlProvedor, 'xml') == 'exempt'
assert sanitize_scope(TipoTextoArticulado, 'rodape_global') == 'exempt'
assert sanitize_scope(Parlamentar, 'biografia') == 'rich'
def test_sanitize_field_respeita_isencao():
xml = '<xml><a href="javascript:x">y</a></xml>'
assert sanitize_field(LexmlProvedor, 'xml', xml) == xml
@pytest.mark.django_db
def test_pre_save_sanitiza_campo_simples():
t = baker.make(TramitacaoAdministrativo,
texto='<script>alert(1)</script>Ciente <b>ok</b>')
t.refresh_from_db()
assert t.texto == 'Ciente ok'
@pytest.mark.django_db
def test_pre_save_sanitiza_campo_rico_preservando_html():
e = baker.make(ExpedienteSessao,
conteudo='<b>x</b><script>alert(1)</script>'
'<a href="https://a.b" target="_blank">l</a>')
e.refresh_from_db()
assert '<script>' not in e.conteudo
assert '<b>x</b>' in e.conteudo
assert 'href="https://a.b"' in e.conteudo
@pytest.mark.django_db
def test_pre_save_nao_toca_modelo_isento():
xml = '<xml>a &amp; b <script>x</script></xml>'
p = baker.make(LexmlProvedor, xml=xml)
p.refresh_from_db()
assert p.xml == xml
@pytest.mark.django_db
def test_get_field_display_nao_devolve_script():
t = TramitacaoAdministrativo(texto='<script>alert(1)</script>Ciente')
__, display = get_field_display(t, 'texto')
assert '<script>' not in display
assert 'Ciente' in display
@pytest.mark.django_db
def test_get_field_display_protege_linha_legada():
"""Linhas gravadas antes do pre_save não passam pela camada de entrada."""
t = baker.make(TramitacaoAdministrativo, texto='ok')
TramitacaoAdministrativo.objects.filter(pk=t.pk).update(
texto='<script>alert(1)</script>legado')
t.refresh_from_db()
assert t.texto == '<script>alert(1)</script>legado'
__, display = get_field_display(t, 'texto')
assert '<script>' not in display
def test_pre_save_ignora_raw():
"""loaddata (inclusive em migrations) grava o objeto literalmente."""
t = TramitacaoAdministrativo(texto='<b>fixture</b>')
pre_save.send(sender=TramitacaoAdministrativo, instance=t, raw=True)
assert t.texto == '<b>fixture</b>'
def test_get_field_display_escapa_texto_puro_uma_unica_vez():
t = TramitacaoAdministrativo(texto='ALFA & BETA < 30')
__, display = get_field_display(t, 'texto')
assert 'ALFA &amp; BETA &lt; 30' in display
assert '&amp;amp;' not in display
def test_get_field_display_escapa_campo_isento():
p = LexmlProvedor(xml='<xml><script>x</script></xml>')
__, display = get_field_display(p, 'xml')
assert '<script>' not in display
assert '&lt;xml&gt;' in display
def test_striptags_apos_sanitize_nao_escapa_duas_vezes():
"""Blocos da ata: texto puro, sem entidades escapadas de novo."""
t = Template('{% load common_tags %}{{ v|sanitize|striptags }}')
saida = t.render(Context({
'v': '<p>Ofício&nbsp;12 lido &amp; arquivado</p>'
'<script>alert(1)</script><img src=x onerror=alert(1)>'}))
assert saida == 'Ofício&nbsp;12 lido &amp; arquivado'
@pytest.mark.parametrize('valor, esperado', [
('<br>', True),
('<br/>', True),
('<div class="titulo">Justificativa</div>', True),
('Art. ', True),
('<span class="x">', False),
('</span>', False),
('<b><i>x</b></i>', False),
])
def test_html_fragment_is_balanced(valor, esperado):
assert html_fragment_is_balanced(valor) is esperado
@pytest.mark.django_db
def test_tipo_dispositivo_form_rejeita_fragmento_desbalanceado():
td = baker.make(TipoDispositivo)
dados = {f: getattr(td, f) or '' for f in TipoDispositivoForm.Meta.fields}
dados['rotulo_prefixo_html'] = '<span class="rotulo">'
dados['rotulo_sufixo_html'] = '</span>'
form = TipoDispositivoForm(data=dados, instance=td)
assert not form.is_valid()
assert 'rotulo_prefixo_html' in form.errors
assert 'rotulo_sufixo_html' in form.errors
dados['rotulo_prefixo_html'] = '<br/>'
dados['rotulo_sufixo_html'] = ''
form = TipoDispositivoForm(data=dados, instance=td)
assert 'rotulo_prefixo_html' not in form.errors

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

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

11
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):

28
sapl/compilacao/forms.py

@ -26,6 +26,7 @@ from sapl.compilacao.models import (NOTAS_PUBLICIDADE_CHOICES,
from sapl.compilacao.utils import DISPOSITIVO_SELECT_RELATED
from sapl.crispy_layout_mixin import SaplFormHelper
from sapl.crispy_layout_mixin import SaplFormLayout, to_column, to_row
from sapl.sanitize import RICH_TEXT_FIELDS, html_fragment_is_balanced
from sapl.utils import YES_NO_CHOICES, FileFieldCheckMixin
@ -51,6 +52,33 @@ ta_error_messages = {
}
class TipoDispositivoForm(ModelForm):
class Meta:
model = TipoDispositivo
fields = ['rotulo_ordinal', 'formato_variacao0',
'rotulo_separador_variacao01', 'formato_variacao1',
'rotulo_separador_variacao12', 'formato_variacao2',
'rotulo_separador_variacao23', 'formato_variacao3',
'rotulo_separador_variacao34', 'formato_variacao4',
'rotulo_separador_variacao45', 'formato_variacao5',
'rotulo_prefixo_html', 'rotulo_sufixo_html',
'texto_prefixo_html', 'texto_sufixo_html',
'nota_automatica_prefixo_html',
'nota_automatica_sufixo_html']
def clean(self):
cleaned_data = super().clean()
for fieldname in RICH_TEXT_FIELDS['compilacao.TipoDispositivo']:
value = cleaned_data.get(fieldname)
if value and not html_fragment_is_balanced(value):
self.add_error(fieldname, _(
'Toda tag aberta neste campo deve ser fechada nele '
'mesmo; tags divididas entre prefixo e sufixo são '
'descartadas ao salvar.'))
return cleaned_data
class TipoTaForm(ModelForm):
sigla = forms.CharField(
label=TipoTextoArticulado._meta.get_field(

13
sapl/compilacao/utils.py

@ -1,5 +1,7 @@
import sys
from django.utils.translation import ugettext_lazy as _
DISPOSITIVO_SELECT_RELATED = (
'tipo_dispositivo',
'ta_publicado',
@ -66,3 +68,14 @@ def get_integrations_view_names():
if 'IntegracaoTaView' in str(base):
result.append(v)
return result
def valid_int(value):
if value:
try:
int(value)
except (TypeError, ValueError):
raise ValueError(
_('Valor "%(value)s" não é um número inteiro válido.') % {
'value': value})
return value

48
sapl/compilacao/views.py

@ -39,7 +39,8 @@ from sapl.compilacao.forms import (DispositivoDefinidorVigenciaForm,
DispositivoRegistroRevogacaoForm,
DispositivoSearchModalForm, NotaForm,
PublicacaoForm, TaForm,
TextNotificacoesForm, TipoTaForm, VideForm)
TextNotificacoesForm, TipoDispositivoForm,
TipoTaForm, VideForm)
from sapl.compilacao.models import (STATUS_TA_EDITION, STATUS_TA_PRIVATE,
STATUS_TA_PUBLIC, Dispositivo, Nota,
PerfilEstruturalTextoArticulado,
@ -49,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
@ -85,6 +86,7 @@ class TipoDispositivoCrud(CrudAux):
class UpdateView(CrudAux.UpdateView):
layout_key = 'TipoDispositivoUpdate'
form_class = TipoDispositivoForm
class ListView(CrudAux.ListView):
paginate_by = 100
@ -3177,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
@ -3228,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 = ''
@ -3288,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()

12
sapl/crispy_layout_mixin.py

@ -8,9 +8,12 @@ from django.contrib.contenttypes.models import ContentType
from django.urls import reverse, reverse_lazy
from django.utils import formats
from django.utils.encoding import force_text
from django.utils.html import escape
from django.utils.translation import ugettext as _
import yaml
from sapl.sanitize import sanitize_html, sanitize_scope
def heads_and_tails(list_of_lists):
for alist in list_of_lists:
@ -167,7 +170,14 @@ def get_field_display(obj, fieldname):
args=(value.id,)),
value)
elif 'TextField' in str_type_from_field:
display = value.replace('\n', '<br/>')
scope = sanitize_scope(obj._meta.model, fieldname)
if scope == 'rich':
display = sanitize_html(value, rich=True)
elif scope == 'plain':
display = escape(sanitize_html(value))
else:
display = escape(value)
display = display.replace('\n', '<br/>')
display = '<div class="dont-break-out">{}</div>'.format(display)
else:
display = str(value)

5
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

4
sapl/materia/forms.py

@ -1944,10 +1944,6 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
fields = [
to_column((Fieldset(
TipoProposicao._meta.verbose_name, Field('tipo')), 12)),
to_column(
(Alert('teste',
css_class="ementa_materia hidden alert-info",
dismiss=False), 12)),
to_column(('descricao', 12)),
to_column(('observacao', 12)),

90
sapl/materia/migrations/0088_fix_view_materiaemtramitacao.py

@ -0,0 +1,90 @@
from django.db import migrations, models
_OLD_VIEW = """
create or replace view materia_materiaemtramitacao as
select m.id as id,
m.id as materia_id,
t.id as tramitacao_id,
t.unidade_tramitacao_destino_id as unidade_tramitacao_atual_id
from materia_materialegislativa m
inner join materia_tramitacao t on (m.id = t.materia_id)
where t.id = (select max(id) from materia_tramitacao where materia_id = m.id)
order by m.id DESC
"""
_NEW_VIEW = """
CREATE OR REPLACE VIEW materia_materiaemtramitacao AS
SELECT
m.id,
m.id AS materia_id,
t.id AS tramitacao_id,
t.unidade_tramitacao_destino_id AS unidade_tramitacao_atual_id
FROM materia_materialegislativa m
JOIN LATERAL (
SELECT
t.id,
t.unidade_tramitacao_destino_id
FROM materia_tramitacao t
WHERE t.materia_id = m.id
ORDER BY t.id DESC
LIMIT 1
) t ON true;
"""
class Migration(migrations.Migration):
# CREATE INDEX CONCURRENTLY cannot run inside a transaction.
atomic = False
dependencies = [
('materia', '0087_update_viewdb_materiaemtramitacao'),
]
operations = [
migrations.RunSQL(sql=_NEW_VIEW, reverse_sql=_OLD_VIEW),
migrations.SeparateDatabaseAndState(
database_operations=[
# Um CREATE INDEX CONCURRENTLY interrompido deixa o índice
# criado porém inválido, e o IF NOT EXISTS abaixo o pularia
# na próxima execução. DROP INDEX sem CONCURRENTLY porque
# este não roda dentro de um bloco DO.
migrations.RunSQL(
sql="""
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname = 'tram_materia_id_desc'
AND NOT i.indisvalid
) THEN
DROP INDEX tram_materia_id_desc;
END IF;
END $$;
""",
reverse_sql=migrations.RunSQL.noop,
),
migrations.RunSQL(
sql="""
CREATE INDEX CONCURRENTLY IF NOT EXISTS
tram_materia_id_desc
ON materia_tramitacao (materia_id, id DESC)
""",
reverse_sql="""
DROP INDEX CONCURRENTLY IF EXISTS
tram_materia_id_desc
""",
),
],
state_operations=[
migrations.AddIndex(
model_name='tramitacao',
index=models.Index(
fields=['materia', '-id'],
name='tram_materia_id_desc',
),
),
],
),
]

22
sapl/materia/models.py

@ -1154,6 +1154,25 @@ class Proposicao(models.Model):
update_fields=update_fields)
def pode_ver_proposicao(user, proposicao):
"""Texto original da proposição: nunca público. Operadores do autor veem
sempre; demais usuários seguem as permissões por estado da página de
detalhe (ProposicaoCrud.DetailView.dispatch)."""
if not user.is_authenticated:
return False
if proposicao.autor and proposicao.autor.operadores.filter(
id=user.id).exists():
return True
# devolução limpa data_envio: checar antes de tratar como rascunho
if proposicao.data_devolucao:
return user.has_perm('materia.detail_proposicao_devolvida')
if not proposicao.data_envio:
return False
if not proposicao.data_recebimento:
return user.has_perm('materia.detail_proposicao_enviada')
return user.has_perm('materia.detail_proposicao_incorporada')
class HistoricoProposicao(models.Model):
STATUS_PROPOSICAO = Choices(('E', 'ENVIADA', _('Enviada')),
('R', 'RECEBIDA', _('Recebida')),
@ -1350,6 +1369,9 @@ class Tramitacao(models.Model):
verbose_name = _('Tramitação')
verbose_name_plural = _('Tramitações')
ordering = ('-data_tramitacao', '-id')
indexes = [
models.Index(fields=['materia', '-id'], name='tram_materia_id_desc'),
]
def __str__(self):
return _('%(materia)s | %(status)s | %(data)s') % {

64
sapl/materia/tests/test_acessorio_em_lote.py

@ -0,0 +1,64 @@
import os
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from model_bakery import baker
import pytest
from sapl.materia.models import (DocumentoAcessorio, MateriaLegislativa,
TipoDocumento)
@pytest.fixture
def dirs(settings, tmp_path, monkeypatch):
media = tmp_path / 'media'
temp = tmp_path / 'temp'
media.mkdir()
temp.mkdir()
settings.MEDIA_ROOT = str(media)
monkeypatch.setattr('sapl.materia.views.MEDIA_ROOT', str(media))
monkeypatch.setattr('sapl.materia.views.get_tempfile_dir',
lambda: str(temp))
return media, temp
def _post(client, nome, conteudo):
materias = baker.make(MateriaLegislativa, _quantity=2)
tipo = baker.make(TipoDocumento, descricao='Parecer')
client.post(reverse('sapl.materia:acessorio_em_lote'), {
'materia_id': [m.pk for m in materias],
'tipo': tipo.descricao,
'nome': 'Parecer',
'autor': 'Comissão',
'data': '12/10/2026',
'ementa': 'Ementa',
'arquivo': SimpleUploadedFile(nome, conteudo),
})
return materias
@pytest.mark.django_db(transaction=False)
def test_arquivo_invalido_nao_grava_nada(admin_client, dirs):
media, temp = dirs
_post(admin_client, 'x.html', b'%PDF-1.4\n<html><script></script>')
assert not DocumentoAcessorio.objects.exists()
assert os.listdir(media) == []
assert os.listdir(temp) == []
@pytest.mark.django_db(transaction=False)
def test_arquivo_valido_copiado_para_cada_materia(admin_client, dirs):
media, temp = dirs
materias = _post(admin_client, 'parecer.pdf', b'%PDF-1.4\n%%EOF')
docs = DocumentoAcessorio.objects.filter(materia__in=materias)
assert docs.count() == 2
for doc in docs:
assert doc.arquivo.name == (
'sapl/public/documentoacessorio/2026/%s/parecer.pdf' % doc.pk)
assert (media / doc.arquivo.name).read_bytes() == b'%PDF-1.4\n%%EOF'
assert os.listdir(media) == ['sapl']
assert os.listdir(temp) == []

98
sapl/materia/tests/test_proposicao_confirmar.py

@ -0,0 +1,98 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils import timezone
from model_bakery import baker
import pytest
from sapl.base.models import AppConfig
from sapl.materia.models import (Proposicao, TipoMateriaLegislativa,
TipoProposicao)
from sapl.utils import SEPARADOR_HASH_PROPOSICAO, gerar_hash_arquivo
PROPOSICAO_PATH = 'sapl/private/proposicao/2026/1/proposicao.pdf'
PERMS_DOCADM = ['add_documentoadministrativo',
'change_documentoadministrativo',
'detail_documentoadministrativo']
def _login(client, app_label, *codenames):
user = baker.make(get_user_model(), username=app_label)
user.user_permissions.add(*Permission.objects.filter(
content_type__app_label=app_label, codename__in=codenames))
client.force_login(user)
return user
def _login_docadm(client):
user = _login(client, 'protocoloadm', *PERMS_DOCADM)
assert user.has_module_perms('protocoloadm')
def _login_protocolo(client):
_login(client, 'materia', 'detail_proposicao_enviada')
def _proposicao_enviada(settings, tmp_path):
settings.MEDIA_ROOT = str(tmp_path)
baker.make(AppConfig)
arquivo = tmp_path / PROPOSICAO_PATH
arquivo.parent.mkdir(parents=True)
arquivo.write_bytes(b'%PDF-1.4 proposicao')
tipo = baker.make(
TipoProposicao,
content_type=ContentType.objects.get_for_model(TipoMateriaLegislativa),
object_id=baker.make(TipoMateriaLegislativa).pk)
proposicao = baker.make(Proposicao, tipo=tipo,
texto_original=PROPOSICAO_PATH,
data_envio=timezone.now())
hasher = gerar_hash_arquivo(str(arquivo), str(proposicao.pk))
return proposicao, hasher[1:].split(SEPARADOR_HASH_PROPOSICAO)[0]
def _url_confirmar(proposicao, hash_):
return reverse('sapl.materia:proposicao-confirmar',
kwargs={'hash': hash_, 'pk': proposicao.pk})
@pytest.mark.django_db(transaction=False)
def test_receber_exige_permissao_de_proposicao(client):
_login_docadm(client)
url = reverse('sapl.materia:receber-proposicao')
assert client.get(url).status_code == 403
@pytest.mark.django_db(transaction=False)
def test_receber_operador_de_protocolo(client):
_login_protocolo(client)
url = reverse('sapl.materia:receber-proposicao')
assert client.get(url).status_code == 200
@pytest.mark.django_db(transaction=False)
def test_confirmar_exige_permissao_de_proposicao(client, settings, tmp_path):
proposicao, hash_ = _proposicao_enviada(settings, tmp_path)
_login_docadm(client)
response = client.get(_url_confirmar(proposicao, hash_))
assert response.status_code == 403
@pytest.mark.django_db(transaction=False)
def test_confirmar_operador_de_protocolo_ve_texto(client, settings,
tmp_path):
settings.DEBUG = False
proposicao, hash_ = _proposicao_enviada(settings, tmp_path)
_login_protocolo(client)
response = client.get(_url_confirmar(proposicao, hash_))
assert response.status_code == 200
assert proposicao.texto_original.url.encode() in response.content
texto = client.get(proposicao.texto_original.url)
assert texto.status_code == 200
assert texto['X-Accel-Redirect'] == '/media-interna/' + PROPOSICAO_PATH

126
sapl/materia/views.py

@ -5,6 +5,7 @@ import logging
import os
from random import choice
import shutil
import tempfile
from string import ascii_letters, digits
import time
import zipfile
@ -15,7 +16,8 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, ValidationError
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, \
SuspiciousFileOperation, ValidationError
from django.db import transaction
from django.db.models import Max, Q
from django.http import HttpResponse, JsonResponse
@ -52,11 +54,12 @@ from sapl.materia.forms import (AnexadaForm, AutoriaForm, AutoriaMultiCreateForm
from sapl.norma.models import LegislacaoCitada
from sapl.parlamentares.models import Legislatura
from sapl.protocoloadm.models import Protocolo
from sapl.settings import MAX_DOC_UPLOAD_SIZE, MEDIA_ROOT, RATE_LIMITER_RATE
from sapl.settings import MEDIA_ROOT, RATE_LIMITER_RATE
from sapl.utils import (autor_label, autor_modal, gerar_hash_arquivo, get_base_url,
get_client_ip, get_mime_type_from_file_extension, lista_anexados,
mail_service_configured, montar_row_autor, SEPARADOR_HASH_PROPOSICAO,
show_results_filter_set, get_tempfile_dir,
restringe_tipos_de_arquivo_txt, validar_arquivo,
google_recaptcha_configured, MultiFormatOutputMixin, ratelimit_ip)
from .forms import (AcessorioEmLoteFilterSet, AcompanhamentoMateriaForm,
@ -72,7 +75,7 @@ from .forms import (AcessorioEmLoteFilterSet, AcompanhamentoMateriaForm,
ReceberProposicaoForm, RelatoriaForm,
TramitacaoEmLoteFilterSet, TramitacaoEmLoteForm,
UnidadeTramitacaoForm, StatusTramitacaoFilterSet)
from .models import (AcompanhamentoMateria, Anexada, AssuntoMateria, Autoria, DespachoInicial,
from .models import (pode_ver_proposicao, AcompanhamentoMateria, Anexada, AssuntoMateria, Autoria, DespachoInicial,
DocumentoAcessorio, MateriaAssunto, MateriaLegislativa, Numeracao, Orgao,
Origem, Proposicao, RegimeTramitacao, Relatoria, StatusTramitacao,
TipoDocumento, TipoFimRelatoria, TipoMateriaLegislativa, TipoProposicao,
@ -104,14 +107,10 @@ def proposicao_texto(request, pk):
username = request.user.username
logger.debug('user=' + username +
'. Tentando obter objeto Proposicao com pk = {}.'.format(pk))
proposicao = Proposicao.objects.get(pk=pk)
proposicao = get_object_or_404(Proposicao, pk=pk)
if proposicao.texto_original:
if (not proposicao.data_recebimento and
not proposicao.autor.operadores.filter(
id=request.user.id
).exists()
):
if not pode_ver_proposicao(request.user, proposicao):
logger.error("user=" + username + ". Usuário ({}) não tem permissão para acessar o texto original."
.format(request.user.id))
messages.error(request, _(
@ -577,8 +576,8 @@ class ProposicaoRecebida(PermissionRequiredMixin, ListView):
return context
class ReceberProposicao(PermissionRequiredForAppCrudMixin, FormView):
app_label = sapl.protocoloadm.apps.AppConfig.label
class ReceberProposicao(PermissionRequiredMixin, FormView):
permission_required = ('materia.detail_proposicao_enviada', )
template_name = "crud/form.html"
form_class = ReceberProposicaoForm
@ -661,8 +660,8 @@ class RetornarProposicao(UpdateView):
request, *args, **kwargs)
class ConfirmarProposicao(PermissionRequiredForAppCrudMixin, UpdateView):
app_label = sapl.protocoloadm.apps.AppConfig.label
class ConfirmarProposicao(PermissionRequiredMixin, UpdateView):
permission_required = ('materia.detail_proposicao_enviada', )
template_name = "materia/confirmar_proposicao.html"
model = Proposicao
form_class = ConfirmarProposicaoForm, DevolverProposicaoForm
@ -2330,17 +2329,15 @@ class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView):
messages.add_message(request, messages.ERROR, msg)
return self.get(request, self.kwargs)
if request.FILES['arquivo'].size > MAX_DOC_UPLOAD_SIZE:
msg = _("O arquivo Anexo de Texto Integral deve ser menor que {0:.1f} MB, \
o tamanho atual desse arquivo é {1:.1f} MB"
.format((MAX_DOC_UPLOAD_SIZE / 1024) / 1024, (request.FILES['arquivo'].size / 1024) / 1024))
messages.add_message(request, messages.ERROR, msg)
arquivo = request.FILES['arquivo']
try:
validar_arquivo(arquivo, 'Arquivo')
restringe_tipos_de_arquivo_txt(arquivo)
except ValidationError as e:
for m in e.messages:
messages.add_message(request, messages.ERROR, m)
return self.get(request, self.kwargs)
tmp_name = os.path.join(MEDIA_ROOT, request.FILES['arquivo'].name)
with open(tmp_name, 'wb') as destination:
for chunk in request.FILES['arquivo'].chunks():
destination.write(chunk)
try:
doc_data = tz.localize(datetime.strptime(
request.POST['data'], "%d/%m/%Y"))
@ -2350,44 +2347,57 @@ class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView):
messages.add_message(request, messages.ERROR, msg)
self.logger.error("User={}. {}. Data inserida: {}".format(
username, str(msg), request.POST['data']))
os.remove(tmp_name)
return self.get(request, self.kwargs)
for materia_id in marcadas:
doc = DocumentoAcessorio()
doc.materia_id = materia_id
doc.tipo = tipo
doc.nome = request.POST['nome']
doc.data = doc_data
doc.autor = request.POST['autor']
doc.ementa = request.POST['ementa']
doc.arquivo.name = tmp_name
try:
doc.clean_fields()
except ValidationError as e:
for m in ['%s: %s' % (DocumentoAcessorio()._meta.get_field(k).verbose_name, '</br>'.join(v))
for k, v in e.message_dict.items()]:
# Insere as mensagens de erro no formato:
# 'verbose_name do nome do campo': 'mensagem de erro'
messages.add_message(request, messages.ERROR, m)
self.logger.error("User={}. {}. Nome do arquivo: {}.".format(
username, str(msg), request.FILES['arquivo'].name))
os.remove(tmp_name)
return self.get(request, self.kwargs)
doc.save()
diretorio = os.path.join(MEDIA_ROOT,
'sapl/public/documentoacessorio',
str(doc_data.year),
str(doc.id))
if not os.path.exists(diretorio):
os.makedirs(diretorio)
file_path = os.path.join(diretorio,
request.FILES['arquivo'].name)
shutil.copy2(tmp_name, file_path)
doc.arquivo.name = file_path.split(
MEDIA_ROOT + "/")[1] # Retira MEDIA_ROOT do nome
doc.save()
os.remove(tmp_name)
nome_arquivo = os.path.basename(arquivo.name)
if nome_arquivo in ('', '.', '..'):
messages.add_message(request, messages.ERROR,
_('Nome de arquivo inválido.'))
return self.get(request, self.kwargs)
# Fora de MEDIA_ROOT, para não ficar acessível em /media/
with tempfile.NamedTemporaryFile(dir=get_tempfile_dir(),
delete=False) as tmp:
for chunk in arquivo.chunks():
tmp.write(chunk)
try:
for materia_id in marcadas:
doc = DocumentoAcessorio()
doc.materia_id = materia_id
doc.tipo = tipo
doc.nome = request.POST['nome']
doc.data = doc_data
doc.autor = request.POST['autor']
doc.ementa = request.POST['ementa']
try:
doc.clean_fields(exclude=['arquivo'])
except ValidationError as e:
for m in ['%s: %s' % (DocumentoAcessorio()._meta.get_field(k).verbose_name, '</br>'.join(v))
for k, v in e.message_dict.items()]:
# Insere as mensagens de erro no formato:
# 'verbose_name do nome do campo': 'mensagem de erro'
messages.add_message(request, messages.ERROR, m)
self.logger.error("User={}. {}. Nome do arquivo: {}.".format(
username, str(m), arquivo.name))
return self.get(request, self.kwargs)
doc.save()
diretorio = os.path.join(MEDIA_ROOT,
'sapl/public/documentoacessorio',
str(doc_data.year),
str(doc.id))
if not os.path.exists(diretorio):
os.makedirs(diretorio)
diretorio = os.path.normpath(diretorio)
file_path = os.path.normpath(
os.path.join(diretorio, nome_arquivo))
if not file_path.startswith(diretorio + os.sep):
raise SuspiciousFileOperation(nome_arquivo)
shutil.copyfile(tmp.name, file_path)
doc.arquivo.name = file_path.split(
MEDIA_ROOT + "/")[1] # Retira MEDIA_ROOT do nome
doc.save()
finally:
os.remove(tmp.name)
msg = _('Documento(s) criado(s).')
messages.add_message(request, messages.SUCCESS, msg)

4
sapl/middleware/check_password.py

@ -1,5 +1,6 @@
import logging
from django.conf import settings
from django.shortcuts import redirect
from django.urls import reverse
@ -14,7 +15,8 @@ class CheckWeakPasswordMiddleware:
if request.user.is_authenticated and \
request.session.get('weak_password', False) and \
request.path != reverse('sapl.base:alterar_senha') and \
request.path != reverse('sapl.base:logout'):
request.path != reverse('sapl.base:logout') and \
not request.path.startswith(settings.MEDIA_URL):
logging.warning(f"Usuário {request.user.username} possui senha fraca.")
return redirect('sapl.base:alterar_senha')

365
sapl/painel/tests/tests.py

@ -1,3 +1,364 @@
# from django.test import TestCase
import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import Client
from django.urls import reverse
from model_bakery import baker
# Create your tests here.
from sapl.base.models import AppConfig as ConfiguracoesAplicacao
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.parlamentares.models import (Legislatura, Mandato, Parlamentar,
SessaoLegislativa, Votante)
from sapl.sessao.models import (ExpedienteMateria, OrdemDia,
PresencaOrdemDia, SessaoPlenaria,
TipoResultadoVotacao, TipoSessaoPlenaria,
VotoParlamentar)
NOMINAL = 2
LEITURA = 4
def _sessao_plenaria():
legislatura = baker.make(Legislatura)
sessao_legislativa = baker.make(SessaoLegislativa)
tipo = baker.make(TipoSessaoPlenaria)
return baker.make(SessaoPlenaria, legislatura=legislatura,
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1)
def _materia():
tipo_materia = baker.make(TipoMateriaLegislativa)
return baker.make(MateriaLegislativa, tipo=tipo_materia)
def _ordem_nominal_aberta(registro_aberto=False):
sessao = _sessao_plenaria()
materia = _materia()
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia,
tipo_votacao=NOMINAL, votacao_aberta=True,
registro_aberto=registro_aberto)
return sessao, ordem
def _ordem_leitura_aberta():
sessao = _sessao_plenaria()
materia = _materia()
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia,
tipo_votacao=LEITURA, votacao_aberta=True)
return sessao, ordem
def _votante(sessao, admin_user):
parlamentar = baker.make(Parlamentar, ativo=True)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar)
baker.make(Votante, parlamentar=parlamentar, user=admin_user)
return parlamentar
def _votante_com_client(sessao):
"""
Como _votante(), mas cria seu próprio usuário (não-superuser, só com a
permissão parlamentares.can_vote) e devolve um Client logado separado —
necessário para testar a corrida entre o operador e o vereador, que
precisam ser duas sessões/usuários distintos.
"""
parlamentar = baker.make(Parlamentar, ativo=True)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar)
user = get_user_model().objects.create_user(
username='votante-{}'.format(parlamentar.pk), password='x')
user.user_permissions.add(Permission.objects.get(codename='can_vote'))
baker.make(Votante, parlamentar=parlamentar, user=user)
client = Client()
client.force_login(user)
return parlamentar, client
def _registrar_url(sessao, ordem):
return reverse('sapl.sessao:votacaonominal',
kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id})
@pytest.mark.django_db(transaction=False)
def test_votante_view_envia_headers_never_cache(admin_client, admin_user):
sessao, ordem = _ordem_nominal_aberta()
_votante(sessao, admin_user)
response = admin_client.get(reverse('sapl.painel:voto_individual'))
assert response.status_code == 200
assert 'no-store' in response['Cache-Control']
@pytest.mark.django_db(transaction=False)
def test_votante_view_mostra_materia_quando_registro_fechado(admin_client, admin_user):
sessao, ordem = _ordem_nominal_aberta(registro_aberto=False)
_votante(sessao, admin_user)
response = admin_client.get(reverse('sapl.painel:voto_individual'))
assert response.status_code == 200
assert 'error_message' not in response.context
assert response.context['materia'] == ordem.materia
@pytest.mark.django_db(transaction=False)
def test_votante_view_mostra_erro_explicito_quando_registro_bloqueado(admin_client, admin_user):
"""
Regressão da causa raiz #1: quando a Mesa bloqueia novos votos
(registro_aberto=True), o vereador que ainda não votou precisa ver uma
mensagem explícita — não uma tela em branco sem explicação.
"""
sessao, ordem = _ordem_nominal_aberta(registro_aberto=True)
_votante(sessao, admin_user)
response = admin_client.get(reverse('sapl.painel:voto_individual'))
assert response.status_code == 200
assert 'Mesa encerrou o recebimento de novos votos' in response.context['error_message']
@pytest.mark.django_db(transaction=False)
def test_voto_do_vereador_prevalece_sobre_lote_do_operador(admin_client):
"""
Invariante: o voto do próprio vereador sempre prevalece sobre qualquer
valor provisório já existente para ele (ex.: de uma tentativa anterior
do operador, ou de qualquer outra origem). Complementa
test_salvar_votacao_nao_sobrescreve_voto_ja_registrado (que cobre a
ordem inversa: o operador não pode sobrescrever um voto real já
registrado) — aqui é o vereador votando por cima de um valor existente
através da view de verdade, não apenas o estado inicial simulado.
"""
sessao, ordem = _ordem_nominal_aberta()
vereador, votante_client = _votante_com_client(sessao)
# 1) Já existe um valor provisório "Não Votou" para o vereador (ex.: o
# <select> nunca foi tocado pelo operador). Esse valor nunca deveria
# impedir o vereador de votar de verdade.
baker.make(VotoParlamentar, ordem=ordem, parlamentar=vereador,
voto='Não Votou')
# 2) O vereador vota pelo tablet — seu voto real prevalece sobre o
# valor provisório.
response = votante_client.post(
reverse('sapl.painel:voto_individual'), {'voto': 'Sim'})
assert response.status_code == 302
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=vereador)
assert voto.voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_post_de_voto_bloqueado_nao_persiste(admin_client, admin_user):
sessao, ordem = _ordem_nominal_aberta(registro_aberto=True)
parlamentar = _votante(sessao, admin_user)
response = admin_client.post(
reverse('sapl.painel:voto_individual'), {'voto': 'Sim'})
assert response.status_code == 302
assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar).exists()
# voto_individual.html é avulso (não herda base.html): o erro precisa
# aparecer na própria tela do tablet.
pagina = admin_client.get(response.url)
assert 'A votação não está disponível para novos votos.' in pagina.content.decode()
@pytest.mark.django_db(transaction=False)
def test_post_rejeita_valor_de_voto_invalido(admin_client, admin_user):
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _votante(sessao, admin_user)
response = admin_client.post(
reverse('sapl.painel:voto_individual'), {'voto': 'Outro'})
assert response.status_code == 302
assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar).exists()
pagina = admin_client.get(response.url)
assert 'Voto inválido.' in pagina.content.decode()
@pytest.mark.django_db(transaction=False)
def test_propria_tela_nao_mostra_voto_de_outra_materia(admin_client):
"""
Regressão: votacao() buscava o voto do próprio vereador com
Q(ordem=ordem_dia) | Q(expediente=expediente) — quando a matéria atual
é uma OrdemDia, expediente é None, e Q(expediente=None) vira
"expediente_id IS NULL" no SQL, que é verdadeiro para QUALQUER voto de
ordem do dia daquele vereador, não só o desta matéria (idem para
ExpedienteMateria, cujas votações também têm ordem=None). Com .first()
sem ordenação, o vereador podia ver o voto de uma matéria antiga em vez
do voto (ou ausência de voto) da matéria atual — inclusive depois de
trocar o próprio voto, já que a query buscava a linha errada.
"""
sessao, ordem_antiga = _ordem_nominal_aberta()
vereador, votante_client = _votante_com_client(sessao)
baker.make(VotoParlamentar, ordem=ordem_antiga, parlamentar=vereador, voto='Sim')
ordem_antiga.votacao_aberta = False
ordem_antiga.save()
baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(),
tipo_votacao=NOMINAL, votacao_aberta=True, registro_aberto=False)
status_url = reverse('sapl.painel:voto_individual_status')
# Ainda não votou na matéria atual — não pode herdar o 'Sim' da antiga.
assert votante_client.get(status_url).json()['voto_parlamentar'] is None
votante_client.post(reverse('sapl.painel:voto_individual'), {'voto': 'Não'})
assert votante_client.get(status_url).json()['voto_parlamentar'] == 'Não'
assert VotoParlamentar.objects.get(
ordem=ordem_antiga, parlamentar=vereador).voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_get_dados_painel_nao_usa_etag_incompleto(admin_client):
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False)
sessao, ordem = _ordem_nominal_aberta()
url = reverse('sapl.painel:dados_painel', kwargs={'pk': sessao.pk})
primeira = admin_client.get(url)
assert primeira.status_code == 200
assert 'ETag' not in primeira
# Mesmo que um cliente antigo ainda envie o último valor conhecido, a
# resposta deve ser sempre recalculada para não ocultar mudanças em
# presenças, oradores ou configuração do painel.
segunda = admin_client.get(url, HTTP_IF_NONE_MATCH='"valor-antigo"')
assert segunda.status_code == 200
assert 'ETag' not in segunda
@pytest.mark.django_db(transaction=False)
def test_painel_exibe_nao_votou_para_parlamentar_sem_voto(admin_client):
"""
Quem estava presente e não votou chega ao painel como 'Não Votou' depois
do encerramento. Para dado legado sem VotoParlamentar (voto null), o
JavaScript mostra "Não votou" só com a votação registrada — com ela
aberta, voto vazio significa apenas "ainda não votou".
"""
baker.make(ConfiguracoesAplicacao, mostrar_voto=True,
mostrar_brasao_painel=False)
sessao, ordem = _ordem_nominal_aberta()
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada',
natureza='A')
votou = baker.make(Parlamentar, ativo=True)
nao_votou = baker.make(Parlamentar, ativo=True)
for parlamentar in (votou, nao_votou):
baker.make(PresencaOrdemDia, sessao_plenaria=sessao,
parlamentar=parlamentar)
baker.make(Mandato, parlamentar=parlamentar,
legislatura=sessao.legislatura)
resposta_registro = admin_client.post(_registrar_url(sessao, ordem), {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': [
'Sim:{}'.format(votou.pk),
'Não Votou:{}'.format(nao_votou.pk),
],
})
assert resposta_registro.status_code == 302
dados = admin_client.get(reverse(
'sapl.painel:dados_painel', kwargs={'pk': sessao.pk})).json()
parlamentar_sem_voto = next(
p for p in dados['presentes'] if p['parlamentar_id'] == nao_votou.pk)
assert parlamentar_sem_voto['voto'] == 'Não Votou'
painel = admin_client.get(reverse(
'sapl.painel:painel_principal', kwargs={'pk': sessao.pk}))
assert painel.status_code == 200
assert b'if (!parlamentar.voto && registrada)' in painel.content
assert 'Não votou'.encode() in painel.content
@pytest.mark.django_db(transaction=False)
def test_dados_painel_leitura_nao_tem_voto_individual(admin_client, admin_user):
"""
Contrato consumido pelo JS do painel (sapl/templates/painel/index.html):
para uma matéria em Leitura, tipo_votacao chega como a string 'Leitura'
(não o código inteiro) e nenhum parlamentar presente recebe um voto —
é isso que permite ao JS distinguir "não há voto individual nesta
matéria" de "ainda não votou".
"""
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False)
sessao, ordem = _ordem_leitura_aberta()
_votante(sessao, admin_user)
dados = admin_client.get(reverse(
'sapl.painel:dados_painel', kwargs={'pk': sessao.pk})).json()
assert dados['tipo_votacao'] == 'Leitura'
assert all(p['voto'] == '' for p in dados['presentes'])
@pytest.mark.django_db(transaction=False)
def test_dados_painel_sem_materia_nao_envia_tipo_votacao(admin_client, admin_user):
"""
Sem nenhuma matéria aberta ou já votada/lida (ex.: sessão solene), a
chave tipo_votacao não é enviada — o JS trata isso como "sem voto
individual" da mesma forma que trata 'Leitura'.
"""
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False)
sessao = _sessao_plenaria()
dados = admin_client.get(reverse(
'sapl.painel:dados_painel', kwargs={'pk': sessao.pk})).json()
assert 'tipo_votacao' not in dados
@pytest.mark.django_db(transaction=False)
def test_votante_status_reflete_estado_e_nao_exige_permissao_do_painel():
"""
votante_status precisa ser alcançável por uma conta só-Votante (sem
nenhuma permissão do app painel) — é por isso que não reaproveita
get_dados_painel, que exige check_permission (permissão de módulo do
app painel).
"""
sessao, ordem = _ordem_nominal_aberta()
vereador, votante_client = _votante_com_client(sessao)
status_url = reverse('sapl.painel:voto_individual_status')
resposta = votante_client.get(status_url)
assert resposta.status_code == 200
data = resposta.json()
assert data['materia_id'] == ordem.materia_id
assert data['status_message'] == 'Aguardando seu voto.'
assert data['voto_parlamentar'] is None
votante_client.post(reverse('sapl.painel:voto_individual'), {'voto': 'Não'})
resposta2 = votante_client.get(status_url)
data2 = resposta2.json()
assert data2['voto_parlamentar'] == 'Não'
assert 'encerramento da votação' in data2['status_message']
@pytest.mark.django_db(transaction=False)
def test_votante_status_com_duas_votacoes_abertas_nao_acumula_mensagens():
"""
Uma OrdemDia e uma ExpedienteMateria abertas ao mesmo tempo (as
constraints são por tabela): o poll do tablet devolve o erro em
error_message, em texto puro, sem empilhar mensagens na sessão.
"""
sessao, ordem = _ordem_nominal_aberta()
vereador, votante_client = _votante_com_client(sessao)
outra_sessao = _sessao_plenaria()
baker.make(ExpedienteMateria, sessao_plenaria=outra_sessao,
materia=_materia(), tipo_votacao=NOMINAL, votacao_aberta=True)
status_url = reverse('sapl.painel:voto_individual_status')
for _ in range(2):
data = votante_client.get(status_url).json()
assert 'mais de uma' in data['error_message']
assert '<' not in data['error_message']
assert '_messages' not in votante_client.session

12
sapl/painel/urls.py

@ -1,9 +1,9 @@
from django.conf.urls import url
from .apps import AppConfig
from .views import (cronometro_painel, get_dados_painel, painel_mensagem_view,
painel_parlamentar_view, painel_view, painel_votacao_view,
switch_painel, verifica_painel, votante_view)
from .views import (cronometro_painel, get_dados_painel, painel_view,
switch_painel, verifica_painel, votante_status,
votante_view)
app_name = AppConfig.name
@ -11,12 +11,8 @@ urlpatterns = [
url(r'^painel-principal/(?P<pk>\d+)$', painel_view,
name="painel_principal"),
url(r'^painel/(?P<pk>\d+)/dados$', get_dados_painel, name='dados_painel'),
url(r'^painel/mensagem$', painel_mensagem_view, name="painel_mensagem"),
url(r'^painel/parlamentar$', painel_parlamentar_view,
name='painel_parlamentar'),
url(r'^painel/switch-painel$', switch_painel,
name="switch_painel"),
url(r'^painel/votacao$', painel_votacao_view, name='painel_votacao'),
url(r'^painel/verifica-painel$', verifica_painel,
name="verifica_painel"),
url(r'^painel/cronometro$', cronometro_painel, name='cronometro_painel'),
@ -24,4 +20,6 @@ urlpatterns = [
url(r'^voto-individual/$', votante_view,
name='voto_individual'),
url(r'^voto-individual/status$', votante_status,
name='voto_individual_status'),
]

275
sapl/painel/views.py

@ -6,6 +6,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import (login_required, permission_required,
user_passes_test)
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.db import IntegrityError, transaction
from django.urls import reverse
from django.db.models import Q
from django.http import HttpResponse, JsonResponse
@ -44,6 +45,10 @@ def votacao_aberta(request):
Função que verifica se há somente 1 uma matéria aberta ou
nenhuma. É utilizada como uma função auxiliar para a view
votante_view.
A mensagem de erro volta como texto puro e é exibida pelo chamador em
error_message (inclusive no poll JSON do tablet), então não é
registrada em messages — senão cada poll empilharia uma cópia na sessão.
'''
logger = logging.getLogger(__name__)
username = request.user.username
@ -53,19 +58,13 @@ def votacao_aberta(request):
Q(expedientemateria__votacao_aberta=True)).distinct()
if len(votacoes_abertas) > 1:
msg_abertas = []
for v in votacoes_abertas:
msg_abertas.append('''<li><a href="%s">%s</a></li>''' % (
reverse('sapl.sessao:sessaoplenaria_detail',
kwargs={'pk': v.id}),
v.__str__()))
sessoes = ', '.join(str(v) for v in votacoes_abertas)
logger.info('user=' + username + '. Existe mais de uma votações aberta. Elas se encontram '
'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. '
'Para votar, peça para que o Operador feche-as.')
'nas seguintes Sessões: ' + sessoes + '. '
'Para votar, peça para que o Operador feche-as.')
msg = _('Existe mais de uma votações aberta. Elas se encontram '
'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. '
'Para votar, peça para que o Operador feche-as.')
messages.add_message(request, messages.INFO, msg)
'nas seguintes Sessões: ' + sessoes + '. '
'Para votar, peça para que o Operador feche-as.')
return None, msg
elif len(votacoes_abertas) == 1:
@ -78,18 +77,11 @@ def votacao_aberta(request):
numero_materias_abertas = len(ordens) + len(expedientes)
if numero_materias_abertas > 1:
sessao = str(votacoes_abertas.first())
logger.info('user=' + username + '. Existe mais de uma votação aberta na Sessão: ' +
('''<li><a href="%s">%s</a></li>''' % (
reverse('sapl.sessao:sessaoplenaria_detail',
kwargs={'pk': votacoes_abertas.first().id}),
votacoes_abertas.first().__str__())))
msg = _('Existe mais de uma votação aberta na Sessão: ' +
('''<li><a href="%s">%s</a></li>''' % (
reverse('sapl.sessao:sessaoplenaria_detail',
kwargs={'pk': votacoes_abertas.first().id}),
votacoes_abertas.first().__str__())) +
'Para votar, peça para que o Operador as feche.')
messages.add_message(request, messages.INFO, msg)
sessao)
msg = _('Existe mais de uma votação aberta na Sessão: ' + sessao +
'. Para votar, peça para que o Operador as feche.')
return None, msg
return votacoes_abertas.first(), None
@ -98,41 +90,35 @@ def votacao_aberta(request):
def votacao(context, context_vars):
logger = logging.getLogger(__name__)
parlamentar = context_vars['votante'].parlamentar
parlamentar_presente = False
if parlamentar.id in context_vars['presentes']:
parlamentar_presente = True
context_vars.update({'parlamentar': parlamentar})
else:
context.update({'error_message':
'Não há presentes na Sessão com a '
'matéria em votação.'})
if parlamentar_presente:
voto = []
if context_vars['ordem_dia']:
voto = VotoParlamentar.objects.filter(
ordem=context_vars['ordem_dia'])
elif context_vars['expediente']:
voto = VotoParlamentar.objects.filter(
expediente=context_vars['expediente'])
if voto:
try:
logger.debug(
"Tentando obter objeto VotoParlamentar com parlamentar={}.".format(context_vars['parlamentar']))
voto = voto.get(parlamentar=context_vars['parlamentar'])
context.update({'voto_parlamentar': voto.voto})
except ObjectDoesNotExist:
logger.error("Voto do parlamentar {} não computado.".format(context_vars['parlamentar']))
context.update(
{'voto_parlamentar': 'Voto não '
'computado.'})
else:
if parlamentar.id not in context_vars['presentes']:
logger.error("Parlamentar com id={} não está presente na "
"Ordem do Dia/Expediente em votação.".format(parlamentar.id))
context.update({'error_message':
'Você não está presente na '
'Ordem do Dia/Expediente em votação.'})
return context, context_vars
context_vars.update({'parlamentar': parlamentar})
if context_vars['ordem_dia']:
voto = VotoParlamentar.objects.filter(
ordem=context_vars['ordem_dia'], parlamentar=parlamentar).first()
elif context_vars['expediente']:
voto = VotoParlamentar.objects.filter(
expediente=context_vars['expediente'], parlamentar=parlamentar).first()
else:
voto = None
if voto:
context.update({
'voto_parlamentar': voto.voto,
'status_message': 'Voto registrado. Aguardando o encerramento '
'da votação pela Mesa.',
})
else:
context.update({'status_message': 'Aguardando seu voto.'})
return context, context_vars
@ -171,8 +157,11 @@ def sessao_votacao(context, context_vars):
ordem_dia = get_materia_aberta(pk)
expediente = get_materia_expediente_aberta(pk)
errors_msgs = {'materia': 'Não há nenhuma matéria aberta.',
'registro': 'A votação para esta matéria já encerrou.',
'tipo': 'A matéria aberta não é do tipo votação nominal.'}
'registro': 'A Mesa encerrou o recebimento de novos votos '
'para apurar o resultado desta matéria. '
'Aguarde a próxima matéria.',
'tipo': 'Esta matéria não é votada individualmente pelos '
'tablets — a Mesa registra o resultado diretamente.'}
materia_aberta = None
if ordem_dia:
@ -218,86 +207,126 @@ def can_vote(context, context_vars, request):
context_vars.update({'sessao': sessao})
if sessao and not msg:
context, context_vars = sessao_votacao(context, context_vars)
elif not sessao and msg:
return HttpResponseRedirect('/')
elif msg:
# Mais de uma votação aberta ao mesmo tempo (não deveria acontecer
# mais, dado o invariante garantido em abrir_votacao(), mas se
# acontecer é preferível mostrar isso explicitamente ao vereador do
# que redirecioná-lo silenciosamente para "/".
context.update({'error_message': msg})
else:
context.update(
{'error_message': 'Não há nenhuma sessão com matéria aberta.'})
return context, context_vars
@login_required
@permission_required('parlamentares.can_vote', raise_exception=True)
def votante_view(request):
logger = logging.getLogger(__name__)
def _resolve_votante_context(request):
"""
Resolve o estado atual de votação para o Votante autenticado — usado
tanto por votante_view (renderização completa) quanto por
votante_status (endpoint leve de polling), para as duas views
compartilharem a mesma lógica de can_vote() em vez de duplicá-la.
"""
username = request.user.username
if not Votante.objects.filter(user=request.user).exists():
logger.warning(
logging.getLogger(__name__).warning(
f'user={username} sem cadastro de Votante tentou acessar /voto-individual/.'
)
raise PermissionDenied
template_name = 'painel/voto_individual.html'
context = {'head_title': str(_('Votação Individual'))}
context_vars = {'votante': Votante.objects.get(user=request.user)}
return can_vote(context, context_vars, request)
@never_cache
@login_required
@permission_required('parlamentares.can_vote', raise_exception=True)
def votante_status(request):
"""
Endpoint leve para o polling automático do tablet (voto_individual.html)
— devolve só o suficiente pra decidir se algo mudou desde o último
carregamento da página, sem o custo de renderizar a página inteira a
cada poll. Não reaproveita get_dados_painel: aquele endpoint exige a
permissão do módulo painel (check_permission), que uma conta só-Votante
não necessariamente tem.
"""
context, context_vars = _resolve_votante_context(request)
materia = context.get('materia')
return JsonResponse({
'materia_id': materia.id if materia else None,
'error_message': context.get('error_message'),
'status_message': context.get('status_message'),
'voto_parlamentar': context.get('voto_parlamentar'),
})
@never_cache
@login_required
@permission_required('parlamentares.can_vote', raise_exception=True)
def votante_view(request):
logger = logging.getLogger(__name__)
username = request.user.username
context, context_vars = can_vote(context, context_vars, request)
template_name = 'painel/voto_individual.html'
context, context_vars = _resolve_votante_context(request)
# Salva o voto
if request.method == 'POST':
if context_vars['ordem_dia']:
try:
logger.info("user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e "
"ordem={}. "
.format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto = VotoParlamentar.objects.get(
parlamentar=context_vars['parlamentar'],
ordem=context_vars['ordem_dia'])
except ObjectDoesNotExist:
logger.error("user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e ordem={}. "
"Criando objeto. "
.format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto = VotoParlamentar.objects.create(
parlamentar=context_vars['parlamentar'],
voto=request.POST['voto'],
user=request.user,
ip=get_client_ip(request),
ordem=context_vars['ordem_dia'])
else:
logger.info("user=" + username + ". VotoParlamentar para parlamentar={} e ordem={} obtido com sucesso."
.format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto.voto = request.POST['voto']
voto.ip = get_client_ip(request)
voto.user = request.user
voto.save()
elif context_vars['expediente']:
voto_submetido = request.POST.get('voto')
votos_validos = ('Sim', 'Não', 'Abstenção')
ordem = context_vars.get('ordem_dia')
expediente = context_vars.get('expediente')
parlamentar = context_vars.get('parlamentar')
if voto_submetido not in votos_validos:
messages.error(request, _('Voto inválido.'))
elif parlamentar and (ordem or expediente):
try:
logger.info(
"user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e expediente={}."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto = VotoParlamentar.objects.get(
parlamentar=context_vars['parlamentar'],
expediente=context_vars['expediente'])
except ObjectDoesNotExist:
logger.error(
"user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e expediente={}. Criando objeto."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto = VotoParlamentar.objects.create(
parlamentar=context_vars['parlamentar'],
voto=request.POST['voto'],
user=request.user,
ip=get_client_ip(request),
expediente=context_vars['expediente'])
else:
logger.info(
"user=" + username + ". VotoParlamentar para parlamentar={} e expediente={} obtido com sucesso."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto.voto = request.POST['voto']
voto.ip = get_client_ip(request)
voto.user = request.user
voto.save()
with transaction.atomic():
if ordem:
materia = OrdemDia.objects.select_for_update().get(pk=ordem.pk)
fase_sessao = {'ordem': materia}
else:
materia = ExpedienteMateria.objects.select_for_update().get(
pk=expediente.pk)
fase_sessao = {'expediente': materia}
presenca_model = (PresencaOrdemDia if ordem
else SessaoPlenariaPresenca)
esta_presente = presenca_model.objects.filter(
sessao_plenaria_id=materia.sessao_plenaria_id,
parlamentar=parlamentar).exists()
if (not esta_presente or not materia.votacao_aberta or
materia.registro_aberto or
materia.tipo_votacao != VOTACAO_NOMINAL or
RegistroVotacao.objects.filter(**fase_sessao).exists()):
messages.error(
request,
_('A votação não está mais disponível para novos votos.'))
else:
# get_or_create já cria num savepoint e, se der
# IntegrityError, refaz o get() no mesmo queryset
# (com select_for_update).
voto, _created = (VotoParlamentar.objects
.select_for_update().get_or_create(
parlamentar=parlamentar,
**fase_sessao))
voto.voto = voto_submetido
voto.ip = get_client_ip(request)
voto.user = request.user
voto.save()
logger.info(
"user=%s. VotoParlamentar para parlamentar=%s "
"salvo com sucesso.", username, parlamentar)
except IntegrityError:
logger.exception(
"user=%s. Falha de integridade ao salvar voto de parlamentar=%s.",
username, parlamentar)
messages.error(request, _('Não foi possível registrar o voto. Tente novamente.'))
else:
messages.error(
request, _('A votação não está disponível para novos votos.'))
return HttpResponseRedirect(
reverse('sapl.painel:voto_individual'))
@ -327,21 +356,6 @@ def verifica_painel(request):
return resposta
@user_passes_test(check_permission)
def painel_mensagem_view(request):
return render(request, 'painel/mensagem.html')
@user_passes_test(check_permission)
def painel_parlamentar_view(request):
return render(request, 'painel/parlamentares.html')
@user_passes_test(check_permission)
def painel_votacao_view(request):
return render(request, 'painel/votacao.html')
@user_passes_test(check_permission)
def cronometro_painel(request):
request.session[request.GET['tipo']] = request.GET['action']
@ -550,6 +564,7 @@ def get_votos(response, materia, mostrar_voto):
return response
@never_cache
@user_passes_test(check_permission)
def get_dados_painel(request, pk):
sessao = SessaoPlenaria.objects.get(id=pk)

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')),
(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,

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

13
sapl/protocoloadm/models.py

@ -6,7 +6,8 @@ from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from model_utils import Choices
from sapl.base.models import Autor, AppConfig as SaplAppConfig
from sapl.base.models import Autor, AppConfig as SaplAppConfig, \
DOC_ADM_OSTENSIVO
from sapl.materia.models import TipoMateriaLegislativa, UnidadeTramitacao,\
MateriaLegislativa
from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, texto_upload_path,
@ -158,6 +159,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,
@ -341,6 +345,13 @@ class DocumentoAdministrativo(models.Model):
update_fields=update_fields)
def pode_ver_documento_adm(user, documento):
"""Mesmas regras da página de detalhe do documento (CRUD)."""
if SaplAppConfig.attr('documentos_administrativos') == DOC_ADM_OSTENSIVO:
return not (documento.restrito and user.is_anonymous)
return user.has_perm('protocoloadm.detail_documentoadministrativo')
class DocumentoAcessorioAdministrativo(models.Model):
documento = models.ForeignKey(DocumentoAdministrativo,
on_delete=models.PROTECT)

163
sapl/protocoloadm/tests/test_docadm_acesso.py

@ -0,0 +1,163 @@
from io import BytesIO
from PyPDF4 import PdfFileWriter
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from model_bakery import baker
import pytest
from sapl.base.models import AppConfig
from sapl.protocoloadm.models import (DocumentoAcessorioAdministrativo,
DocumentoAdministrativo)
@pytest.fixture(autouse=True)
def limpa_cache_appconfig():
# o cache em arquivo sobrevive ao rollback do teste
cache.clear()
yield
cache.clear()
@pytest.fixture
def media_tmp(settings, tmp_path):
settings.MEDIA_ROOT = str(tmp_path)
return tmp_path
def _config(comportamento):
cache.clear()
AppConfig.objects.all().delete()
baker.make(AppConfig, documentos_administrativos=comportamento)
def _documento(restrito):
documento = baker.make(DocumentoAdministrativo, restrito=restrito)
documento.texto_integral = SimpleUploadedFile(
'texto.pdf', b'%PDF-1.4 conteudo', content_type='application/pdf')
documento.save()
return documento
def _client(client, perfil):
if perfil == 'anonimo':
return client
user = baker.make(get_user_model(), username=perfil)
if perfil == 'com_permissao':
ct = ContentType.objects.get_for_model(DocumentoAdministrativo)
perm, _ = Permission.objects.get_or_create(
codename='detail_documentoadministrativo', content_type=ct,
defaults={'name': 'detail'})
user.user_permissions.add(perm)
client.force_login(user)
return client
ACESSO = [
# (comportamento, restrito, perfil, pode_ver)
('O', False, 'anonimo', True),
('O', False, 'sem_permissao', True),
('O', False, 'com_permissao', True),
('O', True, 'anonimo', False),
('O', True, 'sem_permissao', True),
('O', True, 'com_permissao', True),
('R', False, 'anonimo', False),
('R', False, 'sem_permissao', False),
('R', False, 'com_permissao', True),
('R', True, 'anonimo', False),
('R', True, 'sem_permissao', False),
('R', True, 'com_permissao', True),
]
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('comportamento,restrito,perfil,pode_ver', ACESSO)
def test_doc_texto_integral_acesso(client, media_tmp, comportamento,
restrito, perfil, pode_ver):
_config(comportamento)
documento = _documento(restrito)
client = _client(client, perfil)
response = client.get(reverse('sapl.protocoloadm:doc_texto_integral',
kwargs={'pk': documento.pk}))
assert response.status_code == (200 if pode_ver else 404)
def _pdf():
writer = PdfFileWriter()
writer.addBlankPage(72, 72)
data = BytesIO()
writer.write(data)
return data.getvalue()
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('comportamento,restrito,perfil,pode_ver', ACESSO)
def test_pdf_docacessorios_acesso(client, media_tmp, monkeypatch,
comportamento, restrito, perfil, pode_ver):
# create_pdf_docacessorios usa o MEDIA_ROOT importado de sapl.settings
monkeypatch.setattr('sapl.protocoloadm.views.MEDIA_ROOT', str(media_tmp))
_config(comportamento)
documento = baker.make(DocumentoAdministrativo, restrito=restrito)
acessorio = baker.make(DocumentoAcessorioAdministrativo,
documento=documento)
acessorio.arquivo = SimpleUploadedFile(
'acessorio.pdf', _pdf(), content_type='application/pdf')
acessorio.save()
client = _client(client, perfil)
response = client.get(reverse('sapl.protocoloadm:merge_docacessorios',
kwargs={'pk': documento.pk}))
if pode_ver:
assert response.status_code == 200
assert response['Content-Type'] == 'application/pdf'
assert response.content.startswith(b'%PDF')
else:
assert response.status_code == 404
@pytest.mark.django_db(transaction=False)
def test_pdf_docacessorios_sem_acessorios_volta_para_lista(client):
_config('O')
documento = baker.make(DocumentoAdministrativo, restrito=False)
response = client.get(reverse('sapl.protocoloadm:merge_docacessorios',
kwargs={'pk': documento.pk}))
assert response.status_code == 302
assert response['Location'] == reverse(
'sapl.protocoloadm:documentoacessorioadministrativo_list',
kwargs={'pk': documento.pk})
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('url_name', ['doc_texto_integral',
'merge_docacessorios'])
def test_docadm_pk_inexistente(client, url_name):
_config('O')
response = client.get(reverse('sapl.protocoloadm:' + url_name,
kwargs={'pk': 999999}))
assert response.status_code == 404
@pytest.mark.django_db(transaction=False)
def test_detalhe_e_arquivo_usam_a_mesma_configuracao(client, media_tmp):
# com mais de uma linha em AppConfig, página e arquivo leem a mesma
_config('O')
baker.make(AppConfig, documentos_administrativos='R')
documento = _documento(False)
detalhe = client.get(reverse(
'sapl.protocoloadm:documentoadministrativo_detail',
kwargs={'pk': documento.pk}))
texto = client.get(reverse('sapl.protocoloadm:doc_texto_integral',
kwargs={'pk': documento.pk}))
assert detalhe.status_code == 302
assert texto.status_code == 404

38
sapl/protocoloadm/views.py

@ -32,7 +32,8 @@ from django_filters.views import FilterView
import sapl
from sapl.base.email_utils import do_envia_email_confirmacao
from sapl.base.models import Autor, CasaLegislativa, AppConfig
from sapl.base.models import (Autor, CasaLegislativa, AppConfig,
DOC_ADM_OSTENSIVO)
from sapl.comissoes.models import Comissao
from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud, make_pagination,
RP_LIST, RP_DETAIL)
@ -42,7 +43,7 @@ from sapl.parlamentares.models import Legislatura, Parlamentar
from sapl.protocoloadm.forms import VinculoDocAdminMateriaForm,\
VinculoDocAdminMateriaEmLoteFilterSet
from sapl.protocoloadm.models import Protocolo, DocumentoAdministrativo,\
VinculoDocAdminMateria
VinculoDocAdminMateria, pode_ver_documento_adm
from sapl.relatorios.views import relatorio_doc_administrativos
from sapl.utils import (create_barcode, get_base_url, get_client_ip,
get_mime_type_from_file_extension, lista_anexados,
@ -101,15 +102,9 @@ def recuperar_materia_protocolo(request):
def doc_texto_integral(request, pk):
can_see = True
documento = get_object_or_404(DocumentoAdministrativo, pk=pk)
if not request.user.is_authenticated:
app_config = AppConfig.objects.last()
if app_config and app_config.documentos_administrativos == 'R':
can_see = False
if can_see:
documento = DocumentoAdministrativo.objects.get(pk=pk)
if pode_ver_documento_adm(request.user, documento):
if documento.texto_integral:
arquivo = documento.texto_integral
@ -127,6 +122,8 @@ def doc_texto_integral(request, pk):
def get_pdf_docacessorios(request, pk):
documento_administrativo = get_object_or_404(DocumentoAdministrativo, pk=pk)
if not pode_ver_documento_adm(request.user, documento_administrativo):
raise Http404
logger = logging.getLogger(__name__)
username = 'Usuário anônimo' if request.user.is_anonymous else request.user.username
try:
@ -137,21 +134,24 @@ def get_pdf_docacessorios(request, pk):
logger.error("user= {}.Não há arquivos cadastrados".format(username))
msg = _('Não há arquivos cadastrados nesses documentos acessórios.')
messages.add_message(request, messages.ERROR, msg)
return redirect(reverse('sapl.materia:documentoacessorio_list',
kwargs={'pk': pk}))
return redirect(reverse(
'sapl.protocoloadm:documentoacessorioadministrativo_list',
kwargs={'pk': pk}))
except Exception as e:
logger.error("user= {}.Um erro inesperado ocorreu na criação do pdf de documentos acessorios: {}"
.format(username, str(e)))
msg = _('Um erro inesperado ocorreu. Entre em contato com o suporte do SAPL.')
messages.add_message(request, messages.ERROR, msg)
return redirect(reverse('sapl.materia:documentoacessorio_list',
kwargs={'pk': pk}))
return redirect(reverse(
'sapl.protocoloadm:documentoacessorioadministrativo_list',
kwargs={'pk': pk}))
if not data:
msg = _('Não há nenhum documento acessório PDF cadastrado.')
messages.add_message(request, messages.ERROR, msg)
return redirect(reverse('sapl.materia:documentoacessorio_list',
kwargs={'pk': pk}))
return redirect(reverse(
'sapl.protocoloadm:documentoacessorioadministrativo_list',
kwargs={'pk': pk}))
response = HttpResponse(data, content_type='application/pdf')
response['Content-Disposition'] = ('attachment; filename="%s"'
@ -401,8 +401,8 @@ class AcompanhamentoDocumentoView(CreateView):
class DocumentoAdministrativoMixin:
def has_permission(self):
app_config = AppConfig.objects.last()
if app_config and app_config.documentos_administrativos == 'O':
# mesmo acessor de pode_ver_documento_adm, usado nos arquivos
if AppConfig.attr('documentos_administrativos') == DOC_ADM_OSTENSIVO:
return True
return super().has_permission()
@ -498,7 +498,7 @@ class DocumentoAdministrativoCrud(Crud):
def get(self, *args, **kwargs):
pk = self.kwargs['pk']
documento = DocumentoAdministrativo.objects.get(id=pk)
documento = get_object_or_404(DocumentoAdministrativo, pk=pk)
if documento.restrito and self.request.user.is_anonymous:
return redirect('/')
return super(Crud.DetailView, self).get(args, kwargs)

4
sapl/relatorios/forms.py

@ -551,9 +551,7 @@ class RelatorioMateriasTramitacaoFilterSet(django_filters.FilterSet):
@property
def qs(self):
parent = super(RelatorioMateriasTramitacaoFilterSet, self).qs
return parent.distinct().order_by(
'-materia__ano', 'materia__tipo', '-materia__numero'
)
return parent.order_by('-materia__ano', 'materia__tipo', '-materia__numero')
class Meta:
model = MateriaEmTramitacao

35
sapl/relatorios/templates/pdf_sessao_plenaria_gerar.py

@ -7,6 +7,8 @@
import os
import time
import logging
from xml.sax.saxutils import escape
from django.template.defaultfilters import safe
from django.utils.html import strip_tags
from trml2pdf import parseString
@ -198,6 +200,33 @@ def presenca(lst_presenca_sessao, lst_ausencia_sessao):
return tmp
def correspondencias(lst_correspondencias):
tmp = ''
if lst_correspondencias:
tmp += '\t\t<para style="P1">Correspondências</para>\n'
tmp += '\t\t<para style="P2">\n'
tmp += '\t\t\t<font color="white"> <br/></font>\n'
tmp += '\t\t</para>\n'
tmp += '<blockTable style="repeater" repeatRows="1" colWidths="3cm,4cm,3.5cm,6.5cm">\n'
tmp += '<tr><td>Tipo</td><td>Documento</td><td>Interessado</td><td>Assunto</td></tr>\n'
for c in lst_correspondencias:
tmp += '<tr>'
tmp += '<td><para style="P4">' + \
escape(str(c['tipo'])) + '</para></td>\n'
tmp += '<td><para style="P4">' + escape(str(c['epigrafe'])) + \
' - ' + escape(str(c['data'])) + '</para></td>\n'
tmp += '<td><para style="P4">' + \
escape(str(c['interessado'] or '')) + '</para></td>\n'
tmp += '<td><para style="P4">' + \
escape(str(c['assunto'] or '')) + '</para></td>\n'
tmp += '</tr>\n'
tmp += '</blockTable>\n'
tmp += '\t\t<para style="P2">\n'
tmp += '\t\t\t<font color="white"> <br/></font>\n'
tmp += '\t\t</para>\n'
return tmp
def expedientes(lst_expedientes):
tmp = ''
if lst_expedientes:
@ -415,7 +444,7 @@ def consideracoes(lst_consideracoes):
return tmp
def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_presenca_sessao, lst_ausencia_sessao, lst_expedientes, lst_expediente_materia, lst_expediente_materia_vot_nom, lst_oradores_expediente, lst_presenca_ordem_dia, lst_votacao, lst_votacao_vot_nom, lst_oradores_ordemdia, lst_oradores, lst_ocorrencias, lst_consideracoes):
def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_presenca_sessao, lst_ausencia_sessao, lst_correspondencias, lst_expedientes, lst_expediente_materia, lst_expediente_materia_vot_nom, lst_oradores_expediente, lst_presenca_ordem_dia, lst_votacao, lst_votacao_vot_nom, lst_oradores_ordemdia, lst_oradores, lst_ocorrencias, lst_consideracoes):
"""
"""
arquivoPdf = str(int(time.time() * 100)) + ".pdf"
@ -440,6 +469,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_
ordenacao = ResumoOrdenacao.objects.first()
dict_ord_template = {
'cont_mult': multimidia(cont_mult_dic),
'correspondencia': correspondencias(lst_correspondencias),
'exp': expedientes(lst_expedientes),
'id_basica': inf_basicas(inf_basicas_dic),
'lista_p': presenca(lst_presenca_sessao, lst_ausencia_sessao),
@ -473,6 +503,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_
tmp += dict_ord_template[ordenacao.decimo_terceiro]
tmp += dict_ord_template[ordenacao.decimo_quarto]
tmp += dict_ord_template[ordenacao.decimo_quinto]
tmp += dict_ord_template[ordenacao.decimo_sexto]
except KeyError as e:
logger.error("KeyError: " + str(e) + ". Erro ao tentar utilizar "
"configuração de ordenação. Utilizando ordenação padrão.")
@ -480,6 +511,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_
tmp += multimidia(cont_mult_dic)
tmp += mesa(lst_mesa)
tmp += presenca(lst_presenca_sessao, lst_ausencia_sessao)
tmp += correspondencias(lst_correspondencias)
tmp += expedientes(lst_expedientes)
tmp += expediente_materia(lst_expediente_materia)
tmp += expediente_materia_vot_nom(lst_expediente_materia_vot_nom)
@ -497,6 +529,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_
tmp += multimidia(cont_mult_dic)
tmp += mesa(lst_mesa)
tmp += presenca(lst_presenca_sessao, lst_ausencia_sessao)
tmp += correspondencias(lst_correspondencias)
tmp += expedientes(lst_expedientes)
tmp += expediente_materia(lst_expediente_materia)
tmp += expediente_materia_vot_nom(lst_expediente_materia_vot_nom)

125
sapl/relatorios/views.py

@ -9,6 +9,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, Q, F
from django.db.models.functions import Coalesce
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
@ -24,7 +25,7 @@ from sapl.materia.models import (Autoria, MateriaLegislativa, Numeracao,
TipoMateriaLegislativa, MateriaEmTramitacao, DocumentoAcessorio, TipoDocumento,
StatusTramitacao)
from sapl.norma.models import TipoNormaJuridica, NormaJuridica
from sapl.parlamentares.models import Filiacao, Parlamentar, SessaoLegislativa, Legislatura
from sapl.parlamentares.models import Parlamentar, SessaoLegislativa, Legislatura
from sapl.protocoloadm.models import (DocumentoAdministrativo, Protocolo,
TramitacaoAdministrativo, StatusTramitacaoAdministrativo,
TipoDocumentoAdministrativo)
@ -40,7 +41,8 @@ from sapl.sessao.models import (ExpedienteMateria, ExpedienteSessao,
OrdemDia, PresencaOrdemDia, SessaoPlenaria,
SessaoPlenariaPresenca, OcorrenciaSessao,
RegistroVotacao, VotoParlamentar, OradorOrdemDia,
ConsideracoesFinais, ResumoOrdenacao, TipoSessaoPlenaria)
ConsideracoesFinais, ResumoOrdenacao, TipoSessaoPlenaria,
restringe_sessoes_visiveis)
from sapl.sessao.views import (get_identificacao_basica, get_mesa_diretora,
get_presenca_sessao, get_expedientes,
get_materias_expediente, get_oradores_expediente,
@ -93,7 +95,8 @@ def get_imagem(casa):
def get_rodape(casa):
if len(casa.cep) == 8:
cep = casa.cep[:4] + "-" + casa.cep[5:]
# CEP tem 8 dígitos e se formata como XXXXX-XXX.
cep = casa.cep[:5] + "-" + casa.cep[5:]
else:
cep = ""
@ -543,6 +546,8 @@ def get_sessao_plenaria(sessao, casa, user):
if sessao.tipo.nome == 'Solene':
inf_basicas_dic["tema_solene"] = sessao.tema_solene
data_sessao = sessao.data_fim if sessao.data_fim else sessao.data_inicio
# Conteudo multimidia
cont_mult_dic = {
"multimidia_audio": str(sessao.url_audio) if sessao.url_audio else "Indisponível",
@ -554,9 +559,11 @@ def get_sessao_plenaria(sessao, casa, user):
for composicao in IntegranteMesa.objects.select_related('parlamentar', 'cargo') \
.filter(sessao_plenaria=sessao) \
.order_by('cargo_id'):
partido_sigla = Filiacao.objects.filter(
parlamentar=composicao.parlamentar).first()
sigla = '' if not partido_sigla else partido_sigla.partido.sigla
# Antes usava Filiacao...first() (filiação mais recente), enquanto a
# tela usava .last() (a mais antiga) — e as listas de presença deste
# mesmo PDF já usavam filiacao_data. Unifica no critério das listas
# de presença e do Resumo: o partido vigente em data_inicio.
sigla = filiacao_data(composicao.parlamentar, sessao.data_inicio)
lst_mesa.append({
'nom_parlamentar': composicao.parlamentar.nome_parlamentar,
'sgl_partido': sigla,
@ -653,7 +660,7 @@ def get_sessao_plenaria(sessao, casa, user):
"num_ordem": expediente_materia.numero_ordem,
"id_materia": f"{materia.tipo.sigla} {materia.tipo.descricao} {str(materia.numero)}/{str(materia.ano)}",
"des_numeracao": ' ',
"des_turno": get_turno(materia)[0],
"des_turno": get_turno(materia, data_sessao)[0],
"situacao": materia_em_tramitacao.tramitacao.status if materia_em_tramitacao else _("Não informada"),
"txt_ementa": str(materia.ementa),
"materia_observacao": materia.observacao,
@ -712,7 +719,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 = []
@ -733,13 +740,11 @@ def get_sessao_plenaria(sessao, casa, user):
for orador_expediente in OradorExpediente.objects.filter(sessao_plenaria=sessao).order_by('numero_ordem'):
parlamentar = Parlamentar.objects.get(
id=orador_expediente.parlamentar.id)
partido_sigla = Filiacao.objects.filter(
parlamentar=parlamentar).first()
lst_oradores_expediente.append({
"num_ordem": orador_expediente.numero_ordem,
"nom_parlamentar": parlamentar.nome_parlamentar,
"observacao": orador_expediente.observacao,
"sgl_partido": "" if not partido_sigla else partido_sigla.partido.sigla
"sgl_partido": filiacao_data(parlamentar, sessao.data_inicio)
})
# Lista presença na ordem do dia
@ -775,7 +780,7 @@ def get_sessao_plenaria(sessao, casa, user):
materia_em_tramitacao = materia.materiaemtramitacao_set.first()
dic_votacao.update({
"des_turno": get_turno(materia)[0],
"des_turno": get_turno(materia, data_sessao)[0],
# https://github.com/interlegis/sapl/issues/1009
"txt_ementa": html.unescape(materia.ementa),
"materia_observacao": materia.observacao,
@ -825,7 +830,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 = []
@ -850,27 +855,23 @@ def get_sessao_plenaria(sessao, casa, user):
for orador_ordemdia in oradores_ordem_dia:
parlamentar_orador = Parlamentar.objects.get(
id=orador_ordemdia.parlamentar.id)
sigla_partido = Filiacao.objects.filter(
parlamentar=parlamentar_orador).first()
lst_oradores_ordemdia.append({
"num_ordem": orador_ordemdia.numero_ordem,
"nome_parlamentar": parlamentar_orador.nome_parlamentar,
"observacao": orador_ordemdia.observacao,
"sigla": "" if not sigla_partido else sigla_partido.partido.sigla
"sigla": filiacao_data(parlamentar_orador, sessao.data_inicio)
})
# Lista dos oradores nas Explicações Pessoais
lst_oradores = []
for orador in Orador.objects.select_related('parlamentar').filter(sessao_plenaria=sessao).order_by('numero_ordem'):
parlamentar = orador.parlamentar
partido_sigla = orador.parlamentar.filiacao_set.select_related(
'partido', 'parlamentar').first()
lst_oradores.append({
"num_ordem": orador.numero_ordem,
"nom_parlamentar": parlamentar.nome_parlamentar,
"observacao": orador.observacao,
"sgl_partido": "" if not partido_sigla else partido_sigla.partido.sigla
"sgl_partido": filiacao_data(parlamentar, sessao.data_inicio)
})
# Ocorrências da Sessão
@ -931,11 +932,20 @@ def get_sessao_plenaria(sessao, casa, user):
lst_consideracoes)
def get_turno(materia):
def get_turno(materia, data_sessao=None):
"""Turno e situação de uma matéria.
`data_sessao` corta as tramitações posteriores à sessão, como já fazem
`get_materias_expediente` / `get_materias_ordem_do_dia` em
sapl.sessao.views. Sem esse corte, o PDF do resumo exibia um turno
registrado depois da sessão que ele documenta.
"""
descricao_turno = ''
descricao_tramitacao = ''
tramitacoes = materia.tramitacao_set.order_by(
'-data_tramitacao', '-id').all()
if data_sessao:
tramitacoes = tramitacoes.filter(data_tramitacao__lte=data_sessao)
tramitacoes_turno = tramitacoes.exclude(turno="")
if tramitacoes:
@ -970,7 +980,8 @@ def relatorio_sessao_plenaria(request, pk):
try:
logger.debug("user=" + username +
". Tentando obter SessaoPlenaria com id={}.".format(pk))
sessao = SessaoPlenaria.objects.get(id=pk)
sessao = restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), request.user).get(id=pk)
except ObjectDoesNotExist as e:
logger.error("user=" + username +
". Essa SessaoPlenaria não existe (pk={}). ".format(pk) + str(e))
@ -1144,8 +1155,29 @@ def relatorio_etiqueta_protocolo(request, nro, ano):
def get_etiqueta_protocolos(prots):
prot_list = list(prots)
if not prot_list:
return []
# Pre-fetch MateriaLegislativa for all protocols in one query.
materia_query = Q()
for p in prot_list:
materia_query |= Q(numero_protocolo=p.numero, ano=p.ano)
materias_map = {
(m.numero_protocolo, m.ano): m
for m in MateriaLegislativa.objects.filter(
materia_query).select_related('tipo')
}
# Pre-fetch DocumentoAdministrativo for all protocols in one query.
documentos_map = {
doc.protocolo_id: doc
for doc in DocumentoAdministrativo.objects.filter(
protocolo__in=prot_list).select_related('tipo')
}
protocolos = []
for p in prots:
for p in prot_list:
dic = {}
dic['titulo'] = str(p.numero) + '/' + str(p.ano)
@ -1162,11 +1194,11 @@ def get_etiqueta_protocolos(prots):
dic['nom_autor'] = str(p.autor or ' ')
dic['num_materia'] = ''
for materia in MateriaLegislativa.objects.filter(
numero_protocolo=p.numero, ano=p.ano):
dic['num_materia'] = materia.tipo.sigla + ' ' + \
str(materia.numero) + '/' + str(materia.ano)
materia = materias_map.get((p.numero, p.ano))
dic['num_materia'] = (
materia.tipo.sigla + ' ' + str(materia.numero) + '/' + str(materia.ano)
if materia else ''
)
dic['natureza'] = ''
if p.tipo_processo == 0:
@ -1174,11 +1206,11 @@ def get_etiqueta_protocolos(prots):
if p.tipo_processo == 1:
dic['natureza'] = 'Legislativo'
dic['num_documento'] = ''
for documento in DocumentoAdministrativo.objects.filter(
protocolo=p):
dic['num_documento'] = documento.tipo.sigla + ' ' + \
str(documento.numero) + '/' + str(documento.ano)
documento = documentos_map.get(p.pk)
dic['num_documento'] = (
documento.tipo.sigla + ' ' + str(documento.numero) + '/' + str(documento.ano)
if documento else ''
)
dic['ident_processo'] = dic['num_materia'] or dic['num_documento']
@ -1383,9 +1415,15 @@ def make_pdf(base_url, main_template, header_template, main_css='', header_css='
def resumo_ata_pdf(request, pk):
base_url = request.build_absolute_uri()
casa = CasaLegislativa.objects.first()
if not casa:
raise Http404
rodape = ' '.join(get_rodape(casa))
sessao_plenaria = SessaoPlenaria.objects.get(pk=pk)
# A mesma restrição de visibilidade aplicada por ResumoAtaView: sem ela,
# uma sessão oculta na consulta pública era legível por aqui.
sessao_plenaria = get_object_or_404(
restringe_sessoes_visiveis(SessaoPlenaria.objects.all(), request.user),
pk=pk)
dict_ord_template = {
'cont_mult': 'conteudo_multimidia.html',
@ -1421,7 +1459,10 @@ def resumo_ata_pdf(request, pk):
context.update(get_ocorrencias_da_sessao(sessao_plenaria))
context.update(get_consideracoes_finais(sessao_plenaria))
context.update(get_oradores_explicacoes_pessoais(sessao_plenaria))
context.update(get_assinaturas(sessao_plenaria))
context.update(get_assinaturas(
sessao_plenaria,
mesa=context['mesa'],
presenca_ordem=context['presenca_ordem']))
context.update({'object': sessao_plenaria})
context.update({'data': dt.today().strftime('%d/%m/%Y')})
context.update({'rodape': rodape})
@ -1595,7 +1636,8 @@ def relatorio_sessao_plenaria_pdf(request, pk):
try:
logger.debug("user=" + username +
". Tentando obter SessaoPlenaria com id={}.".format(pk))
sessao = SessaoPlenaria.objects.get(id=pk)
sessao = restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), request.user).get(id=pk)
except ObjectDoesNotExist as e:
logger.error("user=" + username +
". Essa SessaoPlenaria não existe (pk={}). ".format(pk) + str(e))
@ -1793,7 +1835,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'),
@ -2082,17 +2124,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()

182
sapl/sanitize.py

@ -0,0 +1,182 @@
"""Sanitização de HTML/JavaScript nos campos de texto livre do SAPL.
Módulo sem dependências internas do SAPL de propósito: é importado por
``sapl.crispy_layout_mixin``, ``sapl.base.receivers`` e pelos templatetags,
e ``sapl.utils`` já importa ``sapl.crispy_layout_mixin``.
Duas políticas:
* ``plain`` — remove toda a marcação e guarda texto puro, com as entidades
já decodificadas: o escape fica por conta da renderização. É o padrão para
qualquer ``TextField``.
* ``rich`` — allowlist para os campos editados via TinyMCE, que contêm HTML
legítimo (negrito, listas, tabelas e links).
A ``rich`` é idempotente, o que permite aplicá-la tanto no ``pre_save`` quanto
na renderização. A ``plain`` só não é idempotente para texto que seja HTML
codificado em entidades (``&lt;b&gt;``): a primeira passada decodifica, a
segunda remove a tag. Na renderização, campos ``plain`` são escapados em vez
de re-sanitizados.
"""
import html
from html.parser import HTMLParser
import nh3
SANITIZE_RICH_TAGS = {
'p', 'br', 'hr', 'div', 'span',
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'sub', 'sup',
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'pre', 'code',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption',
'col', 'colgroup',
'a', 'img',
}
# 'style' mantém o alinhamento produzido pelos botões do TinyMCE;
# 'target' mantém o "abrir em nova aba" dos links já cadastrados.
# 'rel' não pode entrar aqui: o nh3 aborta se a tag 'a' declarar 'rel'
# ao mesmo tempo em que link_rel está definido — ele mesmo escreve o atributo.
SANITIZE_RICH_ATTRS = {
'*': {'style', 'class', 'align', 'title', 'dir', 'lang'},
'a': {'href', 'target', 'name'},
'img': {'src', 'alt', 'width', 'height'},
'td': {'colspan', 'rowspan', 'headers'},
'th': {'colspan', 'rowspan', 'scope', 'headers'},
'col': {'span'},
'colgroup': {'span'},
'table': {'border', 'cellpadding', 'cellspacing', 'summary'},
}
SANITIZE_URL_SCHEMES = {'http', 'https', 'mailto', 'tel'}
# O conteúdo destas tags é descartado junto com a tag. Sem isso o texto de
# dentro de um <script> sobreviveria como texto solto.
SANITIZE_CLEAN_CONTENT_TAGS = {'script', 'style'}
# Campos que guardam HTML legítimo, indexados por '<app_label>.<Model>'.
# Os de sessao e compilacao.Dispositivo são editados no TinyMCE; os de
# compilacao.TipoDispositivo são fragmentos de template configurados por
# administradores.
RICH_TEXT_FIELDS = {
'base.CasaLegislativa': {'informacao_geral'},
'norma.NormaRelacionada': {'resumo'},
'parlamentares.Legislatura': {'observacao'},
'parlamentares.Parlamentar': {'biografia'},
'sessao.ExpedienteSessao': {'conteudo'},
'sessao.OcorrenciaSessao': {'conteudo'},
'sessao.ConsideracoesFinais': {'conteudo'},
'compilacao.Dispositivo': {'texto', 'texto_atualizador'},
'compilacao.TipoDispositivo': {
'rotulo_prefixo_html', 'rotulo_sufixo_html',
'texto_prefixo_html', 'texto_sufixo_html',
'nota_automatica_prefixo_html', 'nota_automatica_sufixo_html',
},
}
# Campos que não devem ser tocados em hipótese alguma.
SANITIZE_EXEMPT_FIELDS = {
# xml é XML fornecido pela equipe do LexML; já é escapado em pretty_xml
'lexml.LexmlProvedor': {'xml'},
# rodape_global é interpolado dentro de um content: de CSS
'compilacao.TipoTextoArticulado': {'rodape_global'},
}
def model_key(model):
return '{}.{}'.format(model._meta.app_label, model.__name__)
def sanitize_scope(model, fieldname):
"""Retorna 'exempt', 'rich' ou 'plain' para um campo de um modelo."""
key = model_key(model)
if fieldname in SANITIZE_EXEMPT_FIELDS.get(key, ()):
return 'exempt'
if fieldname in RICH_TEXT_FIELDS.get(key, ()):
return 'rich'
return 'plain'
def sanitize_html(value, rich=False):
"""Remove HTML/JavaScript perigoso de ``value``.
Com ``rich=False`` toda a marcação é removida e apenas o texto sobra,
sem escape: ``&`` continua ``&``.
Com ``rich=True`` aplica-se a allowlist: links são preservados, mas
esquemas de URL fora de SANITIZE_URL_SCHEMES (javascript:, data:) e
manipuladores de evento (onclick, onerror) são descartados.
"""
if not value:
return value
if not isinstance(value, str):
value = str(value)
if rich:
return nh3.clean(
value,
tags=SANITIZE_RICH_TAGS,
attributes=SANITIZE_RICH_ATTRS,
url_schemes=SANITIZE_URL_SCHEMES,
clean_content_tags=SANITIZE_CLEAN_CONTENT_TAGS,
link_rel='noopener noreferrer',
strip_comments=True)
return html.unescape(nh3.clean(
value,
tags=set(),
attributes={},
clean_content_tags=SANITIZE_CLEAN_CONTENT_TAGS,
link_rel=None,
strip_comments=True))
def sanitize_field(model, fieldname, value):
"""Sanitiza ``value`` conforme a política do campo.
Campos isentos atravessam sem modificação.
"""
scope = sanitize_scope(model, fieldname)
if scope == 'exempt':
return value
return sanitize_html(value, rich=(scope == 'rich'))
_VOID_TAGS = {
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
'meta', 'param', 'source', 'track', 'wbr',
}
class _BalanceChecker(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.stack = []
self.balanced = True
def handle_starttag(self, tag, attrs):
if tag not in _VOID_TAGS:
self.stack.append(tag)
def handle_startendtag(self, tag, attrs):
pass
def handle_endtag(self, tag):
if tag in _VOID_TAGS:
return
if not self.stack or self.stack.pop() != tag:
self.balanced = False
def html_fragment_is_balanced(value):
"""Indica se toda tag aberta em ``value`` é fechada nele mesmo.
Fragmentos desbalanceados (``<span>`` num campo, ``</span>`` em outro)
são reescritos pela sanitização ``rich``, que fecha ou descarta as tags.
"""
checker = _BalanceChecker()
checker.feed(value)
checker.close()
return checker.balanced and not checker.stack

31
sapl/sessao/admin.py

@ -1,3 +1,34 @@
from django.contrib import admin
from sapl.sessao.models import ExpedienteMateria, OrdemDia
from sapl.utils import register_all_models_in_admin
class NoAdminAccessModelAdmin(admin.ModelAdmin):
"""
SAPL não usa as páginas de admin e desencoraja seu uso — e votacao_aberta/
registro_aberto só podem ser alterados com segurança através do fluxo de
abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que
garante a invariante de no máximo uma matéria aberta por vez. Em vez de
apenas tornar os campos somente-leitura, o acesso ao admin é desabilitado
por completo para estes dois modelos.
"""
def has_view_permission(self, request, obj=None):
return False
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
admin.site.register(OrdemDia, NoAdminAccessModelAdmin)
admin.site.register(ExpedienteMateria, NoAdminAccessModelAdmin)
# register_all_models_in_admin já pula modelos já registrados acima
register_all_models_in_admin(__name__)

4
sapl/sessao/forms.py

@ -449,10 +449,6 @@ class PresencaForm(forms.Form):
parlamentar = forms.CharField(required=False, max_length=20)
class ListMateriaForm(forms.Form):
error_message = forms.CharField(required=False, label='votacao_aberta')
class MesaForm(forms.Form):
parlamentar = forms.IntegerField(required=True)
cargo = forms.IntegerField(required=True)

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

54
sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py

@ -0,0 +1,54 @@
# Generated by Django 2.2.28 on 2026-08-16 17:36
from django.db import migrations, models
from django.db.models import Count
def remove_votos_duplicados(apps, schema_editor):
"""Preserva o voto canônico antes de criar os índices únicos."""
VotoParlamentar = apps.get_model('sessao', 'VotoParlamentar')
for materia_field in ('ordem_id', 'expediente_id'):
# order_by() limpa o Meta.ordering ('id'), que no Django 2.2 entraria
# no GROUP BY e deixaria todo grupo com n=1.
duplicadas = (VotoParlamentar.objects
.exclude(**{materia_field: None})
.order_by()
.values('parlamentar_id', materia_field)
.annotate(n=Count('id'))
.filter(n__gt=1))
for chave in duplicadas:
votos_duplicados = list(VotoParlamentar.objects.filter(
parlamentar_id=chave['parlamentar_id'],
**{materia_field: chave[materia_field]}))
voto_preservado = max(
votos_duplicados,
key=lambda voto: (
voto.votacao_id is not None,
voto.data_hora is not None,
voto.data_hora,
voto.id,
))
ids_removidos = [voto.id for voto in votos_duplicados
if voto.id != voto_preservado.id]
VotoParlamentar.objects.filter(id__in=ids_removidos).delete()
class Migration(migrations.Migration):
dependencies = [
('sessao', '0070_presenca_unica_por_sessao'),
]
operations = [
migrations.RunPython(
remove_votos_duplicados, migrations.RunPython.noop),
migrations.AddConstraint(
model_name='votoparlamentar',
constraint=models.UniqueConstraint(condition=models.Q(ordem__isnull=False), fields=('parlamentar', 'ordem'), name='sessao_votoparlamentar_unique_parlamentar_ordem'),
),
migrations.AddConstraint(
model_name='votoparlamentar',
constraint=models.UniqueConstraint(condition=models.Q(expediente__isnull=False), fields=('parlamentar', 'expediente'), name='sessao_votoparlamentar_unique_parlamentar_expediente'),
),
]

45
sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py

@ -0,0 +1,45 @@
# Generated by Django 2.2.28 on 2026-08-16 18:58
from django.db import migrations, models
def fecha_materias_abertas_duplicadas(apps, schema_editor):
"""
Pré-requisito para o AddConstraint abaixo: se já existir mais de uma
OrdemDia/ExpedienteMateria com votacao_aberta=True (dado deixado por
versões anteriores, que não garantiam essa invariante), a constraint
falharia ao ser criada. Mantém aberta apenas a criada por último (maior
id) de cada tabela e fecha as demais.
Também zera registro_aberto, que mudou de significado ("a Mesa abriu a
tela de registro" -> "novos votos bloqueados"): o fluxo antigo marcava
True ao abrir a tela e não voltava para False ao encerrar, o que faria
uma matéria reaberta voltar com os tablets bloqueados.
"""
for model_name in ('OrdemDia', 'ExpedienteMateria'):
model = apps.get_model('sessao', model_name)
model.objects.filter(registro_aberto=True).update(registro_aberto=False)
abertas = model.objects.filter(votacao_aberta=True).order_by('-id')
for materia in abertas[1:]:
materia.votacao_aberta = False
materia.save(update_fields=['votacao_aberta'])
class Migration(migrations.Migration):
dependencies = [
('sessao', '0071_votoparlamentar_unique_constraint'),
]
operations = [
migrations.RunPython(
fecha_materias_abertas_duplicadas, migrations.RunPython.noop),
migrations.AddConstraint(
model_name='expedientemateria',
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'),
),
migrations.AddConstraint(
model_name='ordemdia',
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_ordemdia_unique_votacao_aberta'),
),
]

63
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(
@ -413,6 +438,15 @@ class ExpedienteMateria(AbstractOrdemDia):
verbose_name = _('Matéria do Expediente')
verbose_name_plural = _('Matérias do Expediente')
ordering = ['numero_ordem']
constraints = [
# No máximo uma matéria de expediente aberta para votação por
# vez (mesma invariante que abrir_votacao() já garante na
# aplicação — isto é o backstop no nível do banco).
models.UniqueConstraint(
fields=['votacao_aberta'],
condition=models.Q(votacao_aberta=True),
name='sessao_expedientemateria_unique_votacao_aberta'),
]
class TipoExpediente(models.Model):
@ -563,6 +597,15 @@ class OrdemDia(AbstractOrdemDia):
verbose_name = _('Matéria da Ordem do Dia')
verbose_name_plural = _('Matérias da Ordem do Dia')
ordering = ['numero_ordem']
constraints = [
# No máximo uma matéria de ordem do dia aberta para votação por
# vez (mesma invariante que abrir_votacao() já garante na
# aplicação — isto é o backstop no nível do banco).
models.UniqueConstraint(
fields=['votacao_aberta'],
condition=models.Q(votacao_aberta=True),
name='sessao_ordemdia_unique_votacao_aberta'),
]
class PresencaOrdemDia(models.Model): # OrdemDiaPresenca
@ -574,6 +617,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
@ -700,6 +746,20 @@ class VotoParlamentar(models.Model): # RegistroVotacaoParlamentar
verbose_name = _('Registro de Votação de Parlamentar')
verbose_name_plural = _('Registros de Votações de Parlamentares')
ordering = ('id',)
constraints = [
# Garante, no nível do banco, no máximo um voto por parlamentar
# por matéria — get_or_create() sozinho não protege contra duas
# inserções concorrentes (ex.: o tablet do parlamentar e o
# formulário em lote do operador chegando ao mesmo tempo).
models.UniqueConstraint(
fields=['parlamentar', 'ordem'],
condition=models.Q(ordem__isnull=False),
name='sessao_votoparlamentar_unique_parlamentar_ordem'),
models.UniqueConstraint(
fields=['parlamentar', 'expediente'],
condition=models.Q(expediente__isnull=False),
name='sessao_votoparlamentar_unique_parlamentar_expediente'),
]
def __str__(self):
return _('Votação: %(votacao)s - Parlamentar: %(parlamentar)s') % {
@ -716,6 +776,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 = [

94
sapl/sessao/tests/test_sessao_oculta_api.py

@ -0,0 +1,94 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.utils import timezone
from model_bakery import baker
import pytest
from sapl.sessao.models import Orador, SessaoPlenaria
@pytest.fixture(autouse=True)
def limpa_cache_appconfig():
# o cache em arquivo sobrevive ao rollback do teste
cache.clear()
yield
cache.clear()
@pytest.fixture
def sessoes():
# só a sessão futura, não iniciada e sem pauta fica oculta do público
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=timezone.localdate() + timedelta(days=7))
iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False)
baker.make(Orador, sessao_plenaria=previa)
baker.make(Orador, sessao_plenaria=iniciada)
return previa, iniciada
def _listados(client, endpoint, campo, sessoes):
"""Sessões de `sessoes` referenciadas na listagem completa do endpoint."""
valores, page = set(), 1
while page:
response = client.get('/api/sessao/%s/?page_size=100&page=%s' % (
endpoint, page))
assert response.status_code == 200
dados = response.json()
valores |= {r[campo] for r in dados['results']}
page = dados['pagination']['next_page']
return valores & {s.pk for s in sessoes}
@pytest.mark.django_db(transaction=False)
def test_api_oculta_sessao_previa_do_anonimo(client, sessoes):
previa, iniciada = sessoes
assert _listados(client, 'sessaoplenaria', 'id', sessoes) == {
iniciada.pk}
assert client.get('/api/sessao/sessaoplenaria/%s/' % previa.pk
).status_code == 404
assert client.get('/api/sessao/sessaoplenaria/%s/ecidadania/' % previa.pk
).status_code == 404
assert client.get('/api/sessao/sessaoplenaria/%s/expedientes/' % previa.pk
).status_code == 404
assert client.get('/api/sessao/sessaoplenaria/%s/expedientes/'
% iniciada.pk).status_code == 200
assert _listados(client, 'orador', 'sessao_plenaria', sessoes) == {
iniciada.pk}
@pytest.mark.django_db(transaction=False)
def test_api_mostra_sessao_previa_ao_usuario_autenticado(client, sessoes):
previa, iniciada = sessoes
client.force_login(baker.make(get_user_model()))
assert _listados(client, 'sessaoplenaria', 'id', sessoes) == {
previa.pk, iniciada.pk}
assert client.get('/api/sessao/sessaoplenaria/%s/' % previa.pk
).status_code == 200
assert _listados(client, 'orador', 'sessao_plenaria', sessoes) == {
previa.pk, iniciada.pk}
@pytest.mark.django_db(transaction=False)
def test_media_de_sessao_oculta_do_anonimo(client, settings, sessoes):
settings.DEBUG = False
previa, iniciada = sessoes
orador_previa = Orador.objects.get(sessao_plenaria=previa)
orador_iniciada = Orador.objects.get(sessao_plenaria=iniciada)
def get(path):
return client.get('/media/sapl/public/' + path).status_code
assert get('sessaoplenaria/%s/pauta/p.pdf' % previa.pk) == 404
assert get('sessaoplenaria/%s/pauta/p.pdf' % iniciada.pk) == 200
assert get('orador/%s/anexo/a.pdf' % orador_previa.pk) == 404
assert get('orador/%s/anexo/a.pdf' % orador_iniciada.pk) == 200
client.force_login(baker.make(get_user_model()))
assert get('sessaoplenaria/%s/pauta/p.pdf' % previa.pk) == 200
assert get('orador/%s/anexo/a.pdf' % orador_previa.pk) == 200

220
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
@ -147,3 +154,214 @@ class TestResumoView():
resultado_get_ocorrencia = get_ocorrencias_da_sessao(self.sessao_plenaria)
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)

689
sapl/sessao/tests/test_votacao_nominal.py

@ -0,0 +1,689 @@
import re
from unittest import mock
import pytest
from django.contrib.messages import get_messages
from django.db import IntegrityError
from django.urls import reverse
from model_bakery import baker
from sapl.base.models import AuditLog
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.parlamentares.models import (Legislatura, Parlamentar,
SessaoLegislativa)
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao,
SessaoPlenaria, TipoResultadoVotacao,
TipoSessaoPlenaria, VotoParlamentar)
NOMINAL = 2
def _sessao_plenaria():
legislatura = baker.make(Legislatura)
sessao_legislativa = baker.make(SessaoLegislativa)
tipo = baker.make(TipoSessaoPlenaria)
return baker.make(SessaoPlenaria, legislatura=legislatura,
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1)
def _materia():
tipo_materia = baker.make(TipoMateriaLegislativa)
return baker.make(MateriaLegislativa, tipo=tipo_materia)
def _ordem_nominal_aberta():
sessao = _sessao_plenaria()
materia = _materia()
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia,
tipo_votacao=NOMINAL, votacao_aberta=True,
registro_aberto=False)
return sessao, ordem
def _presente(sessao):
parlamentar = baker.make(Parlamentar, ativo=True)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar)
return parlamentar
def _registrar_url(sessao, ordem):
return reverse('sapl.sessao:votacaonominal',
kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id})
@pytest.mark.django_db(transaction=False)
def test_get_registrar_votacao_nao_bloqueia_novos_votos(admin_client):
"""
Regressão da causa raiz #1: abrir a tela "Registrar Votação" não pode,
sozinho, impedir que vereadores que ainda não votaram continuem votando.
"""
sessao, ordem = _ordem_nominal_aberta()
_presente(sessao)
response = admin_client.get(_registrar_url(sessao, ordem))
assert response.status_code == 200
ordem.refresh_from_db()
assert ordem.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_post_sem_acao_reconhecida_redireciona_para_registro(admin_client):
sessao, ordem = _ordem_nominal_aberta()
_presente(sessao)
response = admin_client.post(_registrar_url(sessao, ordem) + '?page=2', {})
assert response.status_code == 302
assert response.url == _registrar_url(sessao, ordem) + '?page=2'
ordem.refresh_from_db()
assert ordem.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_botao_registrar_votacao_nominal_usa_get(admin_client):
"""
A tela de registro não tem efeito colateral no GET; com o botão em GET
o ?page vai na URL e é preservado até o redirect final do post().
"""
sessao, ordem = _ordem_nominal_aberta()
response = admin_client.get(
reverse('sapl.sessao:ordemdia_list', kwargs={'pk': sessao.pk}))
conteudo = response.content.decode()
form = re.search(
r'<form action="%s"[^>]*>' % re.escape(_registrar_url(sessao, ordem)),
conteudo)
assert form
assert form.group(0) == '<form action="%s">' % _registrar_url(sessao, ordem)
@pytest.mark.django_db(transaction=False)
def test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_existentes(admin_client):
sessao, ordem = _ordem_nominal_aberta()
votante = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=votante, voto='Sim')
url = _registrar_url(sessao, ordem)
response = admin_client.post(url, {'bloquear-registro-votacao': '1'})
assert response.status_code == 302
ordem.refresh_from_db()
assert ordem.registro_aberto is True
response = admin_client.post(url, {'reabrir-votacao': '1'})
assert response.status_code == 302
ordem.refresh_from_db()
assert ordem.registro_aberto is False
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votante)
assert voto.voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_nao_sobrescreve_voto_ja_registrado(admin_client):
"""
Regressão do modelo de concorrência (3b): um formulário de "Fechar
Votação" com um valor obsoleto para quem já votou pelo tablet não pode
sobrescrever esse voto — só preenche quem ainda não tem voto registrado.
"""
sessao, ordem = _ordem_nominal_aberta()
ja_votou = _presente(sessao)
ainda_nao_votou = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=ja_votou, voto='Sim')
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
url = _registrar_url(sessao, ordem)
payload = {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': [
# valor obsoleto: a tela do operador ainda não sabia que este
# parlamentar já havia votado "Sim" pelo tablet
'Não Votou:{}'.format(ja_votou.pk),
'Não:{}'.format(ainda_nao_votou.pk),
],
}
response = admin_client.post(url, payload)
assert response.status_code == 302
voto_ja_votou = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ja_votou)
assert voto_ja_votou.voto == 'Sim'
voto_novo = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ainda_nao_votou)
assert voto_novo.voto == 'Não'
registro = RegistroVotacao.objects.get(ordem=ordem)
assert registro.numero_votos_sim == 1
assert registro.numero_votos_nao == 1
ordem.refresh_from_db()
assert ordem.votacao_aberta is False
assert ordem.registro_aberto is False
# O 'Não Votou' enviado para quem já votou é só o valor padrão do
# <select>, não um conflito — não pode gerar o aviso de não sobrescrito.
avisos = [str(m) for m in get_messages(response.wsgi_request)]
assert not any('não foram sobrescritos' in a for a in avisos)
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_avisa_quando_voto_do_tablet_diverge_do_formulario(admin_client):
sessao, ordem = _ordem_nominal_aberta()
ja_votou = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=ja_votou, voto='Sim')
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
response = admin_client.post(_registrar_url(sessao, ordem), {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Não:{}'.format(ja_votou.pk)],
})
assert response.status_code == 302
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=ja_votou).voto == 'Sim'
avisos = [str(m) for m in get_messages(response.wsgi_request)]
assert any('não foram sobrescritos' in a for a in avisos)
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_registra_nao_votou_para_presentes_sem_voto(admin_client):
"""
Relatórios, transparência e o painel leem VotoParlamentar por votação:
quem estava presente e não votou precisa ficar registrado como
'Não Votou', vinculado ao RegistroVotacao.
"""
sessao, ordem = _ordem_nominal_aberta()
votou = _presente(sessao)
nao_votou = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
response = admin_client.post(_registrar_url(sessao, ordem), {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': [
'Sim:{}'.format(votou.pk),
'Não Votou:{}'.format(nao_votou.pk),
],
})
assert response.status_code == 302
registro = RegistroVotacao.objects.get(ordem=ordem)
assert registro.numero_votos_sim == 1
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=nao_votou)
assert voto.voto == 'Não Votou'
assert voto.votacao_id == registro.id
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_registra_votos_no_auditlog(admin_client):
"""
O AuditLog é gravado por post_save; o vínculo dos votos ao registro e
as linhas 'Não Votou' precisam passar por save() para ficarem auditados.
"""
sessao, ordem = _ordem_nominal_aberta()
votou = _presente(sessao)
nao_votou = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
admin_client.post(_registrar_url(sessao, ordem), {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': [
'Sim:{}'.format(votou.pk),
'Não Votou:{}'.format(nao_votou.pk),
],
})
registro = RegistroVotacao.objects.get(ordem=ordem)
voto_sim = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votou)
voto_nao_votou = VotoParlamentar.objects.get(ordem=ordem, parlamentar=nao_votou)
logs = AuditLog.objects.filter(model_name='VotoParlamentar')
vinculo = logs.filter(operation='U', object_id=voto_sim.pk).first()
assert vinculo is not None
assert vinculo.data['fields']['votacao'] == registro.pk
criacao = logs.get(operation='C', object_id=voto_nao_votou.pk)
assert criacao.data['fields']['voto'] == 'Não Votou'
assert criacao.data['fields']['votacao'] == registro.pk
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_ignora_parlamentar_inativo_presente(admin_client):
"""
O formulário da Mesa só lista parlamentares ativos; um inativo marcado
como presente não pode ganhar 'Não Votou' nem entrar no total.
"""
sessao, ordem = _ordem_nominal_aberta()
votou = _presente(sessao)
inativo = _presente(sessao)
inativo.ativo = False
inativo.save()
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
response = admin_client.get(_registrar_url(sessao, ordem))
assert response.context['total'] == 1
admin_client.post(_registrar_url(sessao, ordem), {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Sim:{}'.format(votou.pk)],
})
assert RegistroVotacao.objects.filter(ordem=ordem).exists()
assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=inativo).exists()
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_ignora_voto_de_quem_teve_presenca_removida(admin_client):
"""
Parlamentar vota pelo tablet, sai e tem a presença removida antes do
encerramento: o voto não entra na contagem e é apagado.
"""
sessao, ordem = _ordem_nominal_aberta()
presente = _presente(sessao)
saiu = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=saiu, voto='Sim')
PresencaOrdemDia.objects.filter(sessao_plenaria=sessao, parlamentar=saiu).delete()
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Rejeitada', natureza='R')
response = admin_client.post(_registrar_url(sessao, ordem), {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Não:{}'.format(presente.pk)],
})
assert response.status_code == 302
registro = RegistroVotacao.objects.get(ordem=ordem)
assert registro.numero_votos_sim == 0
assert registro.numero_votos_nao == 1
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=saiu).exists()
@pytest.mark.django_db(transaction=False)
def test_salvar_votacao_sem_votos_nao_trava_selects_para_nova_tentativa(admin_client):
"""
Regressão: fechar a votação sem nenhum voto real corretamente mostra um
erro, mas antes disso o laço de salvamento em lote criava um
VotoParlamentar com voto='Não Votou' para cada parlamentar cujo select
não foi tocado (o valor padrão do <select>). Esses registros persistiam
mesmo com o fechamento falhando (o bloco atomic não é revertido, já que
form_invalid retorna normalmente em vez de lançar), e nominal.html
desabilita o <select> de qualquer parlamentar com um VotoParlamentar
existente — travando o operador para sempre sem conseguir registrar
nenhum voto para essa matéria.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
url = _registrar_url(sessao, ordem)
payload = {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Não Votou:{}'.format(parlamentar.pk)],
}
response = admin_client.post(url, payload)
assert response.status_code == 302
assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar).exists()
ordem.refresh_from_db()
assert ordem.votacao_aberta is True
# O operador consegue tentar de novo, agora com um voto real.
response2 = admin_client.post(url, {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)],
})
assert response2.status_code == 302
ordem.refresh_from_db()
assert ordem.votacao_aberta is False
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_status_da_votacao_reflete_troca_de_voto_do_parlamentar(admin_client):
"""
Regressão: a tela de registro (nominal.html) não atualizava a linha de
um parlamentar que trocou o voto durante a janela de votação — o poll
antigo só marcava "já votou" uma vez e nunca revisitava o valor. O poll
(?status=1) precisa sempre devolver o voto atual, não só se existe.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
voto = baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar,
voto='Sim')
url = _registrar_url(sessao, ordem) + '?status=1'
response = admin_client.get(url)
assert response.status_code == 200
data = response.json()
assert data['votos'] == {str(parlamentar.pk): 'Sim'}
assert data['votacao_aberta'] is True
assert data['registro_aberto'] is False
assert data['ja_registrada'] is False
voto.voto = 'Não'
voto.save()
response2 = admin_client.get(url)
assert response2.json()['votos'] == {str(parlamentar.pk): 'Não'}
@pytest.mark.django_db(transaction=False)
def test_status_da_votacao_nao_depende_de_mostrar_voto(admin_client):
"""
O poll da tela de registro é só para a Mesa, não para o público — ao
contrário de sapl.painel:dados_painel, ele não pode mascarar o valor
real do voto por trás de "Voto Informado" mesmo quando a Casa configura
mostrar_voto=False (essa config controla o telão público, não a tela de
registro da própria Mesa). Como o endpoint nem consulta essa
configuração, isso é garantido por construção — este teste só
documenta a expectativa.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar,
voto='Abstenção')
url = _registrar_url(sessao, ordem) + '?status=1'
data = admin_client.get(url).json()
assert data['votos'][str(parlamentar.pk)] == 'Abstenção'
@pytest.mark.django_db(transaction=False)
def test_status_da_votacao_nao_redireciona_apos_encerrar_votacao(
admin_client):
"""
_get_materia_votacao (usado pelo GET normal) redireciona com uma
mensagem quando a matéria já foi votada — comportamento certo para
navegação, errado para um poll em background. O branch ?status=1 não
pode herdar esse redirect.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada',
natureza='A')
url = _registrar_url(sessao, ordem)
admin_client.post(url, {
'salvar-votacao': '1',
'resultado_votacao': str(tipo_resultado.pk),
'observacao': '',
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)],
})
response = admin_client.get(url + '?status=1')
assert response.status_code == 200
data = response.json()
assert data['ja_registrada'] is True
assert data['votacao_aberta'] is False
@pytest.mark.django_db(transaction=False)
def test_abrir_votacao_ja_aberta_e_idempotente(admin_client):
"""
Regressão: clicar em "Abrir Votação" para uma matéria que já está aberta
(ex.: duplo clique, ou a lista ainda não recarregou para trocar o botão
por "Registrar Votação") caía em verifica_votacoes_abertas(), que trata
a própria matéria como uma "outra" votação conflitante — mostra a
mensagem "existem votações abertas... foram fechadas" e fecha a matéria
(para reabri-la em seguida, já que o código sempre prossegue). O
resultado final até ficava certo (votacao_aberta=True), mas a mensagem
era enganosa. Reabrir a própria matéria já aberta precisa ser
silenciosamente idempotente.
"""
sessao, ordem = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
url = reverse('sapl.sessao:abrir_votacao', kwargs={'pk': ordem.pk, 'spk': sessao.pk})
url += '?tipo_materia=ordem'
response = admin_client.get(url, follow=True)
assert response.status_code == 200
ordem.refresh_from_db()
assert ordem.votacao_aberta is True
mensagens = [str(m) for m in response.context['messages']]
assert not any('foram fechadas' in m for m in mensagens)
def _abrir_votacao_url(sessao, ordem):
return reverse('sapl.sessao:abrir_votacao',
kwargs={'pk': ordem.pk, 'spk': sessao.pk}) + '?tipo_materia=ordem'
@pytest.mark.django_db(transaction=False)
def test_abrir_outra_votacao_zera_bloqueio_da_anterior(admin_client):
"""
Abrir Y sem encerrar X fecha X; X não pode voltar com os tablets
bloqueados (registro_aberto=True) quando for reaberta.
"""
sessao, x = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
x.registro_aberto = True
x.save()
y = baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(),
tipo_votacao=NOMINAL, votacao_aberta=False)
admin_client.get(_abrir_votacao_url(sessao, y))
x.refresh_from_db()
y.refresh_from_db()
assert y.votacao_aberta is True
assert x.votacao_aberta is False
assert x.registro_aberto is False
# Legado: matéria fechada que ficou com registro_aberto=True.
OrdemDia.objects.filter(pk=x.pk).update(registro_aberto=True)
admin_client.get(_abrir_votacao_url(sessao, x))
x.refresh_from_db()
assert x.votacao_aberta is True
assert x.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_abrir_votacao_concorrente_mostra_erro_em_vez_de_500(admin_client):
"""
O lock de abrir_votacao() é por sessão, mas a unicidade de
votacao_aberta é global: uma abertura simultânea em outra sessão faz o
save cair no índice parcial. O fechamento das outras votações é
desfeito junto, então a mensagem "foram fechadas" não pode aparecer.
"""
sessao, ordem = _ordem_nominal_aberta()
ordem.votacao_aberta = False
ordem.save()
_, outra = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
with mock.patch.object(OrdemDia, 'save', side_effect=IntegrityError):
response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True)
assert response.status_code == 200
ordem.refresh_from_db()
outra.refresh_from_db()
assert ordem.votacao_aberta is False
assert outra.votacao_aberta is True
mensagens = [str(m) for m in response.context['messages']]
assert any('aberta simultaneamente' in m for m in mensagens)
assert not any('foram fechadas' in m for m in mensagens)
@pytest.mark.django_db(transaction=False)
def test_abrir_votacao_avisa_que_fechou_as_outras(admin_client):
sessao, ordem = _ordem_nominal_aberta()
ordem.votacao_aberta = False
ordem.save()
_, outra = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True)
outra.refresh_from_db()
assert outra.votacao_aberta is False
mensagens = [str(m) for m in response.context['messages']]
assert any('foram fechadas' in m for m in mensagens)
@pytest.mark.django_db(transaction=False)
def test_abrir_votacao_audita_fechamento_das_outras(admin_client):
sessao, ordem = _ordem_nominal_aberta()
ordem.votacao_aberta = False
ordem.save()
_, outra = _ordem_nominal_aberta()
sessao.iniciada = True
sessao.finalizada = False
sessao.save()
_presente(sessao)
admin_client.get(_abrir_votacao_url(sessao, ordem))
fechamento = AuditLog.objects.filter(
model_name='OrdemDia', operation='U', object_id=outra.pk).first()
assert fechamento is not None
assert fechamento.data['fields']['votacao_aberta'] is False
@pytest.mark.django_db(transaction=False)
def test_unique_constraint_impede_voto_duplicado(admin_client):
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim')
with pytest.raises(IntegrityError):
VotoParlamentar.objects.create(
ordem=ordem, parlamentar=parlamentar, voto='Não')
@pytest.mark.django_db(transaction=False)
def test_unique_constraint_impede_duas_ordens_abertas():
sessao, ordem = _ordem_nominal_aberta()
outra = baker.make(OrdemDia, sessao_plenaria=sessao, materia=ordem.materia,
tipo_votacao=NOMINAL, votacao_aberta=False)
with pytest.raises(IntegrityError):
outra.votacao_aberta = True
outra.save()
@pytest.mark.django_db(transaction=False)
def test_migracao_0071_remove_votos_duplicados_antes_da_constraint():
import importlib
from django.apps import apps as real_apps
from django.db import connection
migracao = importlib.import_module(
'sapl.sessao.migrations.0071_votoparlamentar_unique_constraint')
with connection.cursor() as cursor:
cursor.execute(
'DROP INDEX sessao_votoparlamentar_unique_parlamentar_ordem')
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
antigo = baker.make(VotoParlamentar, ordem=ordem,
parlamentar=parlamentar, voto='Não')
recente = baker.make(VotoParlamentar, ordem=ordem,
parlamentar=parlamentar, voto='Sim')
migracao.remove_votos_duplicados(real_apps, None)
votos = VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=parlamentar)
assert list(votos.values_list('id', flat=True)) == [recente.id]
assert not votos.filter(id=antigo.id).exists()
@pytest.mark.django_db(transaction=False)
def test_migracao_0072_fecha_duplicatas_antes_da_constraint():
"""
A função de dados da migration 0072 precisa fechar duplicatas
pré-existentes antes do AddConstraint — senão a migration falharia ao
ser aplicada num banco com dado antigo (de antes desta invariante
existir). Testa a função isoladamente: como o teste roda dentro de uma
transação que é desfeita no final, é seguro derrubar o índice aqui
(DDL é transacional no Postgres).
"""
import importlib
from django.apps import apps as real_apps
from django.db import connection
migracao = importlib.import_module(
'sapl.sessao.migrations.0072_votacao_aberta_unique_constraint')
with connection.cursor() as cursor:
cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta')
sessao, mais_antiga = _ordem_nominal_aberta()
mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao,
materia=mais_antiga.materia, tipo_votacao=NOMINAL,
votacao_aberta=True, registro_aberto=True)
assert mais_recente.pk > mais_antiga.pk
# Legado: o GET antigo da tela de registro deixava registro_aberto=True
# em matérias já encerradas.
encerrada = baker.make(OrdemDia, sessao_plenaria=sessao,
materia=mais_antiga.materia, tipo_votacao=NOMINAL,
votacao_aberta=False, registro_aberto=True)
migracao.fecha_materias_abertas_duplicadas(real_apps, None)
mais_antiga.refresh_from_db()
mais_recente.refresh_from_db()
encerrada.refresh_from_db()
assert mais_antiga.votacao_aberta is False
assert mais_recente.votacao_aberta is True
assert mais_recente.registro_aberto is False
assert encerrada.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_api_nao_permite_abrir_votacao_via_patch(admin_client):
"""
Regressão do bypass encontrado na auditoria: a API auto-gerada
(drfautoapi) não pode mais aceitar votacao_aberta/registro_aberto —
senão qualquer "Operador de Sessão Plenária" conseguiria abrir uma
matéria via PATCH direto, pulando verifica_votacoes_abertas() e o
lock em abrir_votacao().
"""
sessao, ordem = _ordem_nominal_aberta()
ordem.votacao_aberta = False
ordem.save()
url = '/api/sessao/ordemdia/{}/'.format(ordem.pk)
response = admin_client.patch(
url, data={'votacao_aberta': True}, content_type='application/json')
assert response.status_code in (200, 202)
ordem.refresh_from_db()
assert ordem.votacao_aberta is False

1096
sapl/sessao/views.py

File diff suppressed because it is too large

2
sapl/settings.py

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

4
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

9
sapl/templates/base.html

@ -40,6 +40,13 @@
<body>
<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 request|has_iframe %}
{% block navigation %}
@ -200,7 +207,7 @@
<small>
Desenvolvido pelo <a href="http://www.interlegis.leg.br/">Interlegis</a> em software livre e aberto.
</small>
<span>Release: 3.1.165-RC2</span>
<span>Release: 3.1.165-RC4</span>
</p>
</div>

2
sapl/templates/comissoes/materias_em_tramitacao.html

@ -38,7 +38,7 @@
{{ materia_em_tramitacao.materia.ementa|dont_break_out }}
</td>
<td>
{{ materia_em_tramitacao.tramitacao.status.descricao|linebreaksbr|safe }}
{{ materia_em_tramitacao.tramitacao.status.descricao|linebreaksbr }}
</td>
<td>
{% if materia_em_tramitacao.tramitacao.data_fim_prazo %}

2
sapl/templates/compilacao/dispositivo_form_alteracao.html

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

2
sapl/templates/compilacao/dispositivo_form_edicao_basica.html

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

4
sapl/templates/compilacao/dispositivo_form_search_fragment_child.html

@ -1,4 +1,4 @@
{% load i18n compilacao_filters %}
{% load i18n compilacao_filters common_tags %}
<li>
<div class="iteminput">
@ -30,7 +30,7 @@
{{ df.tipo_dispositivo.rotulo_prefixo_html|safe }}
<a name="{{df.pk}}" title="{{df.pk}}">{% if df.rotulo or df.nivel == 1%}{{ df.rotulo }}{%else%}[{{ df|nomenclatura}} {% trans "de" %} {{ df.dispositivo_pai.rotulo }}] - {% endif %}</a>
{{ df.tipo_dispositivo.rotulo_sufixo_html|safe }}
<span class="dtxt" id="d{% if not df.dispositivo_subsequente_id and df.dispositivo_substituido_id %}a{% endif %}{{df.pk}}" pks="{{df.dispositivo_substituido_id|default:''}}" pk="{{df.pk}}">{{ df.tipo_dispositivo.texto_prefixo_html|safe }}{%if df.texto %}{{ df.texto|safe }}{%else%}{%if not df.tipo_dispositivo.dispositivo_de_articulacao %}&nbsp;{% endif %}{% endif %}</span>
<span class="dtxt" id="d{% if not df.dispositivo_subsequente_id and df.dispositivo_substituido_id %}a{% endif %}{{df.pk}}" pks="{{df.dispositivo_substituido_id|default:''}}" pk="{{df.pk}}">{{ df.tipo_dispositivo.texto_prefixo_html|safe }}{%if df.texto %}{{ df.texto|sanitize }}{%else%}{%if not df.tipo_dispositivo.dispositivo_de_articulacao %}&nbsp;{% endif %}{% endif %}</span>
{% if df.ta_publicado_id %}
<a class="nota-alteracao" href="{% url 'sapl.compilacao:ta_text' df.ta_publicado.pk %}#{{df.dispositivo_atualizador_id}}">
{{ df.tipo_dispositivo.nota_automatica_prefixo_html|safe }}

2
sapl/templates/compilacao/layout/dispositivo_checkbox.html

@ -37,7 +37,7 @@
{{ choice.1.tipo_dispositivo.rotulo_prefixo_html|safe }}
<a name="{{choice.1.pk}}" title="{{choice.1.pk}}">{% if choice.1.rotulo %}{{ choice.1.rotulo }}{%else%}[{{ choice.1|nomenclatura}}{% if choice.1.dispositivo_pai %} {% trans "de" %} {{ choice.1.dispositivo_pai.rotulo }}{% endif %}] - {% endif %}</a>
{{ choice.1.tipo_dispositivo.rotulo_sufixo_html|safe }}
<span class="dtxt" id="d{% if not choice.1.dispositivo_subsequente_id and choice.1.dispositivo_substituido_id %}a{% endif %}{{choice.1.pk}}" pks="{{choice.1.dispositivo_substituido_id|default:''}}" pk="{{choice.1.pk}}">{{ choice.1.tipo_dispositivo.texto_prefixo_html|safe }}{%if choice.1.texto %}{{ choice.1.texto|safe }}{%else%}{%if not choice.1.tipo_dispositivo.dispositivo_de_articulacao %}&nbsp;{% endif %}{% endif %}</span>
<span class="dtxt" id="d{% if not choice.1.dispositivo_subsequente_id and choice.1.dispositivo_substituido_id %}a{% endif %}{{choice.1.pk}}" pks="{{choice.1.dispositivo_substituido_id|default:''}}" pk="{{choice.1.pk}}">{{ choice.1.tipo_dispositivo.texto_prefixo_html|safe }}{%if choice.1.texto %}{{ choice.1.texto|sanitize }}{%else%}{%if not choice.1.tipo_dispositivo.dispositivo_de_articulacao %}&nbsp;{% endif %}{% endif %}</span>
{% if choice.1.ta_publicado_id and not choice.1.tipo_dispositivo.dispositivo_de_articulacao %}
<a class="nota-alteracao" target="_blank" href="{% url 'sapl.compilacao:ta_text' choice.1.ta_publicado.pk %}#{{choice.1.dispositivo_atualizador_id}}">
{{ choice.1.tipo_dispositivo.nota_automatica_prefixo_html|safe }}

2
sapl/templates/compilacao/layout/dispositivo_radio.html

@ -35,7 +35,7 @@
<span class="dtxt"
id="d{% if not dpt.dispositivo_subsequente_id and dpt.dispositivo_substituido_id %}a{% endif %}{{dpt.pk}}"
pks="{{dpt.dispositivo_substituido_id|default:''}}"
pk="{{dpt.pk}}">{{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|safe }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %}&nbsp;{% endif %}{% endif %}</span>
pk="{{dpt.pk}}">{{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|sanitize }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %}&nbsp;{% endif %}{% endif %}</span>
{% if dpt.ta_publicado_id and not dpt.tipo_dispositivo.dispositivo_de_articulacao %}
<a class="nota-alteracao" href="{%url 'sapl.compilacao:ta_text' dpt.ta_publicado.pk %}#{{dpt.dispositivo_atualizador_id}}">
{{ dpt.tipo_dispositivo.nota_automatica_prefixo_html|safe }}

10
sapl/templates/compilacao/text_edit.html

@ -17,8 +17,14 @@
<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 %}
</div>
<div class="actions btn-group dropleft" role="group">
{% include 'compilacao/textoarticulado_menu_config.html' %}
<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">
{% include 'compilacao/textoarticulado_menu_config.html' %}
</div>
</div>
</div>
{% if object.dispositivos_set.count == 1 %}

2
sapl/templates/compilacao/text_edit_bloco.html

@ -43,7 +43,7 @@
{% if not node.dpt.texto and not node.td.dispositivo_de_articulacao %}
<span class="semtexto">({{node.td}} sem texto)</span>
{%else%}
{% if node.da and node.dpt.texto_atualizador and node in node.da.alts%}{{ node.dpt.texto_atualizador|safe }}{% else %}{{ node.dpt.texto|safe }}{% endif %}
{% if node.da and node.dpt.texto_atualizador and node in node.da.alts%}{{ node.dpt.texto_atualizador|sanitize }}{% else %}{{ node.dpt.texto|sanitize }}{% endif %}
{%endif%}
{% if node.na %}
<a class="dpt-link nota-alteracao" href="{% url 'sapl.compilacao:ta_text_edit' node.da.dpt.ta_id %}#{{node.da.dpt.pk}}" title="{% trans 'Abrir Edição do Dispositivo Alterador'%}">

2
sapl/templates/compilacao/text_edit_blocoalteracao.html

@ -5,7 +5,7 @@
<div class="dpt" id="dpt{{ch.id}}" pk="{{ch.id}}" >
<div class="{{ ch.tipo_dispositivo.class_css }}" id="id{{ch.id}}" nivel="{{ch.nivel}}" style="margin: 0px;">
<div class="de-compilacao" id="id{{ch.id}}" pk="{{ch.pk}}" ordem="{{ch.ordem}}" name="{{ch.pk}}" title="{{ch.pk}} - {{ch.ordem}}">
{{ ch.tipo_dispositivo.rotulo_prefixo_html|safe }}<a name="{{ch.pk}}" href="{% url 'sapl.compilacao:ta_text_edit' ch.ta.pk %}#{{ch.pk}}">{{ ch.rotulo }}</a>{{ ch.tipo_dispositivo.rotulo_sufixo_html|safe }}{{ ch.tipo_dispositivo.texto_prefixo_html|safe }}{{ ch.texto|safe }}
{{ ch.tipo_dispositivo.rotulo_prefixo_html|safe }}<a name="{{ch.pk}}" href="{% url 'sapl.compilacao:ta_text_edit' ch.ta.pk %}#{{ch.pk}}">{{ ch.rotulo }}</a>{{ ch.tipo_dispositivo.rotulo_sufixo_html|safe }}{{ ch.tipo_dispositivo.texto_prefixo_html|safe }}{{ ch.texto|sanitize }}
</div>
</div>
</div>

2
sapl/templates/compilacao/text_list_bloco.html

@ -32,7 +32,7 @@
{% endif %}
<div class="dtxt" id="d{% if not dpt.dispositivo_subsequente_id and dpt.dispositivo_substituido_id %}a{% endif %}{{dpt.pk}}" pks="{{dpt.dispositivo_substituido_id|default:''}}" pk="{{dpt.pk}}">{{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|safe }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %}&nbsp;{% endif %}{% endif %}</div>
<div class="dtxt" id="d{% if not dpt.dispositivo_subsequente_id and dpt.dispositivo_substituido_id %}a{% endif %}{{dpt.pk}}" pks="{{dpt.dispositivo_substituido_id|default:''}}" pk="{{dpt.pk}}">{{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|sanitize }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %}&nbsp;{% endif %}{% endif %}</div>
{% if dpt.ta_publicado_id %}
<a class="nota-alteracao" href="{%url 'sapl.compilacao:ta_text' dpt.ta_publicado.pk %}#{{dpt.dispositivo_atualizador_id}}">
{{ dpt.tipo_dispositivo.nota_automatica_prefixo_html|safe }}

6
sapl/templates/compilacao/text_list_blocoalteracao.html

@ -19,15 +19,15 @@
{% if ch.rotulo %}
<a name="{{ch.pk}}" href="{% url 'sapl.compilacao:ta_text' ch.ta.pk %}#{{ch.pk}}">{{ ch.rotulo }}</a>
{{ ch.tipo_dispositivo.rotulo_sufixo_html|safe }}
{{ ch.tipo_dispositivo.texto_prefixo_html|safe }}{% if ch.texto_atualizador %}{{ ch.texto_atualizador|safe }}{%else%}{{ ch.texto|safe }}{% endif %}
{{ ch.tipo_dispositivo.texto_prefixo_html|safe }}{% if ch.texto_atualizador %}{{ ch.texto_atualizador|sanitize }}{%else%}{{ ch.texto|sanitize }}{% endif %}
{% elif not ch.rotulo and not ch.auto_inserido %}
<a name="{{ch.pk}}" href="{% url 'sapl.compilacao:ta_text' ch.ta.pk %}#{{ch.pk}}">
{{ ch.tipo_dispositivo.rotulo_sufixo_html|safe }}
{{ ch.tipo_dispositivo.texto_prefixo_html|safe }}{% if ch.texto_atualizador %}{{ ch.texto_atualizador|safe }}{%else%}{{ ch.texto|safe }}{% endif %}
{{ ch.tipo_dispositivo.texto_prefixo_html|safe }}{% if ch.texto_atualizador %}{{ ch.texto_atualizador|sanitize }}{%else%}{{ ch.texto|sanitize }}{% endif %}
</a>
{% else %}
{{ ch.tipo_dispositivo.rotulo_sufixo_html|safe }}
{{ ch.tipo_dispositivo.texto_prefixo_html|safe }}{% if ch.texto_atualizador %}{{ ch.texto_atualizador|safe }}{%else%}{{ ch.texto|safe }}{% endif %}
{{ ch.tipo_dispositivo.texto_prefixo_html|safe }}{% if ch.texto_atualizador %}{{ ch.texto_atualizador|sanitize }}{%else%}{{ ch.texto|sanitize }}{% endif %}
{% endif %}
{% if ch.imagem %}

2
sapl/templates/compilacao/text_notificacoes.html

@ -23,7 +23,7 @@
<span class="dtxt"
id="d{% if not dpt.dispositivo_subsequente_id and dpt.dispositivo_substituido_id %}a{% endif %}{{dpt.pk}}"
pks="{{dpt.dispositivo_substituido_id|default:''}}"
pk="{{dpt.pk}}">{{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|safe }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %}&nbsp;{% endif %}{% endif %}</span>
pk="{{dpt.pk}}">{{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|sanitize }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %}&nbsp;{% endif %}{% endif %}</span>
{%if dpt.ta_publicado_id %}
<a class="nota-alteracao clearfix" href="{%url 'sapl.compilacao:ta_text' dpt.ta_publicado.pk %}#{{dpt.dispositivo_atualizador_id}}">

2
sapl/templates/compilacao/textoarticulado_detail.html

@ -75,7 +75,7 @@
<div class="col-md-12">
<div id="div_id_ementa" class="holder">
<label>{% field_verbose_name object 'ementa' %}</label>
<p>{{ object.ementa|safe}}</p>
<p>{{ object.ementa}}</p>
</div>
</div>
</div>

2
sapl/templates/materia/acompanhamento_materia.html

@ -19,7 +19,7 @@
</div>
<div class="row">
<div class="col-md-12"><b>Ementa:</b> {{materia.ementa|safe}}</div>
<div class="col-md-12"><b>Ementa:</b> {{materia.ementa}}</div>
</div>
{% crispy form %}

2
sapl/templates/materia/materialegislativa_filter.html

@ -153,7 +153,7 @@
{% if m.tramitacao_set.first.data_tramitacao %}
<strong>Data da última Tramitação:</strong> &nbsp;{{m.tramitacao_set.first.data_tramitacao}}</br>
{% if m.tramitacao_set.first.texto %}
<strong>Última Ação:</strong> &nbsp; {{m.tramitacao_set.first.texto|safe}}</br>
<strong>Última Ação:</strong> &nbsp; {{m.tramitacao_set.first.texto}}</br>
{% endif %}
{% endif %}
{% if m.anexo_de.exists %}

2
sapl/templates/materia/resumo_detail_materia.html

@ -3,5 +3,5 @@
<div class="col-md-4"><b>Número: </b>{{object.numero}}</div>
<div class="col-md-4"><b>Ano: </b>{{object.ano}}</div>
</div>
<b>Ementa:</b> {{object.ementa|safe}}
<b>Ementa:</b> {{object.ementa}}
<br /><br />

2
sapl/templates/norma/normajuridica_filter.html

@ -62,7 +62,7 @@
{% endif %}
<strong>Ementa:</strong><br>
{{n.ementa|safe}}<br>
{{n.ementa}}<br>
{% if n.texto_articulado.exists and not n.texto_articulado.first.privacidade %}
<a href="{% url 'sapl.norma:normajuridica_detail' n.id %}">

22
sapl/templates/painel/index.html

@ -313,11 +313,17 @@
if (data["status_painel"] == true) {
mostrar_voto = data["mostrar_voto"];
var tem_voto_individual = data['tipo_votacao'] == 'Nominal';
presentes.append('<table id="parlamentares_list">');
$.each(presentes_list, function (index, parlamentar) {
if (parlamentar.voto == 'Voto Informado' && mostrar_voto == false){
if (!tem_voto_individual){
$('#parlamentares_list').append('<tr><td style="padding-right:20px" >' +
parlamentar.nome +
'</td> <td style="padding-right:20px">' +
parlamentar.partido + '</td> <td style="padding-right:20px">'
+ '</td></tr>')
}
else if (parlamentar.voto == 'Voto Informado' && mostrar_voto == false){
$('#parlamentares_list').append('<tr><td style="padding-right:20px; color:yellow" >' +
parlamentar.nome +
'</td> <td style="padding-right:20px; color:yellow">' +
@ -325,7 +331,7 @@
+ '</td></tr>')
}
else{
$('#parlamentares_list').append(show_voto(parlamentar))
$('#parlamentares_list').append(show_voto(parlamentar, !!data['registro']))
}
});
@ -504,8 +510,13 @@
})();
});
function show_voto(parlamentar) {
function show_voto(parlamentar, registrada) {
color = 'white'
// Com a votação ainda aberta, voto vazio só significa "ainda não votou".
if (!parlamentar.voto && registrada) {
parlamentar.voto = "Não votou";
}
if (parlamentar.voto == "Sim"){
color = 'green'
}
@ -529,4 +540,3 @@
</script>
</html>

120
sapl/templates/painel/mensagem.html

@ -1,120 +0,0 @@
{% load i18n %}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]-->
<!--[if gt IE 8]><!-->
<html lang="pt-br">
<!--<![endif]-->
<head>
<meta charset="UTF-8">
<!-- TODO: does it need this head_title here? -->
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
<STYLE type="text/css">
@media screen {
body {font-size: medium; color: white; line-height: 1em; background: black;}
}
</STYLE>
</head>
<body>
<h1>{{ context.title }}</h1>
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}">
<h2>Ajax refresh counter: <span id="counter"></span></h2>
<h3>
<span id="sessao_plenaria"></span><br/><br/>
<span id="sessao_plenaria_data"></span><br/><br/>
<span id="sessao_plenaria_hora_inicio"></span></br><br/>
<h2><span id="relogio"></span></h2></br><br/><br/>
<span id="materia_legislativa_texto"></span><br/>
<span id="observacao_materia"></span>
</h3>
</body>
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'painel' 'js' %}
<script type="text/javascript">
$(document).ready(function() {
//TODO: replace by a fancy jQuery clock
function checkTime(i) {
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10
return i;
}
function startTime() {
var today=new Date();
var h=today.getHours();
var m=today.getMinutes();
var s=today.getSeconds();
m = checkTime(m);
s = checkTime(s);
$("#relogio").text(h+":"+m+":"+s)
var t = setTimeout(function(){
startTime()
},500);
}
startTime();
var counter = 1;
(function poll() {
$.ajax({
url: $("#json_url").val(),
type: "GET",
success: function(data) {
//TODO: json spitted out is very complex, have to simplify/flat it
//TODO: probably building it by hand on REST side
console.debug(data)
var presentes = $("#parlamentares");
presentes.children().remove();
presentes_ordem_dia = data.presentes_ordem_dia
$.each(presentes_ordem_dia, function(index, parlamentar) {
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes);
});
var votacao = $("#votacao")
votacao.children().remove()
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>")
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>")
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>")
votacao.append("<li>Presentes: " + data["presentes"] + "</li>")
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>")
$("#sessao_plenaria").text(data["sessao_plenaria"])
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"])
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"])
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"])
$("#observacao_materia").text(data["observacao_materia"])
$("#resultado_votacao").text(data["tipo_resultado"])
$("#counter").text(counter);
counter++;
},
error: function(err) {
console.error(err);
},
dataType: "json",
//complete: setTimeout(function() {poll()}, 5000),
timeout: 20000 // TODO: decrease
})
})();
});
</script>
</html>

128
sapl/templates/painel/parlamentares.html

@ -1,128 +0,0 @@
{% load i18n %}
{% load common_tags %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]-->
<!--[if gt IE 8]><!-->
<html lang="pt-br">
<!--<![endif]-->
<head>
<meta charset="UTF-8">
<!-- TODO: does it need this head_title here? -->
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
<STYLE type="text/css">
@media screen {
body {font-size: medium; color: white; line-height: 1em; background: black;}
}
</STYLE>
</head>
<body>
<h1>{{ context.title }}</h1>
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}">
<h3>
<span id="sessao_plenaria"></span><br/><br/>
<span id="sessao_plenaria_data"></span><br/><br/>
<span id="sessao_plenaria_hora_inicio"></span></br><br/>
<h2><span id="relogio"></span></h2>
<table>
<tr>
<td>
<ul id="parlamentares">
</ul>
</td>
</tr>
</table>
</h3>
</body>
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'painel' 'js' %}
<script type="text/javascript">
$(document).ready(function() {
//TODO: replace by a fancy jQuery clock
function checkTime(i) {
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10
return i;
}
function startTime() {
var today=new Date();
var h=today.getHours();
var m=today.getMinutes();
var s=today.getSeconds();
m = checkTime(m);
s = checkTime(s);
$("#relogio").text(h+":"+m+":"+s)
var t = setTimeout(function(){
startTime()
},500);
}
startTime();
var counter = 1;
(function poll() {
$.ajax({
url: $("#json_url").val(),
type: "GET",
success: function(data) {
//TODO: json spitted out is very complex, have to simplify/flat it
//TODO: probably building it by hand on REST side
console.debug(data)
var presentes = $("#parlamentares");
presentes.children().remove();
presentes_ordem_dia = data.presentes_ordem_dia
$.each(presentes_ordem_dia, function(index, parlamentar) {
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido }).appendTo(presentes);
/*$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes);*/
});
var votacao = $("#votacao")
votacao.children().remove()
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>")
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>")
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>")
votacao.append("<li>Presentes: " + data["presentes"] + "</li>")
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>")
$("#sessao_plenaria").text(data["sessao_plenaria"])
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"])
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"])
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"])
$("#observacao_materia").text(data["observacao_materia"])
$("#resultado_votacao").text(data["tipo_resultado"])
$("#counter").text(counter);
counter++;
},
error: function(err) {
console.error(err);
},
dataType: "json",
//complete: setTimeout(function() {poll()}, 5000),
timeout: 20000 // TODO: decrease
})
})();
});
</script>
</html>

123
sapl/templates/painel/votacao.html

@ -1,123 +0,0 @@
{% load i18n %}
{% load render_bundle from webpack_loader %}
{% load webpack_static from webpack_loader %}
<!DOCTYPE HTML>
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]-->
<!--[if gt IE 8]><!-->
<html lang="pt-br">
<!--<![endif]-->
<head>
<meta charset="UTF-8">
<!-- TODO: does it need this head_title here? -->
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% render_chunk_vendors 'css' %}
{% render_bundle 'global' 'css' %}
{% render_bundle 'painel' 'css' %}
<STYLE type="text/css">
@media screen {
body {font-size: medium; color: white; line-height: 1em; background: black;}
}
</STYLE>
</head>
<body>
<h1>{{ context.title }}</h1>
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}">
<h3>
<span id="sessao_plenaria"></span><br/><br/>
<span id="sessao_plenaria_data"></span><br/><br/>
<span id="sessao_plenaria_hora_inicio"></span></br><br/>
<h2><span id="relogio"></span></h2>
<table>
<tr>
<td>
<ul id="votacao">
</ul>
</td>
</tr>
</table>
<span id="resultado_votacao"></span><br/>
</h3>
</body>
{% render_chunk_vendors 'js' %}
{% render_bundle 'global' 'js' %}
{% render_bundle 'painel' 'js' %}
<script type="text/javascript">
$(document).ready(function() {
//TODO: replace by a fancy jQuery clock
function checkTime(i) {
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10
return i;
}
function startTime() {
var today=new Date();
var h=today.getHours();
var m=today.getMinutes();
var s=today.getSeconds();
m = checkTime(m);
s = checkTime(s);
$("#relogio").text(h+":"+m+":"+s)
var t = setTimeout(function(){
startTime()
},500);
}
startTime();
var counter = 1;
(function poll() {
$.ajax({
url: $("#json_url").val(),
type: "GET",
success: function(data) {
//TODO: json spitted out is very complex, have to simplify/flat it
//TODO: probably building it by hand on REST side
console.debug(data)
var presentes = $("#parlamentares");
presentes.children().remove();
presentes_ordem_dia = data.presentes_ordem_dia
$.each(presentes_ordem_dia, function(index, parlamentar) {
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes);
});
var votacao = $("#votacao")
votacao.children().remove()
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>")
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>")
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>")
votacao.append("<li>Presentes: " + data["presentes"] + "</li>")
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>")
$("#sessao_plenaria").text(data["sessao_plenaria"])
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"])
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"])
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"])
$("#observacao_materia").text(data["observacao_materia"])
$("#resultado_votacao").text(data["tipo_resultado"])
$("#counter").text(counter);
counter++;
},
error: function(err) {
console.error(err);
},
dataType: "json",
//complete: setTimeout(function() {poll()}, 5000),
timeout: 20000 // TODO: decrease
})
})();
});
</script>
</html>

71
sapl/templates/painel/voto_individual.html

@ -46,6 +46,10 @@
</head>
<body>
{% for message in messages %}
<h3><font color="#f0ad4e"><p align="center" style="font-family:Verdana">{{ message }}</p></font></h3>
{% endfor %}
{% if not error_message %}
<div id="blocoprincipal" style="display:block">
<h1><b><font color="#4FA64D"><p align="center">{{sessao}}</p></font></b></h1>
@ -77,6 +81,11 @@
</p>
</font>
</h2>
{% if status_message %}
<p align="center" style="font-family:Verdana">
<font color="#8fbfe0">{{status_message}}</font>
</p>
{% endif %}
</br>
</br>
@ -111,7 +120,8 @@
</div>
{% else %}
<div id="blocoerro" style="display:block">
<h2><font color="red"><p align="center" style="font-family:Verdana">{{error_message}}</p></font></h2>
<h1><font color="#eaeaea"><p align="center" style="font-family:Verdana">⏸</p></font></h1>
<h2><font color="#f0ad4e"><p align="center" style="font-family:Verdana">{{error_message}}</p></font></h2>
</br>
</br>
</br>
@ -184,11 +194,62 @@
$("#date").text(n);
startTime();
setTimeout(function() {
document.location.reload(true);
}, 30000)
});
</script>
{% endif %}
<script type="text/javascript">
// Substitui o antigo reload automático de página inteira a cada 30s por
// um poll leve (fetch simples, sem depender do jQuery/bundle carregado
// condicionalmente acima) que só recarrega a página quando o estado
// realmente muda — economiza o custo de renderizar a página inteira a
// cada poll em cada um dos tablets. Roda nas duas telas (voto e erro),
// já que "aguardando abertura de matéria" também precisa se recuperar
// sozinho quando o estado mudar.
(function() {
var estadoAtual = {
materiaId: "{% if materia %}{{ materia.id }}{% endif %}",
errorMessage: "{{ error_message|default:''|escapejs }}",
statusMessage: "{{ status_message|default:''|escapejs }}",
votoParlamentar: "{{ voto_parlamentar|default:''|escapejs }}"
};
function verificaEstado() {
fetch("{% url 'sapl.painel:voto_individual_status' %}", {credentials: 'same-origin'})
.then(function(resp) {
// Sessão expirada (redirect para o login), 403 ou 500: recarrega
// para o tablet não ficar parado na tela antiga.
var contentType = resp.headers.get('content-type') || '';
if (!resp.ok || resp.redirected ||
contentType.indexOf('application/json') === -1) {
document.location.reload();
throw new Error('resposta inesperada');
}
return resp.json();
})
.then(function(data) {
var novo = {
materiaId: data.materia_id ? String(data.materia_id) : "",
errorMessage: data.error_message || "",
statusMessage: data.status_message || "",
votoParlamentar: data.voto_parlamentar || ""
};
if (novo.materiaId !== estadoAtual.materiaId ||
novo.errorMessage !== estadoAtual.errorMessage ||
novo.statusMessage !== estadoAtual.statusMessage ||
novo.votoParlamentar !== estadoAtual.votoParlamentar) {
document.location.reload();
}
})
.catch(function() { /* falha de rede: tenta de novo no próximo poll */ })
.then(function() {
// Agenda o próximo só depois deste terminar, para não sobrepor
// polls quando o servidor está lento.
setTimeout(verificaEstado, 5000);
});
}
setTimeout(verificaEstado, 5000);
})();
</script>

2
sapl/templates/parlamentares/parlamentar_perfil_publico.html

@ -61,7 +61,7 @@
<div class="col-sm-8">
<div id="div_biografia" class="form-group">
<p><b>Biografia: </b> &nbsp {{object.biografia|safe}}</p>
<p><b>Biografia: </b> &nbsp {{object.biografia|sanitize}}</p>
</div>
</div>
</div>

2
sapl/templates/protocoloadm/acompanhamento_documento.html

@ -19,7 +19,7 @@
</div>
<div class="row">
<div class="col-md-12"><b>Assunto:</b> {{documento.assunto|safe}}</div>
<div class="col-md-12"><b>Assunto:</b> {{documento.assunto}}</div>
</div>
{% crispy form %}

2
sapl/templates/protocoloadm/documentoadministrativo_filter.html

@ -48,7 +48,7 @@
<a href="{% url 'sapl.protocoloadm:documentoadministrativo_detail' d.id %}"><strong>{{d}}</strong></a></br>
<strong>Interessado:</strong>&nbsp;{{ d.interessado|default_if_none:"Não informado"}}
</br>
<strong>Assunto:</strong>&nbsp;{{ d.assunto|safe }}
<strong>Assunto:</strong>&nbsp;{{ d.assunto }}
</br>
{% if d.protocolo %}
<strong>Protocolo:</strong>&nbsp;<a href="{% url 'sapl.protocoloadm:protocolo_mostrar' d.protocolo.id %}">{{ d.protocolo}}</a></br>

2
sapl/templates/relatorios/RelatorioMateriasPorAutor_filter.html

@ -54,7 +54,7 @@
{{materia.tipo.sigla}} {{materia.numero}}/{{materia.ano}}
</a>
</td>
<td>{% autoescape off %}{{materia.ementa}}<br>{{materia.observacao}}{% endautoescape %}</td>
<td>{{materia.ementa}}<br>{{materia.observacao}}</td>
<td>
{% for autoria in materia.autoria_set.all %}
{% if autoria.autor != autor %}

2
sapl/templates/relatorios/RelatorioNormasPorAutor_filter.html

@ -52,7 +52,7 @@
<td><a href="{% url 'sapl.norma:normajuridica_detail' norma.pk %}">
{{norma.tipo.sigla}} {{norma.numero}}/{{norma.ano}}
</a></td>
<td>{% autoescape off %}{{norma.ementa}}<br>{{norma.observacao}}{% endautoescape %}</td>
<td>{{norma.ementa}}<br>{{norma.observacao}}</td>
<td>
{% for autoria in norma.autorianorma_set.all %}
{% if autoria.autor != autor %}

3
sapl/templates/relatorios/blocos_sessao_plenaria/consideracoes_finais.html

@ -1,4 +1,5 @@
{% load common_tags %}
<h2 class="gray-title">Considerações Finais</h2>
{% for c in lst_consideracoes%}
<p>{{c|striptags|safe}}</p>
<p>{{c|sanitize|striptags}}</p>
{% endfor %}

3
sapl/templates/relatorios/blocos_sessao_plenaria/expedientes.html

@ -1,5 +1,6 @@
{% load common_tags %}
<h2 class="gray-title">Expedientes</h2>
{% for expediente in lst_expedientes%}
<h3>{{expediente.nom_expediente}}</h3>
<div style="margin-bottom: 1cm">{{expediente.txt_expediente|safe}}</div>
<div style="margin-bottom: 1cm">{{expediente.txt_expediente|sanitize}}</div>
{% endfor%}

3
sapl/templates/relatorios/blocos_sessao_plenaria/ocorrencias_da_sessao.html

@ -1,4 +1,5 @@
{% load common_tags %}
<h2 class="gray-title">Ocorrências da Sessão</h2>
{% for o in lst_ocorrencias%}
<p>{{o.conteudo|striptags|safe}}</p>
<p>{{o.conteudo|sanitize|striptags}}</p>
{% endfor %}

2
sapl/templates/relatorios/relatorio_doc_administrativos.html

@ -13,7 +13,7 @@
<strong><a href="{% url 'sapl.protocoloadm:documentoadministrativo_detail' d.id %}">{{d.tipo.sigla}} {{d.numero}}/{{d.ano}} - {{d.tipo}}</strong></a></br>
<strong>Interessado:</strong>&nbsp;{{ d.interessado|default_if_none:"Não informado"}}
</br>
<strong>Assunto:</strong>&nbsp;<a>{{ d.assunto|safe}}</a>
<strong>Assunto:</strong>&nbsp;<a>{{ d.assunto}}</a>
</br>
{% if d.protocolo %}
<strong>Protocolo:</strong>&nbsp;<a href="{% url 'sapl.protocoloadm:protocolo_mostrar' d.protocolo.id %}">{{ d.protocolo}}</a></br>

2
sapl/templates/relatorios/relatorio_materia_tramitacao.html

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

6
sapl/templates/relatorios/relatorio_materias_por_autor.html

@ -10,7 +10,7 @@
&emsp;Tipo de matéria: {{ tipo }}<br />
&emsp;Data de apresentação: {{periodo}}<br /><br /><br/>
{% if materias_resultado|length %}
{% if object_list|length %}
<table class="table table-bordered table-hover">
<thead class="thead-default">
<tr class="active">
@ -31,8 +31,8 @@
</tbody>
</table>
</br></br>
{% if materias_resultado|length %}
{% for materia in materias_resultado %}
{% if object_list|length %}
{% for materia in object_list %}
<strong>Matéria:</strong> {{materia.tipo.descricao}} {{materia.numero}}/{{materia.ano}}
</br>
<strong>Ementa:</strong>

2
sapl/templates/relatorios/relatorio_normas_por_autor.html

@ -47,7 +47,7 @@
<td>
{{norma.tipo.sigla}} {{norma.numero}}/{{norma.ano}}
</td>
<td>{% autoescape off %}{{norma.ementa}}<br>{{norma.observacao}}{% endautoescape %}</td>
<td>{{norma.ementa}}<br>{{norma.observacao}}</td>
<td>
{% if norma.autorianorma_set.first != norma.autorianorma_set.last %}
{% for autor in norma.autorianorma_set.all %}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save