diff --git a/CHANGES.md b/CHANGES.md index e7b78bbd7..eb37d3303 100644 --- a/CHANGES.md +++ b/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 ======================== diff --git a/docker/Dockerfile b/docker/Dockerfile index 831627ec8..a1dc565f4 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -51,7 +51,7 @@ ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \ RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ - curl jq bash tzdata fontconfig tini libmagic1 \ + curl jq bash tzdata fontconfig tini libmagic1 media-types \ libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf-2.0-0 \ libharfbuzz0b libfreetype6 libjpeg62-turbo zlib1g fonts-dejavu-core; \ if [ "$WITH_GRAPHVIZ" = "1" ]; then apt-get install -y --no-install-recommends graphviz; fi; \ diff --git a/docker/config/nginx/sapl.conf b/docker/config/nginx/sapl.conf index 015538c96..ce5171a00 100644 --- a/docker/config/nginx/sapl.conf +++ b/docker/config/nginx/sapl.conf @@ -49,8 +49,70 @@ server { alias /var/interlegis/sapl/collected_static/; } + # Arquivos que dependem de autorização vão ao Django (sapl.base.media), + # que devolve X-Accel-Redirect para /media-interna/. Manter a lista em + # sincronia com PROTEGIDO_RE e MODELOS_DE_SESSAO (teste em + # sapl/base/tests/test_media.py). Os dois regex ficam no nível do server + # e antes de location /media/: um regex aninhado em /media/ teria + # precedência sobre este. + location ~* ^/media/sapl/(private/|[^/]+/(documentoadministrativo|documentoacessorioadministrativo|proposicao|sessaoplenaria|orador|oradorexpediente|oradorordemdia|justificativaausencia)/) { + proxy_set_header X-Request-ID $req_id; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_pass http://sapl_server; + } + + # Demais arquivos são públicos e servidos direto do disco; conteúdo que o + # navegador executaria como página é forçado a download. + location ~* ^/media/.+\.(html?|xhtml|xht|shtml|svgz?|xml|xsl|xslt|rdf|js|mjs|mht|mhtml|rss|atom|mml|kml|xspf)$ { + root /var/interlegis/sapl; + types { } + default_type application/octet-stream; + add_header Content-Disposition attachment always; + add_header X-Content-Type-Options nosniff always; + } + + # Só os tipos abaixo saem com Content-Type próprio; o resto (inclusive + # +xml que o mime.types global entregaria inline) vai como octet-stream. location /media/ { - alias /var/interlegis/sapl/media/; + root /var/interlegis/sapl; + types { + application/pdf pdf; + image/jpeg jpg jpeg jpe; + image/png png; + image/gif gif; + image/webp webp; + image/bmp bmp; + image/tiff tif tiff; + image/x-icon ico; + text/plain txt; + text/csv csv; + audio/mpeg mp3; + audio/ogg ogg; + video/mp4 mp4; + video/webm webm; + application/rtf rtf; + application/zip zip; + application/msword doc; + application/vnd.ms-excel xls; + application/vnd.ms-powerpoint ppt; + application/vnd.openxmlformats-officedocument.wordprocessingml.document docx; + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx; + application/vnd.openxmlformats-officedocument.presentationml.presentation pptx; + application/vnd.oasis.opendocument.text odt; + application/vnd.oasis.opendocument.spreadsheet ods; + application/vnd.oasis.opendocument.presentation odp; + } + default_type application/octet-stream; + add_header X-Content-Type-Options nosniff always; + } + + location /media-interna/ { + internal; + alias /var/interlegis/sapl/media/; + add_header X-Content-Type-Options nosniff always; } location / { diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index dc8559812..6f4e21780 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -33,7 +33,7 @@ services: networks: - sapl-net sapl: - image: interlegis/sapl:3.1.165-RC2 + image: interlegis/sapl:3.1.165-RC4 # build: # context: ../ # dockerfile: ./docker/Dockerfile diff --git a/frontend/src/__apps/compilacao/js/old/compilacao.js b/frontend/src/__apps/compilacao/js/old/compilacao.js index f7176c473..8853d6425 100644 --- a/frontend/src/__apps/compilacao/js/old/compilacao.js +++ b/frontend/src/__apps/compilacao/js/old/compilacao.js @@ -279,6 +279,69 @@ function DispositivoSearch (opts) { }) } +// Escala de leitura do texto articulado, preferência de cada navegador. +// Aplicada via variável CSS (--cp-font-scale) para não alterar o conteúdo +// dos dispositivos e para valer também nos que são recarregados via ajax. +const EscalaFonte = Object.freeze({ + CHAVE: 'compilacao_escala_fonte', + PADRAO: 1, + PASSO: 0.1, + MIN: 0.8, + MAX: 2.5 +}) +let escalaFonteAtual = null + +// Limita num ao intervalo [lower, upper]. +function clamp (num, lower, upper) { + return Math.max(lower, Math.min(num, upper)) +} + +function lerEscalaFonte () { + if (escalaFonteAtual !== null) { + return escalaFonteAtual + } + let valor = null + try { + valor = window.localStorage.getItem(EscalaFonte.CHAVE) + } catch (e) { + // localStorage indisponível (ex.: navegação privada bloqueada) + console.warn('Escala de fonte: não foi possível ler o localStorage.', e) + return EscalaFonte.PADRAO + } + if (valor === null) { + return EscalaFonte.PADRAO + } + const escala = parseFloat(valor) + if (!Number.isFinite(escala)) { + console.warn('Escala de fonte: valor inválido no localStorage, usando o padrão.', valor) + return EscalaFonte.PADRAO + } + return clamp(escala, EscalaFonte.MIN, EscalaFonte.MAX) +} + +function aplicarEscalaFonte (escala) { + document.documentElement.style.setProperty('--cp-font-scale', escala) + if (window.tinymce) { + window.tinymce.get().forEach(function (editor) { + if (editor.getBody()) { + editor.getBody().style.fontSize = (escala * 100) + '%' + } + }) + } +} + +function salvarEscalaFonte (escala) { + escala = Math.round(clamp(escala, EscalaFonte.MIN, EscalaFonte.MAX) * 10) / 10 + try { + window.localStorage.setItem(EscalaFonte.CHAVE, escala) + } catch (e) { + // localStorage indisponível: a escala vale apenas até recarregar a página + console.warn('Escala de fonte: não foi possível salvar no localStorage.', escala, e) + } + escalaFonteAtual = escala + aplicarEscalaFonte(escala) +} + function InitViewTAs () { setTimeout(function () { var href = location.href.split('#') @@ -297,11 +360,12 @@ function InitViewTAs () { } }, 100) + aplicarEscalaFonte(lerEscalaFonte()) $('#btn_font_menos').click(function () { - $('.dpt').css('font-size', '-=1') + salvarEscalaFonte(lerEscalaFonte() - EscalaFonte.PASSO) }) $('#btn_font_mais').click(function () { - $('.dpt').css('font-size', '+=1') + salvarEscalaFonte(lerEscalaFonte() + EscalaFonte.PASSO) }) $('.dpt.bloco_alteracao .dpt').each(function () { @@ -327,5 +391,7 @@ export default { ReadCookie, insertWaitAjax, InitViewTAs, + lerEscalaFonte, + aplicarEscalaFonte, DispositivoSearch } diff --git a/frontend/src/__apps/compilacao/js/old/compilacao_edit.js b/frontend/src/__apps/compilacao/js/old/compilacao_edit.js index ba651188b..27060135b 100644 --- a/frontend/src/__apps/compilacao/js/old/compilacao_edit.js +++ b/frontend/src/__apps/compilacao/js/old/compilacao_edit.js @@ -244,7 +244,9 @@ window.DispositivoEdit = function () { if (editortype !== 'construct') { dpt_form.html(data) if (editortype === 'tinymce') { - window.initTextRichEditor(null, false, false) + window.initTextRichEditor(null, false, false).then(function () { + window.aplicarEscalaFonte(window.lerEscalaFonte()) + }) } // OptionalCustomFrontEnd().init() } diff --git a/frontend/src/__apps/compilacao/scss/compilacao.scss b/frontend/src/__apps/compilacao/scss/compilacao.scss index 82c52f7f6..842436b24 100644 --- a/frontend/src/__apps/compilacao/scss/compilacao.scss +++ b/frontend/src/__apps/compilacao/scss/compilacao.scss @@ -164,12 +164,21 @@ a:link:after, a:visited:after { } .cp { + /* --cp-font-scale: escala de leitura escolhida pelo usuário (a/A) */ + font-size: calc(100% * var(--cp-font-scale, 1)); + + .dpt-form textarea.form-control { + font-size: calc(1rem * var(--cp-font-scale, 1)); + } + .vigencia-active { margin-top: 30px; display: block; } .cp-linha-vigencias { + /* navegação entre vigências não acompanha a escala de leitura */ + font-size: calc(100% / var(--cp-font-scale, 1)); display: flex; flex-direction: row; list-style: none; diff --git a/frontend/src/__global/js/tinymce/index.js b/frontend/src/__global/js/tinymce/index.js index 51eed1efa..ee20a7089 100644 --- a/frontend/src/__global/js/tinymce/index.js +++ b/frontend/src/__global/js/tinymce/index.js @@ -14,7 +14,23 @@ import 'tinymce/plugins/table' import './langs/pt_BR.js' window.tinymce = tinymce -window.initTextRichEditor = function (elements, readonly = false, paste_as_text = false) { +// Títulos (h1-h6) vindos de estilos de título do Word ou de páginas web +// chegam em negrito e não podem ser desfeitos pelo botão de negrito nem +// por "remover formatação". Converte-os em parágrafos, mantendo o alinhamento. +function colarTitulosComoParagrafo (editor, args) { + args.node.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(function (titulo) { + const paragrafo = document.createElement('p') + if (titulo.style.textAlign) { + paragrafo.style.textAlign = titulo.style.textAlign + } + while (titulo.firstChild) { + paragrafo.appendChild(titulo.firstChild) + } + titulo.replaceWith(paragrafo) + }) +} + +window.initTextRichEditor = function (elements, readonly = false, paste_as_text = false, paste_titulos_como_paragrafo = false) { const configTinymce = { selector: elements === null || elements === undefined ? 'textarea' : elements, language: 'pt_BR', @@ -22,14 +38,17 @@ window.initTextRichEditor = function (elements, readonly = false, paste_as_text forced_root_block: 'p', paste_as_text, plugins: 'table lists advlist link code', - toolbar: 'undo redo | styleselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | code | removeformat ', + toolbar: 'undo redo | blocks | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | code | removeformat ', menubar: 'file edit view insert format table', license_key: 'gpl' } + if (paste_titulos_como_paragrafo) { + configTinymce.paste_postprocess = colarTitulosComoParagrafo + } if (readonly) { configTinymce.readonly = 1 configTinymce.menubar = false configTinymce.toolbar = false } - window.tinymce.init(configTinymce) + return window.tinymce.init(configTinymce) } diff --git a/requirements/requirements.txt b/requirements/requirements.txt index ca05c71b7..79a88872f 100644 --- a/requirements/requirements.txt +++ b/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 diff --git a/sapl/api/serializers.py b/sapl/api/serializers.py index b8bb06b4d..f4a82db35 100644 --- a/sapl/api/serializers.py +++ b/sapl/api/serializers.py @@ -12,7 +12,7 @@ from rest_framework.fields import SerializerMethodField from sapl.base.models import Autor, CasaLegislativa, Metadata from sapl.parlamentares.models import Parlamentar, Mandato, Legislatura -from sapl.sessao.models import OrdemDia, SessaoPlenaria +from sapl.sessao.models import ExpedienteMateria, OrdemDia, SessaoPlenaria class SaplSerializerMixin(DrfAutoApiSerializerMixin): @@ -317,5 +317,25 @@ class SessaoPlenariaECidadaniaSerializer(serializers.ModelSerializer): return self.casa().nome def casa(self): - casa = CasaLegislativa.objects.first() - return casa + return CasaLegislativa.objects.first() + + +class OrdemDiaSerializer(SaplSerializerMixin): + """ + votacao_aberta/registro_aberto só podem ser alterados através do fluxo + de abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que + garante a invariante de no máximo uma matéria aberta por vez. Editáveis + aqui, a API contornaria essa garantia por completo. + """ + class Meta: + model = OrdemDia + fields = '__all__' + read_only_fields = ('votacao_aberta', 'registro_aberto') + + +class ExpedienteMateriaSerializer(SaplSerializerMixin): + """Ver OrdemDiaSerializer — mesmo motivo para os mesmos dois campos.""" + class Meta: + model = ExpedienteMateria + fields = '__all__' + read_only_fields = ('votacao_aberta', 'registro_aberto') diff --git a/sapl/api/tests/test_serializers_sessao.py b/sapl/api/tests/test_serializers_sessao.py new file mode 100644 index 000000000..69e8d7259 --- /dev/null +++ b/sapl/api/tests/test_serializers_sessao.py @@ -0,0 +1,19 @@ +import pytest +from model_bakery import baker + +from sapl.api.serializers import SessaoPlenariaECidadaniaSerializer +from sapl.base.models import CasaLegislativa +from sapl.sessao.models import SessaoPlenaria + + +@pytest.mark.django_db(transaction=False) +def test_sessao_ecidadania_serializa_dados_da_casa(): + casa = baker.make(CasaLegislativa, nome='Câmara Municipal', sigla='CM', + endereco='Praça Central') + sessao = baker.make(SessaoPlenaria) + + data = SessaoPlenariaECidadaniaSerializer(sessao).data + + assert data['txtNomeOrgao'] == casa.nome + assert data['txtSiglaOrgao'] == casa.sigla + assert data['txtLocal'] == casa.endereco diff --git a/sapl/api/views_sessao.py b/sapl/api/views_sessao.py index 75807f8cd..29c31bf29 100644 --- a/sapl/api/views_sessao.py +++ b/sapl/api/views_sessao.py @@ -7,7 +7,8 @@ from drfautoapi.drfautoapi import ApiViewSetConstrutor, \ customize, wrapper_queryset_response_for_drf_action from sapl.api.serializers import ChoiceSerializer,\ SessaoPlenariaECidadaniaSerializer -from sapl.sessao.models import SessaoPlenaria, ExpedienteSessao +from sapl.sessao.models import SessaoPlenaria, ExpedienteSessao,\ + restringe_sessoes_visiveis from sapl.utils import choice_anos_com_sessaoplenaria @@ -21,6 +22,13 @@ ApiViewSetConstrutor.build_class( @customize(SessaoPlenaria) class _SessaoPlenariaViewSet: + def get_queryset(self): + qs = super().get_queryset() + # actions com wrapper_queryset_response_for_drf_action trocam o model + if qs.model is SessaoPlenaria: + qs = restringe_sessoes_visiveis(qs, self.request.user) + return qs + @action(detail=False) def years(self, request, *args, **kwargs): years = choice_anos_com_sessaoplenaria() @@ -30,6 +38,7 @@ class _SessaoPlenariaViewSet: @action(detail=True) def expedientes(self, request, *args, **kwargs): + self.get_object() return self.get_expedientes() @wrapper_queryset_response_for_drf_action(model=ExpedienteSessao) @@ -45,3 +54,20 @@ class _SessaoPlenariaViewSet: def ecidadania_list(self, request, *args, **kwargs): self.serializer_class = SessaoPlenariaECidadaniaSerializer return self.list(request, *args, **kwargs) + + +class _RestringeSessoesVisiveisMixin: + + def get_queryset(self): + qs = super().get_queryset() + if self.request.user.is_authenticated: + return qs + return qs.filter(sessao_plenaria__in=restringe_sessoes_visiveis( + SessaoPlenaria.objects.all(), self.request.user)) + + +for _model in apps.get_app_config('sessao').get_models(): + if _model is not SessaoPlenaria and any( + f.name == 'sessao_plenaria' for f in _model._meta.fields): + customize(_model)(type('_%sViewSet' % _model.__name__, + (_RestringeSessoesVisiveisMixin,), {})) diff --git a/sapl/audiencia/forms.py b/sapl/audiencia/forms.py index f1978032f..d2c7dd45b 100755 --- a/sapl/audiencia/forms.py +++ b/sapl/audiencia/forms.py @@ -10,9 +10,9 @@ from django.utils.translation import ugettext_lazy as _ from crispy_forms.layout import Button, Column, Fieldset, HTML, Layout from sapl.audiencia.models import AudienciaPublica, TipoAudienciaPublica, AnexoAudienciaPublica +from sapl.base.models import Autor from sapl.crispy_layout_mixin import form_actions, SaplFormHelper, SaplFormLayout, to_row from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa -from sapl.parlamentares.models import Parlamentar from sapl.utils import timezone, FileFieldCheckMixin, validar_arquivo @@ -44,10 +44,10 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm): widget=forms.HiddenInput(), queryset=MateriaLegislativa.objects.all()) - parlamentar_autor = forms.ModelChoiceField( - label=_("Parlamentar Autor"), + autor = forms.ModelChoiceField( + label=_("Autor"), required=False, - queryset=Parlamentar.objects.all()) + queryset=Autor.objects.all().order_by('nome')) requerimento = forms.ModelChoiceField( label=_("Requerimento"), @@ -58,7 +58,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm): model = AudienciaPublica fields = ['tipo', 'numero', 'ano', 'nome', 'tema', 'data', 'hora_inicio', 'hora_fim', - 'observacao', 'audiencia_cancelada', 'parlamentar_autor', 'requerimento', 'url_audio', + 'observacao', 'audiencia_cancelada', 'autor', 'requerimento', 'url_audio', 'url_video', 'upload_pauta', 'upload_ata', 'upload_anexo', 'tipo_materia', 'numero_materia', 'ano_materia', 'materia'] @@ -85,7 +85,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm): materia = cleaned_data['numero_materia'] ano_materia = cleaned_data['ano_materia'] tipo_materia = cleaned_data['tipo_materia'] - parlamentar_autor = cleaned_data["parlamentar_autor"] + autor = cleaned_data["autor"] requerimento = cleaned_data["requerimento"] if cleaned_data["ano"] != cleaned_data["data"].year: @@ -157,12 +157,12 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm): raise ValidationError(msg) # requerimento é optativo - if parlamentar_autor and requerimento: - if parlamentar_autor.autor.first() not in requerimento.autores.all(): - raise ValidationError("Parlamentar Autor selecionado não faz" + if autor and requerimento: + if autor not in requerimento.autores.all(): + raise ValidationError("Autor selecionado não faz" " parte da autoria do Requerimento " "selecionado.") - elif parlamentar_autor: + elif autor: raise ValidationError("Para informar um autor deve-se informar um requerimento.") elif requerimento: raise ValidationError("Para informar um requerimento deve-se informar um autor.") diff --git a/sapl/audiencia/migrations/0021_auto_20260623_1938.py b/sapl/audiencia/migrations/0021_auto_20260623_1938.py new file mode 100644 index 000000000..a2e101c64 --- /dev/null +++ b/sapl/audiencia/migrations/0021_auto_20260623_1938.py @@ -0,0 +1,59 @@ +# Generated by Django 2.2.28 on 2026-06-23 22:38 + +from django.db import migrations, models +import django.db.models.deletion + + +def copia_parlamentar_para_autor(apps, schema_editor): + """Converte o antigo parlamentar_autor no Autor genérico correspondente.""" + AudienciaPublica = apps.get_model('audiencia', 'AudienciaPublica') + Autor = apps.get_model('base', 'Autor') + ContentType = apps.get_model('contenttypes', 'ContentType') + try: + ct = ContentType.objects.get( + app_label='parlamentares', model='parlamentar') + except ContentType.DoesNotExist: + return + for ap in AudienciaPublica.objects.filter(parlamentar_autor__isnull=False): + autor = Autor.objects.filter( + content_type=ct, object_id=ap.parlamentar_autor_id).first() + if autor: + ap.autor = autor + ap.save(update_fields=['autor']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0060_auto_20240812_1628'), + ('audiencia', '0020_auto_20251201_1450'), + ] + + operations = [ + migrations.AddField( + model_name='audienciapublica', + name='autor', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='base.Autor', verbose_name='Autor'), + ), + migrations.RunPython( + copia_parlamentar_para_autor, migrations.RunPython.noop), + # Mantém a coluna parlamentar_autor no banco (removida apenas do estado + # do Django). Assim uma casa pode dar rollback da imagem sem perder o + # vínculo, e o código novo simplesmente ignora a coluna. O DROP + # definitivo fica para uma migração posterior, quando toda a frota + # estiver estável nesta versão. + migrations.SeparateDatabaseAndState( + state_operations=[ + migrations.RemoveField( + model_name='audienciapublica', + name='parlamentar_autor', + ), + ], + database_operations=[], + ), + migrations.AlterField( + model_name='audienciapublica', + name='ano', + field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'), + ), + ] diff --git a/sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py b/sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py new file mode 100644 index 000000000..15f49d3fa --- /dev/null +++ b/sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2026-06-26 15:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('audiencia', '0021_auto_20260623_1938'), + ] + + operations = [ + migrations.AddField( + model_name='audienciapublica', + name='data_ultima_atualizacao', + field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'), + ), + ] diff --git a/sapl/audiencia/models.py b/sapl/audiencia/models.py index 6b74cffd0..8ba14f29f 100755 --- a/sapl/audiencia/models.py +++ b/sapl/audiencia/models.py @@ -3,7 +3,7 @@ from django.utils import timezone from django.utils.translation import ugettext_lazy as _ from model_utils import Choices from sapl.materia.models import MateriaLegislativa -from sapl.parlamentares.models import (CargoMesa, Parlamentar) +from sapl.parlamentares.models import CargoMesa from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericRelation, restringe_tipos_de_arquivo_txt, texto_upload_path, @@ -79,12 +79,15 @@ class AudienciaPublica(models.Model): default=False, choices=YES_NO_CHOICES, verbose_name=_('Audiência Cancelada?')) - parlamentar_autor = models.ForeignKey( - Parlamentar, + autor = models.ForeignKey( + 'base.Autor', on_delete=models.PROTECT, null=True, blank=True, - verbose_name=_('Parlamentar Autor')) + verbose_name=_('Autor')) + data_ultima_atualizacao = models.DateTimeField( + blank=True, null=True, auto_now=True, + verbose_name=_('Data da Última Atualização')) requerimento = models.ForeignKey( MateriaLegislativa, null=True, diff --git a/sapl/base/media.py b/sapl/base/media.py new file mode 100644 index 000000000..9b91027c2 --- /dev/null +++ b/sapl/base/media.py @@ -0,0 +1,124 @@ +import mimetypes +import os +import re +from urllib.parse import quote + +from django.conf import settings +from django.http import FileResponse, Http404, HttpResponse + +from sapl.materia.models import Proposicao, pode_ver_proposicao +from sapl.protocoloadm.models import (DocumentoAcessorioAdministrativo, + DocumentoAdministrativo, + pode_ver_documento_adm) +from sapl.sessao.models import (JustificativaAusencia, Orador, + OradorExpediente, OradorOrdemDia, + SessaoPlenaria, restringe_sessoes_visiveis) +from sapl.utils import EXTENSOES_ATIVAS, tipo_ativo + +X_ACCEL_PREFIX = '/media-interna/' + +# O nginx do container só envia para cá os caminhos de PROTEGIDO_RE e de +# MODELOS_DE_SESSAO; o resto de /media/ sai direto do disco. Ao incluir um +# modelo aqui, incluí-lo também em docker/config/nginx/sapl.conf. + +# Arquivos que dependem de autorização, em private/ ou em qualquer outra +# pasta (public/ guarda os enviados antes da criação de private/). +PROTEGIDO_RE = re.compile( + r'^sapl/[^/]+/(?:documentoadministrativo|' + r'documentoacessorioadministrativo|proposicao)/') + +RESOLVEDORES = { + 'documentoadministrativo': ( + DocumentoAdministrativo, 'texto_integral', pode_ver_documento_adm), + 'documentoacessorioadministrativo': ( + DocumentoAcessorioAdministrativo, 'arquivo', + lambda user, obj: pode_ver_documento_adm(user, obj.documento)), + 'proposicao': ( + Proposicao, 'texto_original', pode_ver_proposicao), +} + + +# Arquivos gravados em sapl/public///..., que seguem a +# visibilidade da sessão plenária a que pertencem. +MODELOS_DE_SESSAO = {m._meta.model_name: m for m in ( + SessaoPlenaria, Orador, OradorExpediente, OradorOrdemDia, + JustificativaAusencia)} + + +def _de_sessao_oculta(request, partes): + model = MODELOS_DE_SESSAO.get(partes[2]) + if request.user.is_authenticated or not model or len(partes) < 5 or \ + partes[1] != 'public' or not re.fullmatch(r'[0-9]+', partes[3]): + return False + if model is SessaoPlenaria: + sessoes = SessaoPlenaria.objects.filter(pk=partes[3]) + else: + sessoes = SessaoPlenaria.objects.filter( + pk__in=model.objects.filter(pk=partes[3]).values( + 'sessao_plenaria_id')) + return sessoes.exists() and not restringe_sessoes_visiveis( + sessoes, request.user).exists() + + +def _nome_protegido(request, path): + """Devolve o nome do arquivo gravado no objeto dono, se o usuário puder + vê-lo. O caminho servido vem do banco, nunca da URL.""" + resolvedor = RESOLVEDORES.get(path.split('/')[2]) + if not resolvedor: + raise Http404 + model, campo, pode_ver = resolvedor + obj = model.objects.filter( + **{campo + '__in': [path, './' + path]}).first() + if not obj: + raise Http404 + arquivo = getattr(obj, campo) + if not arquivo or not pode_ver(request.user, obj): + raise Http404 + return arquivo.name[2:] if arquivo.name.startswith('./') else arquivo.name + + +def _caminho_em_media(nome): + raiz = os.path.realpath(settings.MEDIA_ROOT) + caminho = os.path.realpath(os.path.join(raiz, nome)) + if not caminho.startswith(raiz + os.sep): + raise Http404 + return caminho + + +def media(request, path): + partes = path.split('/') + if any(p in ('', '.', '..') for p in partes) or '\x00' in path: + raise Http404 + + protegido = path.startswith('sapl/private/') or bool( + PROTEGIDO_RE.match(path)) + nome = _nome_protegido(request, path) if protegido else path + + if len(partes) > 2 and _de_sessao_oculta(request, partes): + raise Http404 + nome_arquivo = os.path.basename(nome) + + content_type = (mimetypes.guess_type(nome_arquivo)[0] or + 'application/octet-stream') + disposicao = 'inline' + if os.path.splitext(nome_arquivo)[1].lower() in EXTENSOES_ATIVAS or \ + tipo_ativo(content_type): + content_type, disposicao = 'application/octet-stream', 'attachment' + + caminho = _caminho_em_media(nome) + if settings.DEBUG: + try: + response = FileResponse(open(caminho, 'rb'), + content_type=content_type) + except (FileNotFoundError, IsADirectoryError): + raise Http404 + else: + response = HttpResponse(content_type=content_type) + response['X-Accel-Redirect'] = X_ACCEL_PREFIX + quote(nome) + + response['Content-Disposition'] = "%s; filename*=UTF-8''%s" % ( + disposicao, quote(nome_arquivo)) + response['X-Content-Type-Options'] = 'nosniff' + if protegido or (len(partes) > 2 and partes[2] in MODELOS_DE_SESSAO): + response['Cache-Control'] = 'private' + return response diff --git a/sapl/base/migrations/0061_casalegislativa_informacao_geral.py b/sapl/base/migrations/0061_casalegislativa_informacao_geral.py new file mode 100644 index 000000000..5fec68acd --- /dev/null +++ b/sapl/base/migrations/0061_casalegislativa_informacao_geral.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.28 on 2026-08-05 12:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0060_auto_20240812_1628'), + ] + + operations = [ + migrations.AlterField( + model_name='casalegislativa', + name='informacao_geral', + field=models.TextField(blank=True, + verbose_name='Informação Geral'), + ), + ] diff --git a/sapl/base/models.py b/sapl/base/models.py index 692c55138..b31c532ab 100644 --- a/sapl/base/models.py +++ b/sapl/base/models.py @@ -73,8 +73,10 @@ class CasaLegislativa(models.Model): max_length=100, blank=True, verbose_name=_('HomePage')) email = models.EmailField( max_length=100, blank=True, verbose_name=_('E-mail')) + # Campo editado via editor de texto rico (TinyMCE), portanto o valor + # armazenado é HTML. Não deve ter max_length: as tags consomem o limite + # e impedem o salvamento de textos curtos. informacao_geral = models.TextField( - max_length=100, blank=True, verbose_name=_('Informação Geral')) diff --git a/sapl/base/receivers.py b/sapl/base/receivers.py index f0e5365c6..1d0b8c892 100644 --- a/sapl/base/receivers.py +++ b/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') diff --git a/sapl/base/templatetags/common_tags.py b/sapl/base/templatetags/common_tags.py index 84f61b64f..d2c741ee6 100644 --- a/sapl/base/templatetags/common_tags.py +++ b/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): diff --git a/sapl/base/tests/test_form.py b/sapl/base/tests/test_form.py index 35f1c82bf..9464fe39e 100644 --- a/sapl/base/tests/test_form.py +++ b/sapl/base/tests/test_form.py @@ -39,3 +39,30 @@ def test_casa_legislativa_form_invalido(): }) assert not form.is_valid() + + +@pytest.mark.django_db(transaction=False) +def test_casa_legislativa_form_informacao_geral_sem_limite(): + # O campo é editado por editor de texto rico, então o valor submetido é + # HTML: as tags não podem consumir um limite de caracteres. + html = ''.join('

%s

' % linha for linha in + ['Câmara Municipal', 'Rua Osvaldo Cruz, 555 - Centro', + 'Expediente das 8h às 12h e das 13h30 às 17h30']) + assert len(html) > 100 + + form = CasaLegislativaForm(data={'codigo': 'codigo', + 'nome': 'nome', + 'sigla': 'sg', + 'endereco': 'endereco', + 'cep': '70000-000', + 'municipio': 'municipio', + 'uf': 'DF', + 'telefone': '33333333', + 'fax': '33333333', + 'endereco_web': '', + 'email': '', + 'informacao_geral': html + }) + + assert form.is_valid(), form.errors + assert form.cleaned_data['informacao_geral'] == html diff --git a/sapl/base/tests/test_media.py b/sapl/base/tests/test_media.py new file mode 100644 index 000000000..de4ac7c94 --- /dev/null +++ b/sapl/base/tests/test_media.py @@ -0,0 +1,384 @@ +import os +import re + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache +from django.http import Http404 +from django.utils import timezone +from model_bakery import baker +import pytest + +from sapl.base.media import MODELOS_DE_SESSAO, RESOLVEDORES, media +from sapl.base.models import AppConfig, Autor +from sapl.materia.models import Proposicao +from sapl.protocoloadm.models import (DocumentoAcessorioAdministrativo, + DocumentoAdministrativo) +from sapl.utils import EXTENSOES_ATIVAS + +DOC_PATH = 'sapl/private/documentoadministrativo/2026/1/texto.pdf' +ACESSORIO_PATH = 'sapl/public/documentoacessorioadministrativo/2026/1/a.pdf' +PROPOSICAO_PATH = 'sapl/private/proposicao/2026/1/proposicao.pdf' + + +@pytest.fixture(autouse=True) +def limpa_cache_appconfig(): + # o cache em arquivo sobrevive ao rollback do teste + cache.clear() + yield + cache.clear() + + +@pytest.fixture(autouse=True) +def sem_debug(settings): + settings.DEBUG = False + + +def _config(comportamento): + cache.clear() + AppConfig.objects.all().delete() + baker.make(AppConfig, documentos_administrativos=comportamento) + + +def _login(client, username, *perms): + user = baker.make(get_user_model(), username=username) + for perm in perms: + app_label, codename = perm.split('.') + perm_obj = Permission.objects.filter( + content_type__app_label=app_label, codename=codename).first() + if not perm_obj: + perm_obj = Permission.objects.create( + codename=codename, name=codename, + content_type=ContentType.objects.get_for_model( + DocumentoAdministrativo)) + user.user_permissions.add(perm_obj) + client.force_login(user) + return user + + +def _get(client, path): + return client.get('/media/' + path) + + +def _assert_servido(response, path): + assert response.status_code == 200 + assert response['X-Accel-Redirect'] == '/media-interna/' + path + assert response['X-Content-Type-Options'] == 'nosniff' + assert response['Cache-Control'] == 'private' + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('comportamento,restrito,perfil,pode_ver', [ + ('O', False, None, True), + ('O', True, None, False), + ('O', True, 'logado', True), + ('R', False, None, False), + ('R', False, 'logado', False), + ('R', True, None, False), + ('R', True, 'logado', False), + ('R', True, 'com_permissao', True), +]) +def test_documento_administrativo(client, comportamento, restrito, perfil, + pode_ver): + _config(comportamento) + baker.make(DocumentoAdministrativo, restrito=restrito, + texto_integral=DOC_PATH) + if perfil == 'logado': + _login(client, 'logado') + elif perfil == 'com_permissao': + _login(client, 'perm', + 'protocoloadm.detail_documentoadministrativo') + + response = _get(client, DOC_PATH) + + if pode_ver: + _assert_servido(response, DOC_PATH) + assert response['Content-Type'] == 'application/pdf' + else: + assert response.status_code == 404 + assert not response.has_header('X-Accel-Redirect') + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('restrito,pode_ver', [(False, True), (True, False)]) +def test_documento_acessorio_segue_documento(client, restrito, pode_ver): + _config('O') + documento = baker.make(DocumentoAdministrativo, restrito=restrito) + baker.make(DocumentoAcessorioAdministrativo, documento=documento, + arquivo=ACESSORIO_PATH) + + response = _get(client, ACESSORIO_PATH) + + assert response.status_code == (200 if pode_ver else 404) + + +@pytest.mark.django_db(transaction=False) +def test_caminho_legado_e_prefixo_ponto_barra(client): + _config('O') + legado = 'sapl/public/documentoadministrativo/2010/7/antigo.pdf' + baker.make(DocumentoAdministrativo, texto_integral=legado) + baker.make(DocumentoAdministrativo, texto_integral='./' + DOC_PATH) + + _assert_servido(_get(client, legado), legado) + _assert_servido(_get(client, DOC_PATH), DOC_PATH) + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('path', [ + 'sapl/private/documentoadministrativo/2026/1/inexistente.pdf', + 'sapl/private/outromodelo/2026/1/texto.pdf', + 'sapl/private/documentoadministrativo/../../public/x.pdf', + 'sapl/private/documentoadministrativo/%2e%2e/x.pdf', +]) +def test_caminho_invalido(client, path): + _config('O') + baker.make(DocumentoAdministrativo, texto_integral=DOC_PATH) + + assert _get(client, path).status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_extensao_ativa_e_baixada(client): + _config('O') + path = 'sapl/private/documentoadministrativo/2026/1/x.html' + baker.make(DocumentoAdministrativo, texto_integral=path) + + response = _get(client, path) + + _assert_servido(response, path) + assert response['Content-Type'] == 'application/octet-stream' + assert response['Content-Disposition'].startswith('attachment;') + + +@pytest.mark.django_db(transaction=False) +def test_nome_nao_ascii_codificado(client): + _config('O') + path = 'sapl/private/documentoadministrativo/2026/1/ofício_nº1.pdf' + baker.make(DocumentoAdministrativo, texto_integral=path) + + response = _get(client, path) + + assert response.status_code == 200 + assert response['X-Accel-Redirect'] == ( + '/media-interna/sapl/private/documentoadministrativo/2026/1/' + 'of%C3%ADcio_n%C2%BA1.pdf') + + +ESTADOS_PROPOSICAO = ('rascunho', 'enviada', 'devolvida', 'incorporada') +PERMS_DOCADM = ['protocoloadm.add_documentoadministrativo', + 'protocoloadm.change_documentoadministrativo', + 'protocoloadm.detail_documentoadministrativo'] + + +def _proposicao(estado): + autor = baker.make(Autor) + operador = baker.make(get_user_model(), username='operador') + autor.operadores.add(operador) + agora = timezone.now() + # mesmos campos gravados pelo envio, devolução e incorporação + baker.make(Proposicao, autor=autor, texto_original=PROPOSICAO_PATH, + data_envio=agora if estado in ('enviada', 'incorporada') + else None, + data_recebimento=agora if estado == 'incorporada' else None, + data_devolucao=agora if estado == 'devolvida' else None) + return operador + + +def _login_proposicao(client, perfil, operador): + if perfil == 'operador': + client.force_login(operador) + elif perfil == 'docadm': + user = _login(client, 'docadm', *PERMS_DOCADM) + assert user.has_module_perms('protocoloadm') + elif perfil == 'outro': + _login(client, 'outro') + elif perfil: + _login(client, perfil, 'materia.detail_proposicao_' + perfil) + + +def _pode_ver_proposicao(estado, perfil): + return perfil == 'operador' or perfil == estado != 'rascunho' + + +GRADE_PROPOSICAO = [ + (estado, perfil, _pode_ver_proposicao(estado, perfil)) + for estado in ESTADOS_PROPOSICAO + for perfil in (None, 'operador', 'outro', 'docadm', 'enviada', + 'devolvida', 'incorporada')] + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('estado,perfil,pode_ver', GRADE_PROPOSICAO) +def test_proposicao(client, estado, perfil, pode_ver): + _login_proposicao(client, perfil, _proposicao(estado)) + + response = _get(client, PROPOSICAO_PATH) + + if pode_ver: + _assert_servido(response, PROPOSICAO_PATH) + else: + assert response.status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_arquivo_publico_passa_por_x_accel(client): + path = 'sapl/public/materialegislativa/2026/1/materia.pdf' + + response = _get(client, path) + + assert response.status_code == 200 + assert response['X-Accel-Redirect'] == '/media-interna/' + path + assert response['Content-Type'] == 'application/pdf' + assert response['X-Content-Type-Options'] == 'nosniff' + assert not response.has_header('Cache-Control') + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('nome', ['x.svg', 'x.html', 'x.XML', 'x.js', + 'x.xht', 'x.rdf', 'x.xslt', 'x.kml']) +def test_arquivo_publico_ativo_e_baixado(client, nome): + response = _get(client, 'sapl/public/normajuridica/2026/1/' + nome) + + assert response['Content-Type'] == 'application/octet-stream' + assert response['Content-Disposition'].startswith('attachment;') + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('path', [ + 'sapl/public/../private/documentoadministrativo/2026/1/texto.pdf', + 'sapl//private/documentoadministrativo/2026/1/texto.pdf', + 'sapl/./private/documentoadministrativo/2026/1/texto.pdf', + 'sapl/public/normajuridica/', +]) +def test_caminho_nao_normalizado(client, path): + assert _get(client, path).status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_debug_serve_arquivo(rf, settings, tmp_path): + settings.DEBUG = True + settings.MEDIA_ROOT = str(tmp_path) + _config('R') + publico = tmp_path / 'sapl/public/normajuridica/1/n.pdf' + publico.parent.mkdir(parents=True) + publico.write_bytes(b'%PDF-1.4 norma') + baker.make(DocumentoAdministrativo, texto_integral=DOC_PATH) + + def chama(path): + request = rf.get('/media/' + path) + request.user = AnonymousUser() + return media(request, path) + + response = chama('sapl/public/normajuridica/1/n.pdf') + assert b''.join(response.streaming_content) == b'%PDF-1.4 norma' + assert not response.has_header('X-Accel-Redirect') + + for path in (DOC_PATH, 'sapl/public/normajuridica/1/nao.pdf'): + with pytest.raises(Http404): + chama(path) + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('debug', [False, True]) +def test_link_simbolico_para_fora_de_media(rf, settings, tmp_path, debug): + settings.DEBUG = debug + media_root = tmp_path / 'media' + fora = tmp_path / 'fora' + (media_root / 'sapl/public').mkdir(parents=True) + fora.mkdir() + (fora / 'segredo.txt').write_bytes(b'segredo') + (media_root / 'sapl/public/link').symlink_to(fora) + settings.MEDIA_ROOT = str(media_root) + request = rf.get('/media/sapl/public/link/segredo.txt') + request.user = AnonymousUser() + + with pytest.raises(Http404): + media(request, 'sapl/public/link/segredo.txt') + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('path', ['/etc/passwd', 'sapl/public//etc/passwd']) +def test_caminho_absoluto(rf, path): + request = rf.get('/media/' + path) + request.user = AnonymousUser() + + with pytest.raises(Http404): + media(request, path) + + +@pytest.mark.django_db(transaction=False) +def test_modelo_protegido_fora_de_private_e_public(client): + _config('O') + path = 'sapl/outra/documentoadministrativo/2026/1/texto.pdf' + baker.make(DocumentoAdministrativo, restrito=True, texto_integral=path) + + assert _get(client, path).status_code == 404 + + +@pytest.mark.django_db(transaction=False) +def test_senha_fraca_nao_redireciona_media(client): + _login(client, 'fraca') + session = client.session + session['weak_password'] = True + session.save() + + response = _get(client, 'sapl/public/casalegislativa/logotipo.png') + + assert response.status_code == 200 + assert response.has_header('X-Accel-Redirect') + + +@pytest.mark.django_db(transaction=False) +@pytest.mark.parametrize('estado,perfil,pode_ver', GRADE_PROPOSICAO) +def test_proposicao_texto(client, settings, tmp_path, estado, perfil, + pode_ver): + settings.MEDIA_ROOT = str(tmp_path) + arquivo = tmp_path / PROPOSICAO_PATH + arquivo.parent.mkdir(parents=True) + arquivo.write_bytes(b'%PDF-1.4 proposicao') + _login_proposicao(client, perfil, _proposicao(estado)) + pk = Proposicao.objects.get(texto_original=PROPOSICAO_PATH).pk + + response = client.get('/proposicao/texto/%s' % pk) + + if pode_ver: + assert response.status_code == 200 + assert response.content == b'%PDF-1.4 proposicao' + else: + assert response.status_code == 302 + assert response['Location'] == '/proposicao/%s' % pk + + +def test_nginx_envia_ao_django_todos_os_caminhos_protegidos(): + caminho = os.path.join(settings.PROJECT_DIR, 'docker', 'config', + 'nginx', 'sapl.conf') + with open(caminho) as f: + conf = f.read() + regex = re.search(r'location ~\*? \^/media/sapl/\(private/\|' + r'\[\^/\]\+/\(([a-z|]+)\)/\)', conf) + + assert regex, 'location de arquivos protegidos não encontrada' + no_nginx = set(regex.group(1).split('|')) + assert set(RESOLVEDORES) | set(MODELOS_DE_SESSAO) <= no_nginx + assert conf.index(regex.group(0)) < conf.index('location /media/ {') + + +def test_nginx_forca_download_das_extensoes_ativas(): + caminho = os.path.join(settings.PROJECT_DIR, 'docker', 'config', + 'nginx', 'sapl.conf') + with open(caminho) as f: + conf = f.read() + regex = re.search(r'location ~\*? \^/media/\.\+\\\.\(([a-z?|]+)\)\$', + conf) + + assert regex, 'location de extensões ativas não encontrada' + no_nginx = set() + for ext in regex.group(1).split('|'): + if ext.endswith('?'): + no_nginx |= {ext[:-2], ext[:-1]} + else: + no_nginx.add(ext) + assert {'.' + e for e in no_nginx} == EXTENSOES_ATIVAS diff --git a/sapl/base/tests/test_sanitize.py b/sapl/base/tests/test_sanitize.py new file mode 100644 index 000000000..c6e0534c0 --- /dev/null +++ b/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('Ciente') == 'Ciente' + assert sanitize_html('Encaminhado ao setor') == \ + 'Encaminhado ao setor' + assert sanitize_html('') == '' + assert sanitize_html('
a
b
') == 'ab' + + +def test_plain_guarda_texto_puro_sem_escape(): + """O escape é da renderização; gravado escapado, apareceria & na tela.""" + assert sanitize_html('Valor < 10 & prazo > 5') == 'Valor < 10 & prazo > 5' + assert sanitize_html('ALFA & BETA') == 'ALFA & BETA' + + +def test_plain_preserva_quebras_de_linha(): + # get_field_display converte \n em
depois de sanitizar + assert sanitize_html('linha1\nlinha2') == 'linha1\nlinha2' + + +def test_valores_vazios_atravessam(): + assert sanitize_html('') == '' + assert sanitize_html(None) is None + assert sanitize_html('', rich=True) == '' + + +@pytest.mark.parametrize('valor', [ + 'Ciente', + 'Valor < 10 & prazo > 5', + 'Encaminhado ao setor', + 'texto & cia', +]) +def test_plain_e_idempotente(valor): + """Salvar de novo um registro já sanitizado não altera o texto.""" + uma_vez = sanitize_html(valor) + assert sanitize_html(uma_vez) == uma_vez + + +@pytest.mark.parametrize('valor', [ + 'Portal', + '

centro

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

centro

', rich=True) + + saida = sanitize_html( + '
c
', rich=True) + assert '' in saida and 'colspan="2"' in saida + + assert sanitize_html('', rich=True) == \ + '' + + +def test_sanitize_scope(): + assert sanitize_scope(TramitacaoAdministrativo, 'texto') == 'plain' + assert sanitize_scope(ExpedienteSessao, 'conteudo') == 'rich' + assert sanitize_scope(LexmlProvedor, 'xml') == 'exempt' + assert sanitize_scope(TipoTextoArticulado, 'rodape_global') == 'exempt' + assert sanitize_scope(Parlamentar, 'biografia') == 'rich' + + +def test_sanitize_field_respeita_isencao(): + xml = 'y' + assert sanitize_field(LexmlProvedor, 'xml', xml) == xml + + +@pytest.mark.django_db +def test_pre_save_sanitiza_campo_simples(): + t = baker.make(TramitacaoAdministrativo, + texto='Ciente ok') + t.refresh_from_db() + assert t.texto == 'Ciente ok' + + +@pytest.mark.django_db +def test_pre_save_sanitiza_campo_rico_preservando_html(): + e = baker.make(ExpedienteSessao, + conteudo='x' + 'l') + e.refresh_from_db() + assert '' + p = baker.make(LexmlProvedor, xml=xml) + p.refresh_from_db() + assert p.xml == xml + + +@pytest.mark.django_db +def test_get_field_display_nao_devolve_script(): + t = TramitacaoAdministrativo(texto='Ciente') + __, display = get_field_display(t, 'texto') + assert 'legado') + t.refresh_from_db() + assert t.texto == 'legado' + + __, display = get_field_display(t, 'texto') + assert '') + __, display = get_field_display(p, 'xml') + assert ''})) + assert saida == 'Ofício 12 lido & arquivado' + + +@pytest.mark.parametrize('valor, esperado', [ + ('
', True), + ('
', True), + ('
Justificativa
', True), + ('Art. ', True), + ('', False), + ('', False), + ('x', False), +]) +def test_html_fragment_is_balanced(valor, esperado): + assert html_fragment_is_balanced(valor) is esperado + + +@pytest.mark.django_db +def test_tipo_dispositivo_form_rejeita_fragmento_desbalanceado(): + td = baker.make(TipoDispositivo) + dados = {f: getattr(td, f) or '' for f in TipoDispositivoForm.Meta.fields} + dados['rotulo_prefixo_html'] = '' + dados['rotulo_sufixo_html'] = '' + form = TipoDispositivoForm(data=dados, instance=td) + assert not form.is_valid() + assert 'rotulo_prefixo_html' in form.errors + assert 'rotulo_sufixo_html' in form.errors + + dados['rotulo_prefixo_html'] = '
' + dados['rotulo_sufixo_html'] = '' + form = TipoDispositivoForm(data=dados, instance=td) + assert 'rotulo_prefixo_html' not in form.errors diff --git a/sapl/comissoes/forms.py b/sapl/comissoes/forms.py index 76f511ed2..599b9a5b3 100644 --- a/sapl/comissoes/forms.py +++ b/sapl/comissoes/forms.py @@ -558,3 +558,28 @@ class DocumentoAcessorioEditForm(FileFieldCheckMixin, forms.ModelForm): # raise ValidationError(f'Favor anexar arquivo em {nome_arquivo}') return self.cleaned_data + + +class ReuniaoFilterSet(django_filters.FilterSet): + + nome = django_filters.CharFilter( + lookup_expr='icontains', label=_('Nome da Reunião')) + tema = django_filters.CharFilter( + lookup_expr='icontains', label=_('Tema da Reunião')) + + class Meta(FilterOverridesMetaMixin): + model = Reuniao + fields = ['numero', 'nome', 'tema', 'data'] + + def __init__(self, *args, **kwargs): + super(ReuniaoFilterSet, self).__init__(*args, **kwargs) + + row1 = to_row([('numero', 3), ('nome', 5), ('tema', 4)]) + row2 = to_row([('data', 6)]) + + self.form.helper = SaplFormHelper() + self.form.helper.form_method = 'GET' + self.form.helper.layout = Layout( + Fieldset(_('Pesquisar Reunião'), + row1, row2, + form_actions(label=_('Pesquisar')))) diff --git a/sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py b/sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py new file mode 100644 index 000000000..e35f98e0a --- /dev/null +++ b/sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2026-06-26 15:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('comissoes', '0030_auto_20231007_2149'), + ] + + operations = [ + migrations.AddField( + model_name='comissao', + name='data_ultima_atualizacao', + field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'), + ), + ] diff --git a/sapl/comissoes/models.py b/sapl/comissoes/models.py index 2d9a323d3..694fdceaf 100644 --- a/sapl/comissoes/models.py +++ b/sapl/comissoes/models.py @@ -34,6 +34,9 @@ class Comissao(models.Model): tipo = models.ForeignKey(TipoComissao, on_delete=models.PROTECT, verbose_name=_('Tipo')) + data_ultima_atualizacao = models.DateTimeField( + blank=True, null=True, auto_now=True, + verbose_name=_('Data da Última Atualização')) nome = models.CharField(max_length=100, verbose_name=_('Nome')) sigla = models.CharField(max_length=10, verbose_name=_('Sigla')) data_criacao = models.DateField(verbose_name=_('Data de Criação')) diff --git a/sapl/comissoes/views.py b/sapl/comissoes/views.py index 6017ae2b3..92cde447f 100644 --- a/sapl/comissoes/views.py +++ b/sapl/comissoes/views.py @@ -22,7 +22,7 @@ from sapl.comissoes.forms import (ComissaoForm, ComposicaoForm, ParticipacaoCreateForm, ParticipacaoEditForm, PautaReuniaoFilterSet, PautaReuniaoForm, - PeriodoForm, ReuniaoForm) + PeriodoForm, ReuniaoFilterSet, ReuniaoForm) from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud, PermissionRequiredForAppCrudMixin, RP_DETAIL, RP_LIST) @@ -216,7 +216,7 @@ class ReuniaoCrud(MasterDetailCrud): public = [RP_LIST, RP_DETAIL, ] class BaseMixin(MasterDetailCrud.BaseMixin): - list_field_names = ['data', 'nome', 'tema', 'upload_ata'] + list_field_names = ['data', 'nome', 'tema', 'upload_pauta', 'upload_ata'] class DetailView(MasterDetailCrud.DetailView): template_name = "comissoes/reuniao_detail.html" @@ -248,6 +248,11 @@ class ReuniaoCrud(MasterDetailCrud): logger = logging.getLogger(__name__) paginate_by = 10 + def get_queryset(self): + qs = super().get_queryset() + self.filterset = ReuniaoFilterSet(self.request.GET, queryset=qs) + return self.filterset.qs + def take_reuniao_pk(self): username = self.request.user.username @@ -276,6 +281,8 @@ class ReuniaoCrud(MasterDetailCrud): context['documentoacessorio_set'] = DocumentoAcessorio.objects.filter( reuniao__pk=context['reuniao_pk'] ).order_by('id') + + context['form'] = self.filterset.form return context class UpdateView(MasterDetailCrud.UpdateView): diff --git a/sapl/compilacao/forms.py b/sapl/compilacao/forms.py index c4154ec00..3ff3b521d 100644 --- a/sapl/compilacao/forms.py +++ b/sapl/compilacao/forms.py @@ -26,6 +26,7 @@ from sapl.compilacao.models import (NOTAS_PUBLICIDADE_CHOICES, from sapl.compilacao.utils import DISPOSITIVO_SELECT_RELATED from sapl.crispy_layout_mixin import SaplFormHelper from sapl.crispy_layout_mixin import SaplFormLayout, to_column, to_row +from sapl.sanitize import RICH_TEXT_FIELDS, html_fragment_is_balanced from sapl.utils import YES_NO_CHOICES, FileFieldCheckMixin @@ -51,6 +52,33 @@ ta_error_messages = { } +class TipoDispositivoForm(ModelForm): + + class Meta: + model = TipoDispositivo + fields = ['rotulo_ordinal', 'formato_variacao0', + 'rotulo_separador_variacao01', 'formato_variacao1', + 'rotulo_separador_variacao12', 'formato_variacao2', + 'rotulo_separador_variacao23', 'formato_variacao3', + 'rotulo_separador_variacao34', 'formato_variacao4', + 'rotulo_separador_variacao45', 'formato_variacao5', + 'rotulo_prefixo_html', 'rotulo_sufixo_html', + 'texto_prefixo_html', 'texto_sufixo_html', + 'nota_automatica_prefixo_html', + 'nota_automatica_sufixo_html'] + + def clean(self): + cleaned_data = super().clean() + for fieldname in RICH_TEXT_FIELDS['compilacao.TipoDispositivo']: + value = cleaned_data.get(fieldname) + if value and not html_fragment_is_balanced(value): + self.add_error(fieldname, _( + 'Toda tag aberta neste campo deve ser fechada nele ' + 'mesmo; tags divididas entre prefixo e sufixo são ' + 'descartadas ao salvar.')) + return cleaned_data + + class TipoTaForm(ModelForm): sigla = forms.CharField( label=TipoTextoArticulado._meta.get_field( diff --git a/sapl/compilacao/utils.py b/sapl/compilacao/utils.py index aa1793484..c56f618b8 100644 --- a/sapl/compilacao/utils.py +++ b/sapl/compilacao/utils.py @@ -1,5 +1,7 @@ import sys +from django.utils.translation import ugettext_lazy as _ + DISPOSITIVO_SELECT_RELATED = ( 'tipo_dispositivo', 'ta_publicado', @@ -66,3 +68,14 @@ def get_integrations_view_names(): if 'IntegracaoTaView' in str(base): result.append(v) return result + + +def valid_int(value): + if value: + try: + int(value) + except (TypeError, ValueError): + raise ValueError( + _('Valor "%(value)s" não é um número inteiro válido.') % { + 'value': value}) + return value diff --git a/sapl/compilacao/views.py b/sapl/compilacao/views.py index 9afbe3201..995b78368 100644 --- a/sapl/compilacao/views.py +++ b/sapl/compilacao/views.py @@ -39,7 +39,8 @@ from sapl.compilacao.forms import (DispositivoDefinidorVigenciaForm, DispositivoRegistroRevogacaoForm, DispositivoSearchModalForm, NotaForm, PublicacaoForm, TaForm, - TextNotificacoesForm, TipoTaForm, VideForm) + TextNotificacoesForm, TipoDispositivoForm, + TipoTaForm, VideForm) from sapl.compilacao.models import (STATUS_TA_EDITION, STATUS_TA_PRIVATE, STATUS_TA_PUBLIC, Dispositivo, Nota, PerfilEstruturalTextoArticulado, @@ -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() diff --git a/sapl/crispy_layout_mixin.py b/sapl/crispy_layout_mixin.py index 57bc0d538..711f9aaa0 100644 --- a/sapl/crispy_layout_mixin.py +++ b/sapl/crispy_layout_mixin.py @@ -8,9 +8,12 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse, reverse_lazy from django.utils import formats from django.utils.encoding import force_text +from django.utils.html import escape from django.utils.translation import ugettext as _ import yaml +from sapl.sanitize import sanitize_html, sanitize_scope + def heads_and_tails(list_of_lists): for alist in list_of_lists: @@ -167,7 +170,14 @@ def get_field_display(obj, fieldname): args=(value.id,)), value) elif 'TextField' in str_type_from_field: - display = value.replace('\n', '
') + scope = sanitize_scope(obj._meta.model, fieldname) + if scope == 'rich': + display = sanitize_html(value, rich=True) + elif scope == 'plain': + display = escape(sanitize_html(value)) + else: + display = escape(value) + display = display.replace('\n', '
') display = '
{}
'.format(display) else: display = str(value) diff --git a/sapl/crud/base.py b/sapl/crud/base.py index fd091ec52..0966c4bc4 100644 --- a/sapl/crud/base.py +++ b/sapl/crud/base.py @@ -863,6 +863,11 @@ class CrudDetailView(PermissionRequiredContainerCrudMixin, context[context_object_name] = self.object context.update(kwargs) + # Mensagem de atualidade (PNTP): data de última atualização da entidade, + # quando o modelo possuir o campo. Consumido pelo banner em base.html. + context['data_atualizacao_conteudo'] = getattr( + self.object, 'data_ultima_atualizacao', None) + return context @property diff --git a/sapl/materia/forms.py b/sapl/materia/forms.py index 3615fee47..9a0d5007c 100644 --- a/sapl/materia/forms.py +++ b/sapl/materia/forms.py @@ -1944,10 +1944,6 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm): fields = [ to_column((Fieldset( TipoProposicao._meta.verbose_name, Field('tipo')), 12)), - to_column( - (Alert('teste', - css_class="ementa_materia hidden alert-info", - dismiss=False), 12)), to_column(('descricao', 12)), to_column(('observacao', 12)), diff --git a/sapl/materia/migrations/0088_fix_view_materiaemtramitacao.py b/sapl/materia/migrations/0088_fix_view_materiaemtramitacao.py new file mode 100644 index 000000000..fc965fcc3 --- /dev/null +++ b/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', + ), + ), + ], + ), + ] diff --git a/sapl/materia/models.py b/sapl/materia/models.py index bdeb402e3..829460487 100644 --- a/sapl/materia/models.py +++ b/sapl/materia/models.py @@ -1154,6 +1154,25 @@ class Proposicao(models.Model): update_fields=update_fields) +def pode_ver_proposicao(user, proposicao): + """Texto original da proposição: nunca público. Operadores do autor veem + sempre; demais usuários seguem as permissões por estado da página de + detalhe (ProposicaoCrud.DetailView.dispatch).""" + if not user.is_authenticated: + return False + if proposicao.autor and proposicao.autor.operadores.filter( + id=user.id).exists(): + return True + # devolução limpa data_envio: checar antes de tratar como rascunho + if proposicao.data_devolucao: + return user.has_perm('materia.detail_proposicao_devolvida') + if not proposicao.data_envio: + return False + if not proposicao.data_recebimento: + return user.has_perm('materia.detail_proposicao_enviada') + return user.has_perm('materia.detail_proposicao_incorporada') + + class HistoricoProposicao(models.Model): STATUS_PROPOSICAO = Choices(('E', 'ENVIADA', _('Enviada')), ('R', 'RECEBIDA', _('Recebida')), @@ -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') % { diff --git a/sapl/materia/tests/test_acessorio_em_lote.py b/sapl/materia/tests/test_acessorio_em_lote.py new file mode 100644 index 000000000..a56e8bcc6 --- /dev/null +++ b/sapl/materia/tests/test_acessorio_em_lote.py @@ -0,0 +1,64 @@ +import os + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from model_bakery import baker +import pytest + +from sapl.materia.models import (DocumentoAcessorio, MateriaLegislativa, + TipoDocumento) + + +@pytest.fixture +def dirs(settings, tmp_path, monkeypatch): + media = tmp_path / 'media' + temp = tmp_path / 'temp' + media.mkdir() + temp.mkdir() + settings.MEDIA_ROOT = str(media) + monkeypatch.setattr('sapl.materia.views.MEDIA_ROOT', str(media)) + monkeypatch.setattr('sapl.materia.views.get_tempfile_dir', + lambda: str(temp)) + return media, temp + + +def _post(client, nome, conteudo): + materias = baker.make(MateriaLegislativa, _quantity=2) + tipo = baker.make(TipoDocumento, descricao='Parecer') + client.post(reverse('sapl.materia:acessorio_em_lote'), { + 'materia_id': [m.pk for m in materias], + 'tipo': tipo.descricao, + 'nome': 'Parecer', + 'autor': 'Comissão', + 'data': '12/10/2026', + 'ementa': 'Ementa', + 'arquivo': SimpleUploadedFile(nome, conteudo), + }) + return materias + + +@pytest.mark.django_db(transaction=False) +def test_arquivo_invalido_nao_grava_nada(admin_client, dirs): + media, temp = dirs + + _post(admin_client, 'x.html', b'%PDF-1.4\n') + + assert not DocumentoAcessorio.objects.exists() + assert os.listdir(media) == [] + assert os.listdir(temp) == [] + + +@pytest.mark.django_db(transaction=False) +def test_arquivo_valido_copiado_para_cada_materia(admin_client, dirs): + media, temp = dirs + + materias = _post(admin_client, 'parecer.pdf', b'%PDF-1.4\n%%EOF') + + docs = DocumentoAcessorio.objects.filter(materia__in=materias) + assert docs.count() == 2 + for doc in docs: + assert doc.arquivo.name == ( + 'sapl/public/documentoacessorio/2026/%s/parecer.pdf' % doc.pk) + assert (media / doc.arquivo.name).read_bytes() == b'%PDF-1.4\n%%EOF' + assert os.listdir(media) == ['sapl'] + assert os.listdir(temp) == [] diff --git a/sapl/materia/tests/test_proposicao_confirmar.py b/sapl/materia/tests/test_proposicao_confirmar.py new file mode 100644 index 000000000..327b65d84 --- /dev/null +++ b/sapl/materia/tests/test_proposicao_confirmar.py @@ -0,0 +1,98 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse +from django.utils import timezone +from model_bakery import baker +import pytest + +from sapl.base.models import AppConfig +from sapl.materia.models import (Proposicao, TipoMateriaLegislativa, + TipoProposicao) +from sapl.utils import SEPARADOR_HASH_PROPOSICAO, gerar_hash_arquivo + +PROPOSICAO_PATH = 'sapl/private/proposicao/2026/1/proposicao.pdf' +PERMS_DOCADM = ['add_documentoadministrativo', + 'change_documentoadministrativo', + 'detail_documentoadministrativo'] + + +def _login(client, app_label, *codenames): + user = baker.make(get_user_model(), username=app_label) + user.user_permissions.add(*Permission.objects.filter( + content_type__app_label=app_label, codename__in=codenames)) + client.force_login(user) + return user + + +def _login_docadm(client): + user = _login(client, 'protocoloadm', *PERMS_DOCADM) + assert user.has_module_perms('protocoloadm') + + +def _login_protocolo(client): + _login(client, 'materia', 'detail_proposicao_enviada') + + +def _proposicao_enviada(settings, tmp_path): + settings.MEDIA_ROOT = str(tmp_path) + baker.make(AppConfig) + arquivo = tmp_path / PROPOSICAO_PATH + arquivo.parent.mkdir(parents=True) + arquivo.write_bytes(b'%PDF-1.4 proposicao') + tipo = baker.make( + TipoProposicao, + content_type=ContentType.objects.get_for_model(TipoMateriaLegislativa), + object_id=baker.make(TipoMateriaLegislativa).pk) + proposicao = baker.make(Proposicao, tipo=tipo, + texto_original=PROPOSICAO_PATH, + data_envio=timezone.now()) + hasher = gerar_hash_arquivo(str(arquivo), str(proposicao.pk)) + return proposicao, hasher[1:].split(SEPARADOR_HASH_PROPOSICAO)[0] + + +def _url_confirmar(proposicao, hash_): + return reverse('sapl.materia:proposicao-confirmar', + kwargs={'hash': hash_, 'pk': proposicao.pk}) + + +@pytest.mark.django_db(transaction=False) +def test_receber_exige_permissao_de_proposicao(client): + _login_docadm(client) + url = reverse('sapl.materia:receber-proposicao') + + assert client.get(url).status_code == 403 + + +@pytest.mark.django_db(transaction=False) +def test_receber_operador_de_protocolo(client): + _login_protocolo(client) + url = reverse('sapl.materia:receber-proposicao') + + assert client.get(url).status_code == 200 + + +@pytest.mark.django_db(transaction=False) +def test_confirmar_exige_permissao_de_proposicao(client, settings, tmp_path): + proposicao, hash_ = _proposicao_enviada(settings, tmp_path) + _login_docadm(client) + + response = client.get(_url_confirmar(proposicao, hash_)) + + assert response.status_code == 403 + + +@pytest.mark.django_db(transaction=False) +def test_confirmar_operador_de_protocolo_ve_texto(client, settings, + tmp_path): + settings.DEBUG = False + proposicao, hash_ = _proposicao_enviada(settings, tmp_path) + _login_protocolo(client) + + response = client.get(_url_confirmar(proposicao, hash_)) + + assert response.status_code == 200 + assert proposicao.texto_original.url.encode() in response.content + texto = client.get(proposicao.texto_original.url) + assert texto.status_code == 200 + assert texto['X-Accel-Redirect'] == '/media-interna/' + PROPOSICAO_PATH diff --git a/sapl/materia/views.py b/sapl/materia/views.py index 698ad6d33..4ac8a2432 100644 --- a/sapl/materia/views.py +++ b/sapl/materia/views.py @@ -5,6 +5,7 @@ import logging import os from random import choice import shutil +import tempfile from string import ascii_letters, digits import time import zipfile @@ -15,7 +16,8 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin -from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, ValidationError +from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, \ + SuspiciousFileOperation, ValidationError from django.db import transaction from django.db.models import Max, Q from django.http import HttpResponse, JsonResponse @@ -52,11 +54,12 @@ from sapl.materia.forms import (AnexadaForm, AutoriaForm, AutoriaMultiCreateForm from sapl.norma.models import LegislacaoCitada from sapl.parlamentares.models import Legislatura from sapl.protocoloadm.models import Protocolo -from sapl.settings import MAX_DOC_UPLOAD_SIZE, MEDIA_ROOT, RATE_LIMITER_RATE +from sapl.settings import MEDIA_ROOT, RATE_LIMITER_RATE from sapl.utils import (autor_label, autor_modal, gerar_hash_arquivo, get_base_url, get_client_ip, get_mime_type_from_file_extension, lista_anexados, mail_service_configured, montar_row_autor, SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, get_tempfile_dir, + restringe_tipos_de_arquivo_txt, validar_arquivo, google_recaptcha_configured, MultiFormatOutputMixin, ratelimit_ip) from .forms import (AcessorioEmLoteFilterSet, AcompanhamentoMateriaForm, @@ -72,7 +75,7 @@ from .forms import (AcessorioEmLoteFilterSet, AcompanhamentoMateriaForm, ReceberProposicaoForm, RelatoriaForm, TramitacaoEmLoteFilterSet, TramitacaoEmLoteForm, UnidadeTramitacaoForm, StatusTramitacaoFilterSet) -from .models import (AcompanhamentoMateria, Anexada, AssuntoMateria, Autoria, DespachoInicial, +from .models import (pode_ver_proposicao, AcompanhamentoMateria, Anexada, AssuntoMateria, Autoria, DespachoInicial, DocumentoAcessorio, MateriaAssunto, MateriaLegislativa, Numeracao, Orgao, Origem, Proposicao, RegimeTramitacao, Relatoria, StatusTramitacao, TipoDocumento, TipoFimRelatoria, TipoMateriaLegislativa, TipoProposicao, @@ -104,14 +107,10 @@ def proposicao_texto(request, pk): username = request.user.username logger.debug('user=' + username + '. Tentando obter objeto Proposicao com pk = {}.'.format(pk)) - proposicao = Proposicao.objects.get(pk=pk) + proposicao = get_object_or_404(Proposicao, pk=pk) if proposicao.texto_original: - if (not proposicao.data_recebimento and - not proposicao.autor.operadores.filter( - id=request.user.id - ).exists() - ): + if not pode_ver_proposicao(request.user, proposicao): logger.error("user=" + username + ". Usuário ({}) não tem permissão para acessar o texto original." .format(request.user.id)) messages.error(request, _( @@ -577,8 +576,8 @@ class ProposicaoRecebida(PermissionRequiredMixin, ListView): return context -class ReceberProposicao(PermissionRequiredForAppCrudMixin, FormView): - app_label = sapl.protocoloadm.apps.AppConfig.label +class ReceberProposicao(PermissionRequiredMixin, FormView): + permission_required = ('materia.detail_proposicao_enviada', ) template_name = "crud/form.html" form_class = ReceberProposicaoForm @@ -661,8 +660,8 @@ class RetornarProposicao(UpdateView): request, *args, **kwargs) -class ConfirmarProposicao(PermissionRequiredForAppCrudMixin, UpdateView): - app_label = sapl.protocoloadm.apps.AppConfig.label +class ConfirmarProposicao(PermissionRequiredMixin, UpdateView): + permission_required = ('materia.detail_proposicao_enviada', ) template_name = "materia/confirmar_proposicao.html" model = Proposicao form_class = ConfirmarProposicaoForm, DevolverProposicaoForm @@ -2330,17 +2329,15 @@ class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView): messages.add_message(request, messages.ERROR, msg) return self.get(request, self.kwargs) - if request.FILES['arquivo'].size > MAX_DOC_UPLOAD_SIZE: - msg = _("O arquivo Anexo de Texto Integral deve ser menor que {0:.1f} MB, \ - o tamanho atual desse arquivo é {1:.1f} MB" - .format((MAX_DOC_UPLOAD_SIZE / 1024) / 1024, (request.FILES['arquivo'].size / 1024) / 1024)) - messages.add_message(request, messages.ERROR, msg) + arquivo = request.FILES['arquivo'] + try: + validar_arquivo(arquivo, 'Arquivo') + restringe_tipos_de_arquivo_txt(arquivo) + except ValidationError as e: + for m in e.messages: + messages.add_message(request, messages.ERROR, m) return self.get(request, self.kwargs) - tmp_name = os.path.join(MEDIA_ROOT, request.FILES['arquivo'].name) - with open(tmp_name, 'wb') as destination: - for chunk in request.FILES['arquivo'].chunks(): - destination.write(chunk) try: doc_data = tz.localize(datetime.strptime( request.POST['data'], "%d/%m/%Y")) @@ -2350,44 +2347,57 @@ class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView): messages.add_message(request, messages.ERROR, msg) self.logger.error("User={}. {}. Data inserida: {}".format( username, str(msg), request.POST['data'])) - os.remove(tmp_name) return self.get(request, self.kwargs) - for materia_id in marcadas: - doc = DocumentoAcessorio() - doc.materia_id = materia_id - doc.tipo = tipo - doc.nome = request.POST['nome'] - doc.data = doc_data - doc.autor = request.POST['autor'] - doc.ementa = request.POST['ementa'] - doc.arquivo.name = tmp_name - try: - doc.clean_fields() - except ValidationError as e: - for m in ['%s: %s' % (DocumentoAcessorio()._meta.get_field(k).verbose_name, '
'.join(v)) - for k, v in e.message_dict.items()]: - # Insere as mensagens de erro no formato: - # 'verbose_name do nome do campo': 'mensagem de erro' - messages.add_message(request, messages.ERROR, m) - self.logger.error("User={}. {}. Nome do arquivo: {}.".format( - username, str(msg), request.FILES['arquivo'].name)) - os.remove(tmp_name) - return self.get(request, self.kwargs) - doc.save() - diretorio = os.path.join(MEDIA_ROOT, - 'sapl/public/documentoacessorio', - str(doc_data.year), - str(doc.id)) - if not os.path.exists(diretorio): - os.makedirs(diretorio) - file_path = os.path.join(diretorio, - request.FILES['arquivo'].name) - shutil.copy2(tmp_name, file_path) - doc.arquivo.name = file_path.split( - MEDIA_ROOT + "/")[1] # Retira MEDIA_ROOT do nome - doc.save() - os.remove(tmp_name) + nome_arquivo = os.path.basename(arquivo.name) + if nome_arquivo in ('', '.', '..'): + messages.add_message(request, messages.ERROR, + _('Nome de arquivo inválido.')) + return self.get(request, self.kwargs) + + # Fora de MEDIA_ROOT, para não ficar acessível em /media/ + with tempfile.NamedTemporaryFile(dir=get_tempfile_dir(), + delete=False) as tmp: + for chunk in arquivo.chunks(): + tmp.write(chunk) + try: + for materia_id in marcadas: + doc = DocumentoAcessorio() + doc.materia_id = materia_id + doc.tipo = tipo + doc.nome = request.POST['nome'] + doc.data = doc_data + doc.autor = request.POST['autor'] + doc.ementa = request.POST['ementa'] + try: + doc.clean_fields(exclude=['arquivo']) + except ValidationError as e: + for m in ['%s: %s' % (DocumentoAcessorio()._meta.get_field(k).verbose_name, '
'.join(v)) + for k, v in e.message_dict.items()]: + # Insere as mensagens de erro no formato: + # 'verbose_name do nome do campo': 'mensagem de erro' + messages.add_message(request, messages.ERROR, m) + self.logger.error("User={}. {}. Nome do arquivo: {}.".format( + username, str(m), arquivo.name)) + return self.get(request, self.kwargs) + doc.save() + diretorio = os.path.join(MEDIA_ROOT, + 'sapl/public/documentoacessorio', + str(doc_data.year), + str(doc.id)) + if not os.path.exists(diretorio): + os.makedirs(diretorio) + diretorio = os.path.normpath(diretorio) + file_path = os.path.normpath( + os.path.join(diretorio, nome_arquivo)) + if not file_path.startswith(diretorio + os.sep): + raise SuspiciousFileOperation(nome_arquivo) + shutil.copyfile(tmp.name, file_path) + doc.arquivo.name = file_path.split( + MEDIA_ROOT + "/")[1] # Retira MEDIA_ROOT do nome + doc.save() + finally: + os.remove(tmp.name) msg = _('Documento(s) criado(s).') messages.add_message(request, messages.SUCCESS, msg) diff --git a/sapl/middleware/check_password.py b/sapl/middleware/check_password.py index 329e24909..90bf40635 100644 --- a/sapl/middleware/check_password.py +++ b/sapl/middleware/check_password.py @@ -1,5 +1,6 @@ import logging +from django.conf import settings from django.shortcuts import redirect from django.urls import reverse @@ -14,7 +15,8 @@ class CheckWeakPasswordMiddleware: if request.user.is_authenticated and \ request.session.get('weak_password', False) and \ request.path != reverse('sapl.base:alterar_senha') and \ - request.path != reverse('sapl.base:logout'): + request.path != reverse('sapl.base:logout') and \ + not request.path.startswith(settings.MEDIA_URL): logging.warning(f"Usuário {request.user.username} possui senha fraca.") return redirect('sapl.base:alterar_senha') diff --git a/sapl/painel/tests/tests.py b/sapl/painel/tests/tests.py index a79ca8be5..dd7753d18 100644 --- a/sapl/painel/tests/tests.py +++ b/sapl/painel/tests/tests.py @@ -1,3 +1,364 @@ -# from django.test import TestCase +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.test import Client +from django.urls import reverse +from model_bakery import baker -# Create your tests here. +from sapl.base.models import AppConfig as ConfiguracoesAplicacao +from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa +from sapl.parlamentares.models import (Legislatura, Mandato, Parlamentar, + SessaoLegislativa, Votante) +from sapl.sessao.models import (ExpedienteMateria, OrdemDia, + PresencaOrdemDia, SessaoPlenaria, + TipoResultadoVotacao, TipoSessaoPlenaria, + VotoParlamentar) + +NOMINAL = 2 +LEITURA = 4 + + +def _sessao_plenaria(): + legislatura = baker.make(Legislatura) + sessao_legislativa = baker.make(SessaoLegislativa) + tipo = baker.make(TipoSessaoPlenaria) + return baker.make(SessaoPlenaria, legislatura=legislatura, + sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1) + + +def _materia(): + tipo_materia = baker.make(TipoMateriaLegislativa) + return baker.make(MateriaLegislativa, tipo=tipo_materia) + + +def _ordem_nominal_aberta(registro_aberto=False): + sessao = _sessao_plenaria() + materia = _materia() + ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, + tipo_votacao=NOMINAL, votacao_aberta=True, + registro_aberto=registro_aberto) + return sessao, ordem + + +def _ordem_leitura_aberta(): + sessao = _sessao_plenaria() + materia = _materia() + ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, + tipo_votacao=LEITURA, votacao_aberta=True) + return sessao, ordem + + +def _votante(sessao, admin_user): + parlamentar = baker.make(Parlamentar, ativo=True) + baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) + baker.make(Votante, parlamentar=parlamentar, user=admin_user) + return parlamentar + + +def _votante_com_client(sessao): + """ + Como _votante(), mas cria seu próprio usuário (não-superuser, só com a + permissão parlamentares.can_vote) e devolve um Client logado separado — + necessário para testar a corrida entre o operador e o vereador, que + precisam ser duas sessões/usuários distintos. + """ + parlamentar = baker.make(Parlamentar, ativo=True) + baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) + user = get_user_model().objects.create_user( + username='votante-{}'.format(parlamentar.pk), password='x') + user.user_permissions.add(Permission.objects.get(codename='can_vote')) + baker.make(Votante, parlamentar=parlamentar, user=user) + client = Client() + client.force_login(user) + return parlamentar, client + + +def _registrar_url(sessao, ordem): + return reverse('sapl.sessao:votacaonominal', + kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id}) + + +@pytest.mark.django_db(transaction=False) +def test_votante_view_envia_headers_never_cache(admin_client, admin_user): + sessao, ordem = _ordem_nominal_aberta() + _votante(sessao, admin_user) + + response = admin_client.get(reverse('sapl.painel:voto_individual')) + + assert response.status_code == 200 + assert 'no-store' in response['Cache-Control'] + + +@pytest.mark.django_db(transaction=False) +def test_votante_view_mostra_materia_quando_registro_fechado(admin_client, admin_user): + sessao, ordem = _ordem_nominal_aberta(registro_aberto=False) + _votante(sessao, admin_user) + + response = admin_client.get(reverse('sapl.painel:voto_individual')) + + assert response.status_code == 200 + assert 'error_message' not in response.context + assert response.context['materia'] == ordem.materia + + +@pytest.mark.django_db(transaction=False) +def test_votante_view_mostra_erro_explicito_quando_registro_bloqueado(admin_client, admin_user): + """ + Regressão da causa raiz #1: quando a Mesa bloqueia novos votos + (registro_aberto=True), o vereador que ainda não votou precisa ver uma + mensagem explícita — não uma tela em branco sem explicação. + """ + sessao, ordem = _ordem_nominal_aberta(registro_aberto=True) + _votante(sessao, admin_user) + + response = admin_client.get(reverse('sapl.painel:voto_individual')) + + assert response.status_code == 200 + assert 'Mesa encerrou o recebimento de novos votos' in response.context['error_message'] + + +@pytest.mark.django_db(transaction=False) +def test_voto_do_vereador_prevalece_sobre_lote_do_operador(admin_client): + """ + Invariante: o voto do próprio vereador sempre prevalece sobre qualquer + valor provisório já existente para ele (ex.: de uma tentativa anterior + do operador, ou de qualquer outra origem). Complementa + test_salvar_votacao_nao_sobrescreve_voto_ja_registrado (que cobre a + ordem inversa: o operador não pode sobrescrever um voto real já + registrado) — aqui é o vereador votando por cima de um valor existente + através da view de verdade, não apenas o estado inicial simulado. + """ + sessao, ordem = _ordem_nominal_aberta() + vereador, votante_client = _votante_com_client(sessao) + + # 1) Já existe um valor provisório "Não Votou" para o vereador (ex.: o + # \n' + for c in lst_correspondencias: + tmp += '' + tmp += '\n' + tmp += '\n' + tmp += '\n' + tmp += '\n' + tmp += '\n' + tmp += '\n' + tmp += '\t\t\n' + tmp += '\t\t\t
\n' + tmp += '\t\t
\n' + return tmp + + def expedientes(lst_expedientes): tmp = '' if lst_expedientes: @@ -415,7 +444,7 @@ def consideracoes(lst_consideracoes): return tmp -def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_presenca_sessao, lst_ausencia_sessao, lst_expedientes, lst_expediente_materia, lst_expediente_materia_vot_nom, lst_oradores_expediente, lst_presenca_ordem_dia, lst_votacao, lst_votacao_vot_nom, lst_oradores_ordemdia, lst_oradores, lst_ocorrencias, lst_consideracoes): +def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_presenca_sessao, lst_ausencia_sessao, lst_correspondencias, lst_expedientes, lst_expediente_materia, lst_expediente_materia_vot_nom, lst_oradores_expediente, lst_presenca_ordem_dia, lst_votacao, lst_votacao_vot_nom, lst_oradores_ordemdia, lst_oradores, lst_ocorrencias, lst_consideracoes): """ """ arquivoPdf = str(int(time.time() * 100)) + ".pdf" @@ -440,6 +469,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_ ordenacao = ResumoOrdenacao.objects.first() dict_ord_template = { 'cont_mult': multimidia(cont_mult_dic), + 'correspondencia': correspondencias(lst_correspondencias), 'exp': expedientes(lst_expedientes), 'id_basica': inf_basicas(inf_basicas_dic), 'lista_p': presenca(lst_presenca_sessao, lst_ausencia_sessao), @@ -473,6 +503,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_ tmp += dict_ord_template[ordenacao.decimo_terceiro] tmp += dict_ord_template[ordenacao.decimo_quarto] tmp += dict_ord_template[ordenacao.decimo_quinto] + tmp += dict_ord_template[ordenacao.decimo_sexto] except KeyError as e: logger.error("KeyError: " + str(e) + ". Erro ao tentar utilizar " "configuração de ordenação. Utilizando ordenação padrão.") @@ -480,6 +511,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_ tmp += multimidia(cont_mult_dic) tmp += mesa(lst_mesa) tmp += presenca(lst_presenca_sessao, lst_ausencia_sessao) + tmp += correspondencias(lst_correspondencias) tmp += expedientes(lst_expedientes) tmp += expediente_materia(lst_expediente_materia) tmp += expediente_materia_vot_nom(lst_expediente_materia_vot_nom) @@ -497,6 +529,7 @@ def principal(rodape_dic, imagem, inf_basicas_dic, cont_mult_dic, lst_mesa, lst_ tmp += multimidia(cont_mult_dic) tmp += mesa(lst_mesa) tmp += presenca(lst_presenca_sessao, lst_ausencia_sessao) + tmp += correspondencias(lst_correspondencias) tmp += expedientes(lst_expedientes) tmp += expediente_materia(lst_expediente_materia) tmp += expediente_materia_vot_nom(lst_expediente_materia_vot_nom) diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py index 701aad69a..4c8edbd97 100755 --- a/sapl/relatorios/views.py +++ b/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() diff --git a/sapl/sanitize.py b/sapl/sanitize.py new file mode 100644 index 000000000..fcbe9db02 --- /dev/null +++ b/sapl/sanitize.py @@ -0,0 +1,182 @@ +"""Sanitização de HTML/JavaScript nos campos de texto livre do SAPL. + +Módulo sem dependências internas do SAPL de propósito: é importado por +``sapl.crispy_layout_mixin``, ``sapl.base.receivers`` e pelos templatetags, +e ``sapl.utils`` já importa ``sapl.crispy_layout_mixin``. + +Duas políticas: + +* ``plain`` — remove toda a marcação e guarda texto puro, com as entidades + já decodificadas: o escape fica por conta da renderização. É o padrão para + qualquer ``TextField``. +* ``rich`` — allowlist para os campos editados via TinyMCE, que contêm HTML + legítimo (negrito, listas, tabelas e links). + +A ``rich`` é idempotente, o que permite aplicá-la tanto no ``pre_save`` quanto +na renderização. A ``plain`` só não é idempotente para texto que seja HTML +codificado em entidades (``<b>``): a primeira passada decodifica, a +segunda remove a tag. Na renderização, campos ``plain`` são escapados em vez +de re-sanitizados. +""" + +import html +from html.parser import HTMLParser + +import nh3 + +SANITIZE_RICH_TAGS = { + 'p', 'br', 'hr', 'div', 'span', + 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'sub', 'sup', + 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'pre', 'code', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', + 'col', 'colgroup', + 'a', 'img', +} + +# 'style' mantém o alinhamento produzido pelos botões do TinyMCE; +# 'target' mantém o "abrir em nova aba" dos links já cadastrados. +# 'rel' não pode entrar aqui: o nh3 aborta se a tag 'a' declarar 'rel' +# ao mesmo tempo em que link_rel está definido — ele mesmo escreve o atributo. +SANITIZE_RICH_ATTRS = { + '*': {'style', 'class', 'align', 'title', 'dir', 'lang'}, + 'a': {'href', 'target', 'name'}, + 'img': {'src', 'alt', 'width', 'height'}, + 'td': {'colspan', 'rowspan', 'headers'}, + 'th': {'colspan', 'rowspan', 'scope', 'headers'}, + 'col': {'span'}, + 'colgroup': {'span'}, + 'table': {'border', 'cellpadding', 'cellspacing', 'summary'}, +} + +SANITIZE_URL_SCHEMES = {'http', 'https', 'mailto', 'tel'} + +# O conteúdo destas tags é descartado junto com a tag. Sem isso o texto de +# dentro de um {% endblock %} diff --git a/sapl/templates/compilacao/dispositivo_form_edicao_basica.html b/sapl/templates/compilacao/dispositivo_form_edicao_basica.html index 5eca7a09d..c4c5d8e2b 100644 --- a/sapl/templates/compilacao/dispositivo_form_edicao_basica.html +++ b/sapl/templates/compilacao/dispositivo_form_edicao_basica.html @@ -31,6 +31,6 @@ }); } - initTextRichEditor(); + initTextRichEditor(null, false, false, true); {% endblock %} diff --git a/sapl/templates/compilacao/dispositivo_form_search_fragment_child.html b/sapl/templates/compilacao/dispositivo_form_search_fragment_child.html index d835a87bc..6ce293c99 100644 --- a/sapl/templates/compilacao/dispositivo_form_search_fragment_child.html +++ b/sapl/templates/compilacao/dispositivo_form_search_fragment_child.html @@ -1,4 +1,4 @@ -{% load i18n compilacao_filters %} +{% load i18n compilacao_filters common_tags %}
  • @@ -30,7 +30,7 @@ {{ df.tipo_dispositivo.rotulo_prefixo_html|safe }} {% if df.rotulo or df.nivel == 1%}{{ df.rotulo }}{%else%}[{{ df|nomenclatura}} {% trans "de" %} {{ df.dispositivo_pai.rotulo }}] - {% endif %} {{ df.tipo_dispositivo.rotulo_sufixo_html|safe }} - {{ df.tipo_dispositivo.texto_prefixo_html|safe }}{%if df.texto %}{{ df.texto|safe }}{%else%}{%if not df.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %} + {{ df.tipo_dispositivo.texto_prefixo_html|safe }}{%if df.texto %}{{ df.texto|sanitize }}{%else%}{%if not df.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %} {% if df.ta_publicado_id %} {{ df.tipo_dispositivo.nota_automatica_prefixo_html|safe }} diff --git a/sapl/templates/compilacao/layout/dispositivo_checkbox.html b/sapl/templates/compilacao/layout/dispositivo_checkbox.html index e0a7ab4d5..833b18225 100644 --- a/sapl/templates/compilacao/layout/dispositivo_checkbox.html +++ b/sapl/templates/compilacao/layout/dispositivo_checkbox.html @@ -37,7 +37,7 @@ {{ choice.1.tipo_dispositivo.rotulo_prefixo_html|safe }} {% 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 %} {{ choice.1.tipo_dispositivo.rotulo_sufixo_html|safe }} - {{ 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 %} {% endif %}{% endif %} + {{ 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 %} {% endif %}{% endif %} {% if choice.1.ta_publicado_id and not choice.1.tipo_dispositivo.dispositivo_de_articulacao %} {{ choice.1.tipo_dispositivo.nota_automatica_prefixo_html|safe }} diff --git a/sapl/templates/compilacao/layout/dispositivo_radio.html b/sapl/templates/compilacao/layout/dispositivo_radio.html index f0cd84e6b..076e94bc7 100644 --- a/sapl/templates/compilacao/layout/dispositivo_radio.html +++ b/sapl/templates/compilacao/layout/dispositivo_radio.html @@ -35,7 +35,7 @@ {{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|safe }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %} + 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 %} {% endif %}{% endif %} {% if dpt.ta_publicado_id and not dpt.tipo_dispositivo.dispositivo_de_articulacao %} {{ dpt.tipo_dispositivo.nota_automatica_prefixo_html|safe }} diff --git a/sapl/templates/compilacao/text_edit.html b/sapl/templates/compilacao/text_edit.html index ada437934..ed7055e62 100644 --- a/sapl/templates/compilacao/text_edit.html +++ b/sapl/templates/compilacao/text_edit.html @@ -17,8 +17,14 @@ {% if object.editing_locked %}{% trans 'Desbloquear Edição' %}{%else%}{% trans 'Publicar Texto' %}{% endif %} {% endif %}
    -
    - {% include 'compilacao/textoarticulado_menu_config.html' %} +
    +
    + a + A +
    +
    + {% include 'compilacao/textoarticulado_menu_config.html' %} +
    {% if object.dispositivos_set.count == 1 %} diff --git a/sapl/templates/compilacao/text_edit_bloco.html b/sapl/templates/compilacao/text_edit_bloco.html index 496122bbc..25b5e8eea 100644 --- a/sapl/templates/compilacao/text_edit_bloco.html +++ b/sapl/templates/compilacao/text_edit_bloco.html @@ -43,7 +43,7 @@ {% if not node.dpt.texto and not node.td.dispositivo_de_articulacao %} ({{node.td}} sem texto) {%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 %} diff --git a/sapl/templates/compilacao/text_edit_blocoalteracao.html b/sapl/templates/compilacao/text_edit_blocoalteracao.html index d2d9f953f..b76c66dfb 100644 --- a/sapl/templates/compilacao/text_edit_blocoalteracao.html +++ b/sapl/templates/compilacao/text_edit_blocoalteracao.html @@ -5,7 +5,7 @@
    - {{ ch.tipo_dispositivo.rotulo_prefixo_html|safe }}{{ ch.rotulo }}{{ ch.tipo_dispositivo.rotulo_sufixo_html|safe }}{{ ch.tipo_dispositivo.texto_prefixo_html|safe }}{{ ch.texto|safe }} + {{ ch.tipo_dispositivo.rotulo_prefixo_html|safe }}{{ ch.rotulo }}{{ ch.tipo_dispositivo.rotulo_sufixo_html|safe }}{{ ch.tipo_dispositivo.texto_prefixo_html|safe }}{{ ch.texto|sanitize }}
    diff --git a/sapl/templates/compilacao/text_list_bloco.html b/sapl/templates/compilacao/text_list_bloco.html index 41c6112a1..fd810994a 100644 --- a/sapl/templates/compilacao/text_list_bloco.html +++ b/sapl/templates/compilacao/text_list_bloco.html @@ -32,7 +32,7 @@ {% endif %} -
    {{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|safe }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %}
    +
    {{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|sanitize }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %}
    {% if dpt.ta_publicado_id %} {{ dpt.tipo_dispositivo.nota_automatica_prefixo_html|safe }} diff --git a/sapl/templates/compilacao/text_list_blocoalteracao.html b/sapl/templates/compilacao/text_list_blocoalteracao.html index 452a49459..fd1f756f2 100644 --- a/sapl/templates/compilacao/text_list_blocoalteracao.html +++ b/sapl/templates/compilacao/text_list_blocoalteracao.html @@ -19,15 +19,15 @@ {% if ch.rotulo %} {{ ch.rotulo }} {{ 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 %} {{ 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 %} {% 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 %} diff --git a/sapl/templates/compilacao/text_notificacoes.html b/sapl/templates/compilacao/text_notificacoes.html index f30709710..ada42862d 100644 --- a/sapl/templates/compilacao/text_notificacoes.html +++ b/sapl/templates/compilacao/text_notificacoes.html @@ -23,7 +23,7 @@ {{ dpt.tipo_dispositivo.texto_prefixo_html|safe }}{%if dpt.texto %}{{ dpt.texto|safe }}{%else%}{%if not dpt.tipo_dispositivo.dispositivo_de_articulacao %} {% endif %}{% endif %} + 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 %} {% endif %}{% endif %} {%if dpt.ta_publicado_id %} diff --git a/sapl/templates/compilacao/textoarticulado_detail.html b/sapl/templates/compilacao/textoarticulado_detail.html index d2dac4781..0236e6936 100644 --- a/sapl/templates/compilacao/textoarticulado_detail.html +++ b/sapl/templates/compilacao/textoarticulado_detail.html @@ -75,7 +75,7 @@
    -

    {{ object.ementa|safe}}

    +

    {{ object.ementa}}

    diff --git a/sapl/templates/materia/acompanhamento_materia.html b/sapl/templates/materia/acompanhamento_materia.html index 92e3b0a68..0650d5d7b 100644 --- a/sapl/templates/materia/acompanhamento_materia.html +++ b/sapl/templates/materia/acompanhamento_materia.html @@ -19,7 +19,7 @@
    -
    Ementa: {{materia.ementa|safe}}
    +
    Ementa: {{materia.ementa}}
    {% crispy form %} diff --git a/sapl/templates/materia/materialegislativa_filter.html b/sapl/templates/materia/materialegislativa_filter.html index c5f749a6f..08f5b99d8 100644 --- a/sapl/templates/materia/materialegislativa_filter.html +++ b/sapl/templates/materia/materialegislativa_filter.html @@ -153,7 +153,7 @@ {% if m.tramitacao_set.first.data_tramitacao %} Data da última Tramitação:  {{m.tramitacao_set.first.data_tramitacao}}
    {% if m.tramitacao_set.first.texto %} - Última Ação:   {{m.tramitacao_set.first.texto|safe}}
    + Última Ação:   {{m.tramitacao_set.first.texto}}
    {% endif %} {% endif %} {% if m.anexo_de.exists %} diff --git a/sapl/templates/materia/resumo_detail_materia.html b/sapl/templates/materia/resumo_detail_materia.html index c028b907b..4130aa334 100644 --- a/sapl/templates/materia/resumo_detail_materia.html +++ b/sapl/templates/materia/resumo_detail_materia.html @@ -3,5 +3,5 @@
    Número: {{object.numero}}
    Ano: {{object.ano}}
    -Ementa: {{object.ementa|safe}} +Ementa: {{object.ementa}}

    diff --git a/sapl/templates/norma/normajuridica_filter.html b/sapl/templates/norma/normajuridica_filter.html index 38341e2d0..0e8107e43 100644 --- a/sapl/templates/norma/normajuridica_filter.html +++ b/sapl/templates/norma/normajuridica_filter.html @@ -62,7 +62,7 @@ {% endif %} Ementa:
    - {{n.ementa|safe}}
    + {{n.ementa}}
    {% if n.texto_articulado.exists and not n.texto_articulado.first.privacidade %}
    diff --git a/sapl/templates/painel/index.html b/sapl/templates/painel/index.html index d5dc8dc4c..22ef48ecb 100644 --- a/sapl/templates/painel/index.html +++ b/sapl/templates/painel/index.html @@ -313,11 +313,17 @@ if (data["status_painel"] == true) { mostrar_voto = data["mostrar_voto"]; + var tem_voto_individual = data['tipo_votacao'] == 'Nominal'; presentes.append('
  • TipoDocumentoInteressadoAssunto
    ' + \ + escape(str(c['tipo'])) + '' + escape(str(c['epigrafe'])) + \ + ' - ' + escape(str(c['data'])) + '' + \ + escape(str(c['interessado'] or '')) + '' + \ + escape(str(c['assunto'] or '')) + '
    '); $.each(presentes_list, function (index, parlamentar) { - - - if (parlamentar.voto == 'Voto Informado' && mostrar_voto == false){ + if (!tem_voto_individual){ + $('#parlamentares_list').append('') + } + else if (parlamentar.voto == 'Voto Informado' && mostrar_voto == false){ $('#parlamentares_list').append('') } else{ - $('#parlamentares_list').append(show_voto(parlamentar)) + $('#parlamentares_list').append(show_voto(parlamentar, !!data['registro'])) } }); @@ -504,8 +510,13 @@ })(); }); - function show_voto(parlamentar) { + function show_voto(parlamentar, registrada) { color = 'white' + + // Com a votação ainda aberta, voto vazio só significa "ainda não votou". + if (!parlamentar.voto && registrada) { + parlamentar.voto = "Não votou"; + } if (parlamentar.voto == "Sim"){ color = 'green' } @@ -529,4 +540,3 @@ - diff --git a/sapl/templates/painel/mensagem.html b/sapl/templates/painel/mensagem.html deleted file mode 100644 index da8e355d4..000000000 --- a/sapl/templates/painel/mensagem.html +++ /dev/null @@ -1,120 +0,0 @@ -{% load i18n %} -{% load common_tags %} -{% load render_bundle from webpack_loader %} -{% load webpack_static from webpack_loader %} - - - - - - - - - - - {% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %} - - - {% render_chunk_vendors 'css' %} - {% render_bundle 'global' 'css' %} - {% render_bundle 'painel' 'css' %} - - - - - - -

    {{ context.title }}

    - -

    Ajax refresh counter:

    -

    -

    -

    -

    -




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

    {{ context.title }}

    - -

    -

    -

    -

    -

    -
    ' + + parlamentar.nome + + ' ' + + parlamentar.partido + ' ' + + '
    ' + parlamentar.nome + ' ' + @@ -325,7 +331,7 @@ + '
    - - - -
    -
      -
    -
    - - - - {% render_chunk_vendors 'js' %} - {% render_bundle 'global' 'js' %} - {% render_bundle 'painel' 'js' %} - - - diff --git a/sapl/templates/painel/votacao.html b/sapl/templates/painel/votacao.html deleted file mode 100644 index e73551160..000000000 --- a/sapl/templates/painel/votacao.html +++ /dev/null @@ -1,123 +0,0 @@ -{% load i18n %} -{% load render_bundle from webpack_loader %} -{% load webpack_static from webpack_loader %} - - - - - - - - - - - {% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %} - - - {% render_chunk_vendors 'css' %} - {% render_bundle 'global' 'css' %} - {% render_bundle 'painel' 'css' %} - - - - -

    {{ context.title }}

    - -

    -

    -

    -

    -

    - - - - -
    -
      -
    -
    -
    - - - - {% render_chunk_vendors 'js' %} - {% render_bundle 'global' 'js' %} - {% render_bundle 'painel' 'js' %} - - - diff --git a/sapl/templates/painel/voto_individual.html b/sapl/templates/painel/voto_individual.html index 1dde0990d..df71296c5 100644 --- a/sapl/templates/painel/voto_individual.html +++ b/sapl/templates/painel/voto_individual.html @@ -46,6 +46,10 @@ + {% for message in messages %} +

    {{ message }}

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

    {{sessao}}

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

    + {% if status_message %} +

    + {{status_message}} +

    + {% endif %}

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

    {{error_message}}

    +

    ⏸

    +

    {{error_message}}




    @@ -184,11 +194,62 @@ $("#date").text(n); startTime(); - - setTimeout(function() { - document.location.reload(true); - }, 30000) }); {% endif %} + + diff --git a/sapl/templates/parlamentares/parlamentar_perfil_publico.html b/sapl/templates/parlamentares/parlamentar_perfil_publico.html index 7a90d35ec..872a38747 100644 --- a/sapl/templates/parlamentares/parlamentar_perfil_publico.html +++ b/sapl/templates/parlamentares/parlamentar_perfil_publico.html @@ -61,7 +61,7 @@
    -

    Biografia:   {{object.biografia|safe}}

    +

    Biografia:   {{object.biografia|sanitize}}

    diff --git a/sapl/templates/protocoloadm/acompanhamento_documento.html b/sapl/templates/protocoloadm/acompanhamento_documento.html index d7135762b..fb6b7eae8 100644 --- a/sapl/templates/protocoloadm/acompanhamento_documento.html +++ b/sapl/templates/protocoloadm/acompanhamento_documento.html @@ -19,7 +19,7 @@
    -
    Assunto: {{documento.assunto|safe}}
    +
    Assunto: {{documento.assunto}}
    {% crispy form %} diff --git a/sapl/templates/protocoloadm/documentoadministrativo_filter.html b/sapl/templates/protocoloadm/documentoadministrativo_filter.html index e6a7baf6d..b4b60a8a1 100644 --- a/sapl/templates/protocoloadm/documentoadministrativo_filter.html +++ b/sapl/templates/protocoloadm/documentoadministrativo_filter.html @@ -48,7 +48,7 @@
    {{d}}
    Interessado: {{ d.interessado|default_if_none:"Não informado"}}
    - Assunto: {{ d.assunto|safe }} + Assunto: {{ d.assunto }}
    {% if d.protocolo %} Protocolo: {{ d.protocolo}}
    diff --git a/sapl/templates/relatorios/RelatorioMateriasPorAutor_filter.html b/sapl/templates/relatorios/RelatorioMateriasPorAutor_filter.html index dcd7287e0..03a68dae9 100644 --- a/sapl/templates/relatorios/RelatorioMateriasPorAutor_filter.html +++ b/sapl/templates/relatorios/RelatorioMateriasPorAutor_filter.html @@ -54,7 +54,7 @@ {{materia.tipo.sigla}} {{materia.numero}}/{{materia.ano}} - {% autoescape off %}{{materia.ementa}}
    {{materia.observacao}}{% endautoescape %} + {{materia.ementa}}
    {{materia.observacao}} {% for autoria in materia.autoria_set.all %} {% if autoria.autor != autor %} diff --git a/sapl/templates/relatorios/RelatorioNormasPorAutor_filter.html b/sapl/templates/relatorios/RelatorioNormasPorAutor_filter.html index 51c987c34..2c16e1ec1 100644 --- a/sapl/templates/relatorios/RelatorioNormasPorAutor_filter.html +++ b/sapl/templates/relatorios/RelatorioNormasPorAutor_filter.html @@ -52,7 +52,7 @@ {{norma.tipo.sigla}} {{norma.numero}}/{{norma.ano}} - {% autoescape off %}{{norma.ementa}}
    {{norma.observacao}}{% endautoescape %} + {{norma.ementa}}
    {{norma.observacao}} {% for autoria in norma.autorianorma_set.all %} {% if autoria.autor != autor %} diff --git a/sapl/templates/relatorios/blocos_sessao_plenaria/consideracoes_finais.html b/sapl/templates/relatorios/blocos_sessao_plenaria/consideracoes_finais.html index e7caa56e8..d499f5594 100644 --- a/sapl/templates/relatorios/blocos_sessao_plenaria/consideracoes_finais.html +++ b/sapl/templates/relatorios/blocos_sessao_plenaria/consideracoes_finais.html @@ -1,4 +1,5 @@ +{% load common_tags %}

    Considerações Finais

    {% for c in lst_consideracoes%} -

    {{c|striptags|safe}}

    +

    {{c|sanitize|striptags}}

    {% endfor %} diff --git a/sapl/templates/relatorios/blocos_sessao_plenaria/expedientes.html b/sapl/templates/relatorios/blocos_sessao_plenaria/expedientes.html index aa42d97b5..5685e64ad 100644 --- a/sapl/templates/relatorios/blocos_sessao_plenaria/expedientes.html +++ b/sapl/templates/relatorios/blocos_sessao_plenaria/expedientes.html @@ -1,5 +1,6 @@ +{% load common_tags %}

    Expedientes

    {% for expediente in lst_expedientes%}

    {{expediente.nom_expediente}}

    -
    {{expediente.txt_expediente|safe}}
    +
    {{expediente.txt_expediente|sanitize}}
    {% endfor%} diff --git a/sapl/templates/relatorios/blocos_sessao_plenaria/ocorrencias_da_sessao.html b/sapl/templates/relatorios/blocos_sessao_plenaria/ocorrencias_da_sessao.html index fc2016b83..9df62eb6d 100644 --- a/sapl/templates/relatorios/blocos_sessao_plenaria/ocorrencias_da_sessao.html +++ b/sapl/templates/relatorios/blocos_sessao_plenaria/ocorrencias_da_sessao.html @@ -1,4 +1,5 @@ +{% load common_tags %}

    Ocorrências da Sessão

    {% for o in lst_ocorrencias%} -

    {{o.conteudo|striptags|safe}}

    +

    {{o.conteudo|sanitize|striptags}}

    {% endfor %} diff --git a/sapl/templates/relatorios/relatorio_doc_administrativos.html b/sapl/templates/relatorios/relatorio_doc_administrativos.html index 5ba5b5a80..fa75b4d52 100644 --- a/sapl/templates/relatorios/relatorio_doc_administrativos.html +++ b/sapl/templates/relatorios/relatorio_doc_administrativos.html @@ -13,7 +13,7 @@ {{d.tipo.sigla}} {{d.numero}}/{{d.ano}} - {{d.tipo}}
    Interessado: {{ d.interessado|default_if_none:"Não informado"}}
    - Assunto: {{ d.assunto|safe}} + Assunto: {{ d.assunto}}
    {% if d.protocolo %} Protocolo: {{ d.protocolo}}
    diff --git a/sapl/templates/relatorios/relatorio_materia_tramitacao.html b/sapl/templates/relatorios/relatorio_materia_tramitacao.html index ddfe6e38b..a75a7344b 100644 --- a/sapl/templates/relatorios/relatorio_materia_tramitacao.html +++ b/sapl/templates/relatorios/relatorio_materia_tramitacao.html @@ -9,7 +9,7 @@

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

    Tipo de matéria: {{ tipo }}
    - Autor: {{ autor }}
    + {% if autores|length > 1 %}Autores: {% else %}Autor: {% endif %}{{ autores|join:", " }}
    {% for tramitacao in object%} diff --git a/sapl/templates/relatorios/relatorio_materias_por_autor.html b/sapl/templates/relatorios/relatorio_materias_por_autor.html index 34499fc1b..3325059d3 100644 --- a/sapl/templates/relatorios/relatorio_materias_por_autor.html +++ b/sapl/templates/relatorios/relatorio_materias_por_autor.html @@ -10,7 +10,7 @@  Tipo de matéria: {{ tipo }}
     Data de apresentação: {{periodo}}


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


    - {% if materias_resultado|length %} - {% for materia in materias_resultado %} + {% if object_list|length %} + {% for materia in object_list %} Matéria: {{materia.tipo.descricao}} {{materia.numero}}/{{materia.ano}}
    Ementa: diff --git a/sapl/templates/relatorios/relatorio_normas_por_autor.html b/sapl/templates/relatorios/relatorio_normas_por_autor.html index c2c5f52b6..716dcb7e8 100644 --- a/sapl/templates/relatorios/relatorio_normas_por_autor.html +++ b/sapl/templates/relatorios/relatorio_normas_por_autor.html @@ -47,7 +47,7 @@ {{norma.tipo.sigla}} {{norma.numero}}/{{norma.ano}} - {% autoescape off %}{{norma.ementa}}
    {{norma.observacao}}{% endautoescape %} + {{norma.ementa}}
    {{norma.observacao}} {% if norma.autorianorma_set.first != norma.autorianorma_set.last %} {% for autor in norma.autorianorma_set.all %} diff --git a/sapl/templates/relatorios/relatorio_pauta_sessao.html b/sapl/templates/relatorios/relatorio_pauta_sessao.html index 4373be457..8ea6c2045 100644 --- a/sapl/templates/relatorios/relatorio_pauta_sessao.html +++ b/sapl/templates/relatorios/relatorio_pauta_sessao.html @@ -39,7 +39,7 @@

    Expedientes

    {% for e in expedientes %} - {{ e.tipo }}:

    {{ e.conteudo|safe }}

    + {{ e.tipo }}:

    {{ e.conteudo|sanitize }}

    {% endfor %}

    Matérias do Expediente

    {% if materia_expediente %} @@ -53,10 +53,10 @@ Autor{{ m.autor|length|pluralize:"es" }}: {{ m.autor|join:', ' }} - {{ m.ementa|safe }} + {{ m.ementa }} {% if m.observacao %}

    Obs.: {{m.observacao}} {% endif %} - {{m.situacao|linebreaksbr|safe}} + {{m.situacao|linebreaksbr}} {% endfor %} @@ -75,10 +75,10 @@ Autor{{ m.autor|length|pluralize:"es" }}: {{ m.autor|join:', ' }} - {{m.ementa|safe}} + {{m.ementa}} {% if m.observacao %}

    Obs.: {{m.observacao}} {% endif %} - {{m.situacao|linebreaksbr|safe}} + {{m.situacao|linebreaksbr}} {% endfor %} diff --git a/sapl/templates/search/search.html b/sapl/templates/search/search.html index aa3f8f431..d6ca72768 100644 --- a/sapl/templates/search/search.html +++ b/sapl/templates/search/search.html @@ -51,7 +51,7 @@ {% if result.object|search_get_model == 'm' %}

    Matéria Legislativa: {{ result.object }}
    - {{result.object.ementa|safe}}
    + {{result.object.ementa}}
    {% if result.object.texto_original %} Texto Original: Clique aqui
    @@ -64,7 +64,7 @@ {% elif result.object|search_get_model == 'd' %}

    Documento Acessório: {{ result.object }}
    - {{result.object.ementa|safe}}
    + {{result.object.ementa}}
    {% if result.object.arquivo %} Texto Original: Clique aqui
    {% endif %} @@ -73,7 +73,7 @@ {% elif result.object|search_get_model == 'n' %}

    Norma Jurídica: {{ result.object }}
    - {{ result.object.ementa|safe }}
    + {{ result.object.ementa }}
    {% if result.object.texto_integral %} Texto Original: Clique aqui
    {% endif %} diff --git a/sapl/templates/sessao/adicionar_varias_materias_expediente.html b/sapl/templates/sessao/adicionar_varias_materias_expediente.html index 876247216..58b62a9a4 100644 --- a/sapl/templates/sessao/adicionar_varias_materias_expediente.html +++ b/sapl/templates/sessao/adicionar_varias_materias_expediente.html @@ -65,7 +65,7 @@ Localização Atual:  {{ m.tramitacao_set.first.unidade_tramitacao_destino|default_if_none:"Não informado" }}
    Status:  {{ m.tramitacao_set.first.status|default_if_none:"Não informado" }}
    Data da última Tramitação:  {{ m.tramitacao_set.first.data_tramitacao|default_if_none:"Não informado" }}
    - Ementa: {{ m.ementa|safe }}
    + Ementa: {{ m.ementa }}

    diff --git a/sapl/templates/sessao/blocos_ata/consideracoes_finais.html b/sapl/templates/sessao/blocos_ata/consideracoes_finais.html index 1ec5c1e16..11d4e602e 100644 --- a/sapl/templates/sessao/blocos_ata/consideracoes_finais.html +++ b/sapl/templates/sessao/blocos_ata/consideracoes_finais.html @@ -1,8 +1,9 @@ +{% load common_tags %} {% if object.consideracoesfinais.conteudo %}

    Considerações Finais: - {{object.consideracoesfinais.conteudo|striptags|safe}} + {{object.consideracoesfinais.conteudo|sanitize|striptags}}

    {% endif %} diff --git a/sapl/templates/sessao/blocos_ata/expedientes.html b/sapl/templates/sessao/blocos_ata/expedientes.html index 1ac93a195..8f5ad9231 100644 --- a/sapl/templates/sessao/blocos_ata/expedientes.html +++ b/sapl/templates/sessao/blocos_ata/expedientes.html @@ -1,3 +1,4 @@ +{% load common_tags %} {% if expedientes %}

    @@ -5,7 +6,7 @@ {% for e in expedientes %} {% if e.conteudo %} {{ e.tipo }}: - {{ e.conteudo|striptags|safe }} + {{ e.conteudo|sanitize|striptags }} {% endif %} {% endfor %}

    diff --git a/sapl/templates/sessao/blocos_ata/materias_expediente.html b/sapl/templates/sessao/blocos_ata/materias_expediente.html index 8168ccb84..6cac1610c 100644 --- a/sapl/templates/sessao/blocos_ata/materias_expediente.html +++ b/sapl/templates/sessao/blocos_ata/materias_expediente.html @@ -4,7 +4,7 @@ Matérias do Expediente: {% for m in materia_expediente %} {{ m.numero }} - {{ m.titulo }}, - {{ m.ementa|safe }} + {{ m.ementa }} {% if m.observacao %} - Obs.: {{ m.observacao }} {% endif %} Autor{{ m.autor|length|pluralize:"es" }}: {{ m.autor|join:', ' }}, {% if m.numero_protocolo %} diff --git a/sapl/templates/sessao/blocos_ata/materias_ordem_dia.html b/sapl/templates/sessao/blocos_ata/materias_ordem_dia.html index 92ee3789b..bb0c40cda 100644 --- a/sapl/templates/sessao/blocos_ata/materias_ordem_dia.html +++ b/sapl/templates/sessao/blocos_ata/materias_ordem_dia.html @@ -5,7 +5,7 @@ Matérias da Ordem do Dia: {% for m in materias_ordem %} {{ m.numero }} - {{ m.titulo }}, - {{ m.ementa|safe }} + {{ m.ementa }} {% if m.observacao %} - Obs.: {{ m.observacao }} {% endif %} Autor{{ m.autor|length|pluralize:"es" }}: {{ m.autor|join:', ' }}, {% if m.numero_protocolo %} diff --git a/sapl/templates/sessao/blocos_ata/ocorrencias_da_sessao.html b/sapl/templates/sessao/blocos_ata/ocorrencias_da_sessao.html index 20d1d8840..3f6fbb0f4 100644 --- a/sapl/templates/sessao/blocos_ata/ocorrencias_da_sessao.html +++ b/sapl/templates/sessao/blocos_ata/ocorrencias_da_sessao.html @@ -1,8 +1,9 @@ +{% load common_tags %} {% if object.ocorrenciasessao.conteudo %}

    Ocorrências da Sessão: - {{ object.ocorrenciasessao.conteudo|striptags|safe }} + {{ object.ocorrenciasessao.conteudo|sanitize|striptags }}

    {% endif %} diff --git a/sapl/templates/sessao/blocos_resumo/consideracoes_finais.html b/sapl/templates/sessao/blocos_resumo/consideracoes_finais.html index f7b15c1f1..bf057bb47 100644 --- a/sapl/templates/sessao/blocos_resumo/consideracoes_finais.html +++ b/sapl/templates/sessao/blocos_resumo/consideracoes_finais.html @@ -1,8 +1,9 @@ +{% load common_tags %} {% if object.consideracoesfinais.conteudo %}
    Considerações Finais
    -

    {{object.consideracoesfinais.conteudo|safe}}

    +

    {{object.consideracoesfinais.conteudo|sanitize}}




    diff --git a/sapl/templates/sessao/blocos_resumo/expedientes.html b/sapl/templates/sessao/blocos_resumo/expedientes.html index d8a186469..615b5bcc2 100644 --- a/sapl/templates/sessao/blocos_resumo/expedientes.html +++ b/sapl/templates/sessao/blocos_resumo/expedientes.html @@ -1,3 +1,4 @@ +{% load common_tags %} {% if expedientes %}
    Expedientes @@ -9,7 +10,7 @@ {{e.tipo}}:

    -

    {{e.conteudo|safe}}

    +

    {{e.conteudo|sanitize}}

    diff --git a/sapl/templates/sessao/blocos_resumo/ocorrencias_da_sessao.html b/sapl/templates/sessao/blocos_resumo/ocorrencias_da_sessao.html index eb9ffe195..d3f041b27 100644 --- a/sapl/templates/sessao/blocos_resumo/ocorrencias_da_sessao.html +++ b/sapl/templates/sessao/blocos_resumo/ocorrencias_da_sessao.html @@ -1,8 +1,9 @@ +{% load common_tags %} {% if object.ocorrenciasessao.conteudo %}
    Ocorrências da Sessão
    -

    {{object.ocorrenciasessao.conteudo|safe}}

    +

    {{object.ocorrenciasessao.conteudo|sanitize}}




    diff --git a/sapl/templates/sessao/consideracoes_finais.html b/sapl/templates/sessao/consideracoes_finais.html index 6e19bcee0..2f7d2ad1b 100644 --- a/sapl/templates/sessao/consideracoes_finais.html +++ b/sapl/templates/sessao/consideracoes_finais.html @@ -15,7 +15,7 @@ {% else %} - {{object.consideracoesfinais.conteudo|safe}} + {{object.consideracoesfinais.conteudo|sanitize}} {% endif %} {% endblock detail_content %} diff --git a/sapl/templates/sessao/leitura/leitura_bloco.html b/sapl/templates/sessao/leitura/leitura_bloco.html index ac6a45cb4..638a33c82 100644 --- a/sapl/templates/sessao/leitura/leitura_bloco.html +++ b/sapl/templates/sessao/leitura/leitura_bloco.html @@ -72,7 +72,7 @@ {% endfor %}
    {% endif %} {% endif %} - Ementa: {{ o.materia.ementa|safe }}
    + Ementa: {{ o.materia.ementa }}

    diff --git a/sapl/templates/sessao/ocorrencias_da_sessao.html b/sapl/templates/sessao/ocorrencias_da_sessao.html index c50c184a1..09a9d2fa5 100644 --- a/sapl/templates/sessao/ocorrencias_da_sessao.html +++ b/sapl/templates/sessao/ocorrencias_da_sessao.html @@ -15,7 +15,7 @@ {% else %} - {{object.ocorrenciasessao.conteudo|safe}} + {{object.ocorrenciasessao.conteudo|sanitize}} {% endif %} {% endblock detail_content %} diff --git a/sapl/templates/sessao/pauta_sessao_detail.html b/sapl/templates/sessao/pauta_sessao_detail.html index a4eb7bec7..460bbf664 100644 --- a/sapl/templates/sessao/pauta_sessao_detail.html +++ b/sapl/templates/sessao/pauta_sessao_detail.html @@ -54,7 +54,7 @@ {{e.tipo}}:

    -

    {{e.conteudo|safe}}

    +

    {{e.conteudo|sanitize}}

    @@ -82,7 +82,7 @@ {{m.ementa|dont_break_out}} {% if m.observacao %}

    Obs.: {{m.observacao}} {% endif %} - {{m.situacao|linebreaksbr|safe}} + {{m.situacao|linebreaksbr}} {% endfor %} @@ -108,7 +108,7 @@ {{m.ementa|dont_break_out}} {% if m.observacao %}

    Obs.: {{m.observacao}} {% endif %} - {{m.situacao|linebreaksbr|safe}} + {{m.situacao|linebreaksbr}} {% endfor %} diff --git a/sapl/templates/sessao/sessaoplenaria_filter.html b/sapl/templates/sessao/sessaoplenaria_filter.html index 51c2fdb17..7ea3f8b65 100644 --- a/sapl/templates/sessao/sessaoplenaria_filter.html +++ b/sapl/templates/sessao/sessaoplenaria_filter.html @@ -53,6 +53,13 @@ {% if s.upload_ata %} Ata da Sessão
    {% endif %} + {% if s.upload_pauta %} + Pauta da Sessão
    + {% endif %} + {% if s.upload_anexo %} + Anexo da Sessão
    + {% endif %} + Votações Nominais
    {% endfor %} diff --git a/sapl/templates/sessao/votacao/nominal.html b/sapl/templates/sessao/votacao/nominal.html index 540e0f3da..e572fb4c3 100644 --- a/sapl/templates/sessao/votacao/nominal.html +++ b/sapl/templates/sessao/votacao/nominal.html @@ -10,9 +10,18 @@
    Matéria: {{materia.materia}}
    - Ementa: {{materia.ementa|safe}} + Ementa: {{materia.ementa}}

    + {% if total == 0 %}