Browse Source

Merge branch '3.1.x' into doc_restrito

pull/3613/head
cristian-longhi 1 week ago
committed by GitHub
parent
commit
8e5695e840
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 7
      .dockerignore
  2. 25
      .travis.yml
  3. 90
      CHANGES.md
  4. 8
      MANIFEST.in
  5. 3
      README.rst
  6. 174
      docker/Dockerfile
  7. 2
      docker/config/nginx/nginx.conf
  8. 14
      docker/config/nginx/sapl.conf
  9. 6
      docker/docker-compose.yaml
  10. 50
      docker/gunicorn_start.sh
  11. 12
      docker/k8s/sapl-deploy.sh
  12. 218
      docker/k8s/sapl-k8s.yaml
  13. 3
      docker/simple_gunicorn.sh
  14. 147
      docker/start.sh
  15. 0
      docker/startup_scripts/create_admin.py
  16. 0
      docker/startup_scripts/genkey.py
  17. 81
      docker/startup_scripts/gunicorn.conf.py
  18. 18
      docker/startup_scripts/solr_cli.py
  19. 292
      docker/startup_scripts/start.sh
  20. 0
      docker/startup_scripts/wait-for-pg.sh
  21. 0
      docker/startup_scripts/wait-for-solr.sh
  22. 239
      drfautoapi/drfautoapi.py
  23. 70
      frontend/src/__apps/compilacao/js/old/compilacao.js
  24. 4
      frontend/src/__apps/compilacao/js/old/compilacao_edit.js
  25. 9
      frontend/src/__apps/compilacao/scss/compilacao.scss
  26. 6
      frontend/src/__apps/parlamentar/main.js
  27. 3
      frontend/src/__global/js/functions.js
  28. 25
      frontend/src/__global/js/tinymce/index.js
  29. 3
      frontend/src/__global/scss/layouts/_globals.scss
  30. 530
      frontend/webpack-stats.json
  31. 7
      release.sh
  32. 16
      requirements/dev-requirements.txt
  33. 13
      requirements/requirements.txt
  34. 19
      requirements/test-requirements.txt
  35. 18
      sapl/api/serializers.py
  36. 7
      sapl/api/urls.py
  37. 132
      sapl/api/views.py
  38. 106
      sapl/api/views_health.py
  39. 75
      sapl/api/views_materia.py
  40. 22
      sapl/audiencia/forms.py
  41. 28
      sapl/audiencia/migrations/0020_auto_20251201_1450.py
  42. 59
      sapl/audiencia/migrations/0021_auto_20260623_1938.py
  43. 18
      sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py
  44. 15
      sapl/audiencia/models.py
  45. 9
      sapl/audiencia/views.py
  46. 1
      sapl/base/apps.py
  47. 2
      sapl/base/email_utils.py
  48. 19
      sapl/base/migrations/0061_casalegislativa_informacao_geral.py
  49. 4
      sapl/base/models.py
  50. 4
      sapl/base/receivers.py
  51. 2
      sapl/base/search_indexes.py
  52. 4
      sapl/base/templatetags/common_tags.py
  53. 27
      sapl/base/tests/test_form.py
  54. 15
      sapl/base/views.py
  55. 25
      sapl/comissoes/forms.py
  56. 18
      sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py
  57. 3
      sapl/comissoes/models.py
  58. 22
      sapl/comissoes/views.py
  59. 2
      sapl/compilacao/forms.py
  60. 4
      sapl/compilacao/models.py
  61. 13
      sapl/compilacao/utils.py
  62. 44
      sapl/compilacao/views.py
  63. 101
      sapl/crud/base.py
  64. 36
      sapl/health.py
  65. 2
      sapl/lexml/forms.py
  66. 2
      sapl/lexml/models.py
  67. 21
      sapl/logging/filters.py
  68. 51
      sapl/materia/forms.py
  69. 134
      sapl/materia/models.py
  70. 385
      sapl/materia/tests/test_materia_proximo_numero.py
  71. 2
      sapl/materia/urls.py
  72. 106
      sapl/materia/views.py
  73. 12
      sapl/metrics.py
  74. 0
      sapl/middleware/__init__.py
  75. 3
      sapl/middleware/check_password.py
  76. 2
      sapl/middleware/endpoint_restriction.py
  77. 24
      sapl/middleware/request_id.py
  78. 2
      sapl/norma/forms.py
  79. 4
      sapl/norma/models.py
  80. 26
      sapl/norma/views.py
  81. 161
      sapl/painel/views.py
  82. 23
      sapl/parlamentares/migrations/0045_auto_20251201_1531.py
  83. 18
      sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py
  84. 9
      sapl/parlamentares/models.py
  85. 19
      sapl/parlamentares/views.py
  86. 4
      sapl/protocoloadm/forms.py
  87. 28
      sapl/protocoloadm/migrations/0046_auto_20260626_1220.py
  88. 3
      sapl/protocoloadm/models.py
  89. 39
      sapl/protocoloadm/views.py
  90. 79
      sapl/relatorios/forms.py
  91. 53
      sapl/relatorios/tests.py
  92. 6
      sapl/relatorios/urls.py
  93. 197
      sapl/relatorios/views.py
  94. 19
      sapl/sessao/forms.py
  95. 40
      sapl/sessao/migrations/0070_presenca_unica_por_sessao.py
  96. 44
      sapl/sessao/models.py
  97. 220
      sapl/sessao/tests/test_sessao_view.py
  98. 153
      sapl/sessao/views.py
  99. 147
      sapl/settings.py
  100. 5
      sapl/static/.well-known/traffic-advice

7
.dockerignore

@ -12,3 +12,10 @@ bower
.travis.yml .travis.yml
.env .env
.idea .idea
.DS_Store
.coveragerc
*.swp
.coveragerc
.drone.yml
.github
release.sh

25
.travis.yml

@ -1,25 +0,0 @@
language: python
python:
- 3.7
services:
- postgresql
install:
- pip install -r requirements/test-requirements.txt
before_script:
- cp sapl/.env_test sapl/.env
- psql -c "CREATE USER sapl WITH PASSWORD 'sapl'" -U postgres;
- psql -c "CREATE DATABASE sapl OWNER sapl;" -U postgres
- ./scripts/django/check_migrations.sh
script:
- ./manage.py migrate
- py.test --create-db
# - ./scripts/django/test_and_check_qa.sh
addons:
hosts:
- 127.0.0.1 sapldb

90
CHANGES.md

@ -1,4 +1,94 @@
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
========================
* Aumenta RATE_LIMITER_RATE default de 10/m para 35/m (#3824)
* #3821 centralizar geração próximo número para create de matérias legislativas (#3822)
3.1.165-RC1 / 2026-02-24
========================
* Hot-fix: fixes setup tool for Haystack
3.1.165-RC0 / 2026-02-20
========================
* Implementa Header LastModified na Api (#3808)
* Impl expansão dinâmica de campos na API (#3809)
* Seleciona tipo de votação para múltiplas matérias ao incluir na Ordem do Dia ou Expediente (#3781)
* Adiciona requestId em requisições
* Hot-fix: conserta erro em JS de voto individual
* Coloca negrito em resultado de votação
* Inversão da ordem de ano e número em Adicionar Norma
* Aumento de nome de parlamentar
* Aumento do tamanho de Nome e Tema da Audiência Pública
* Conserta bug na pesquisa do Relatório de Votações Nominais (#3803)
* feat: impl filtro m2m com lookup '__in' para buscas com multiplos ids (#3807)
* Refatora rate limiter
* Release: 3.1.164-RC5
3.1.164-RC5 / 2025-09-22
========================
* Adiciona smoke test for rate limiter
* Hot-fix: rate limiter get ip
3.1.164-RC4 / 2025-09-22
========================
* Fix recibo proposição e adiciona rate limiter em matéria e norma
3.1.164-RC3 / 2025-09-16
========================
* Health and Ready endpoints (#3788)
* Fix read-only mount on k8s
* Remove setup.py do projeto SAPL
3.1.164-RC2 / 2025-09-08
========================
* Restaura prod settings de docker-compose
* Restringe tipos de arquivos para upload (#3794)
* feat: cria script para remocao de proposicoes de um autor (#3703)
* HOT-Fix: Conserta erro introduzido no PR #3785
* Atualização da imagem base Docker (#3787)
* Ajustes solicitados - Relatório de Votações Nominais (#3785)
* Refatora lógica de geração de CSV/json/xlsx (#3786)
* Remove btn libras de media print (#3777)
3.1.164-RC1 / 2025-08-05
========================
* Adiciona botoes de impressao na pesquisa de doc adm
* Fix: conserta erro em historico proposicao
* Desabilita detalhe de Justificativa Ausência para acesso público
* Adiciona homepage do parlamentar a perfil público
3.1.164-RC0 / 2025-07-09 3.1.164-RC0 / 2025-07-09
======================== ========================

8
MANIFEST.in

@ -1,8 +0,0 @@
include README.rst LICENSE.txt
include sapl/webpack-stats.json
recursive-include sapl *.html *.yaml
recursive-include sapl/static *
recursive-include sapl/relatorios/templates *.py
recursive-include sapl/compilacao *.sql
global-exclude __pycache__
global-exclude *.py[co]

3
README.rst

@ -1,5 +1,4 @@
.. image:: https://travis-ci.org/interlegis/sapl.svg?branch=3.1.x
:target: https://travis-ci.org/interlegis/sapl
*********************************************** ***********************************************

174
docker/Dockerfile

@ -1,73 +1,123 @@
FROM python:3.9-slim-buster # ---------- 1) BUILDER ----------
FROM python:3.12-slim-bookworm AS builder
# Setup env
ENV LANG C.UTF-8 ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \
ENV LC_ALL C.UTF-8 DEBIAN_FRONTEND=noninteractive \
ENV PYTHONDONTWRITEBYTECODE 1 VENV_DIR=/opt/venv \
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=on
ENV DEBIAN_FRONTEND noninteractive
# Dev headers e toolchain só no builder
ENV BUILD_PACKAGES apt-utils apt-file libpq-dev graphviz-dev build-essential git pkg-config \ RUN apt-get update && apt-get install -y --no-install-recommends \
python3-dev libxml2-dev libjpeg-dev libssl-dev libffi-dev libxslt1-dev \ build-essential git pkg-config \
libcairo2-dev software-properties-common python3-setuptools python3-pip libpq-dev libxml2-dev libjpeg-dev libssl-dev libffi-dev libxslt1-dev \
libcairo2-dev libpango1.0-dev libgdk-pixbuf-2.0-dev libharfbuzz-dev \
## NAO EH PRA TIRAR O vim DA LISTA DE COMANDOS INSTALADOS!!! libfreetype6-dev zlib1g-dev \
ENV RUN_PACKAGES graphviz python3-lxml python3-magic postgresql-client python3-psycopg2 \ && rm -rf /var/lib/apt/lists/*
poppler-utils curl jq bash vim python3-venv tzdata nodejs \
fontconfig ttf-dejavu python nginx # Venv independente do sistema
RUN python -m venv "${VENV_DIR}" \
RUN mkdir -p /var/interlegis/sapl && "${VENV_DIR}/bin/pip" install --upgrade pip setuptools wheel
WORKDIR /build
# Copie APENAS os requirements primeiro para maximizar cache
COPY requirements/ ./requirements/
# Instale os requisitos de produção
# ATENÇÃO: se seu código importa prompt_toolkit em runtime, inclua em requirements.txt:
# prompt_toolkit>=3,<4
RUN "${VENV_DIR}/bin/pip" install -r requirements/requirements.txt
# Opcional: verificação de conflitos (falha cedo se faltar algo)
RUN "${VENV_DIR}/bin/pip" check || true
# ---------- 2) RUNTIME ----------
FROM python:3.12-slim-bookworm AS runtime
ARG WITH_GRAPHVIZ=1
ARG WITH_POPPLER=1
ARG WITH_PSQL_CLIENT=1
ARG WITH_NGINX=1
ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \
DEBIAN_FRONTEND=noninteractive \
VENV_DIR=/opt/venv \
PATH=/opt/venv/bin:$PATH \
PIP_NO_CACHE_DIR=on
# Pacotes de runtime (sem *-dev)
# Removi python3/python3-venv (já temos o Python da base)
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
curl jq bash tzdata fontconfig tini libmagic1 \
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; \
if [ "$WITH_POPPLER" = "1" ]; then apt-get install -y --no-install-recommends poppler-utils; fi; \
if [ "$WITH_PSQL_CLIENT" = "1" ]; then apt-get install -y --no-install-recommends postgresql-client; fi; \
if [ "$WITH_NGINX" = "1" ]; then apt-get install -y --no-install-recommends nginx; fi; \
rm -rf /var/lib/apt/lists/*
# Usuários/grupos (idempotente)
RUN useradd --system --no-create-home --shell /usr/sbin/nologin sapl || true \
&& groupadd -g 101 -r nginx || true \
&& usermod -aG nginx www-data || true \
&& usermod -aG nginx sapl || true
# Estrutura de diretórios
RUN mkdir -p /var/interlegis/sapl /var/interlegis/sapl/data /var/interlegis/sapl/media /var/interlegis/sapl/run \
&& chown -R root:nginx /var/interlegis/sapl /var/interlegis/sapl/run \
&& chmod -R g+rwX /var/interlegis/sapl \
&& chmod 2775 /var/interlegis/sapl /var/interlegis/sapl/run \
&& find /var/interlegis/sapl -type d -exec chmod g+s {} +
WORKDIR /var/interlegis/sapl/ WORKDIR /var/interlegis/sapl/
ADD . /var/interlegis/sapl/ # Traga o venv pré-instalado
COPY --from=builder ${VENV_DIR} ${VENV_DIR}
RUN apt-get update && \
apt-get upgrade -y && \ # Código da aplicação (depois do venv para aproveitar cache)
apt-get install -y --no-install-recommends $BUILD_PACKAGES $RUN_PACKAGES && \ COPY . /var/interlegis/sapl/
fc-cache -fv && \
pip3 install --no-cache-dir --upgrade pip setuptools && \ # Nginx (somente se instalado)
rm -f /etc/nginx/conf.d/* && \ RUN if [ "$WITH_NGINX" = "1" ]; then \
pip install --no-cache-dir -r /var/interlegis/sapl/requirements/dev-requirements.txt --upgrade setuptools && \ rm -f /etc/nginx/conf.d/*; \
SUDO_FORCE_REMOVE=yes apt-get purge -y --auto-remove $BUILD_PACKAGES && \ cp docker/config/nginx/sapl.conf /etc/nginx/conf.d/sapl.conf; \
apt-get autoremove && apt-get clean && rm -rf /var/lib/apt/lists/* cp docker/config/nginx/nginx.conf /etc/nginx/nginx.conf; \
fi
WORKDIR /var/interlegis/sapl/
ADD . /var/interlegis/sapl/ # Scripts + gunicorn.conf no diretório da app
RUN install -m 755 docker/startup_scripts/start.sh /var/interlegis/sapl/start.sh \
COPY docker/start.sh $HOME && install -m 755 docker/startup_scripts/wait-for-pg.sh /var/interlegis/sapl/wait-for-pg.sh \
COPY docker/solr_cli.py $HOME && install -m 755 docker/startup_scripts/wait-for-solr.sh /var/interlegis/sapl/wait-for-solr.sh \
COPY docker/wait-for-pg.sh $HOME && install -m 644 docker/startup_scripts/solr_cli.py /var/interlegis/sapl/solr_cli.py \
COPY docker/wait-for-solr.sh $HOME && install -m 644 docker/startup_scripts/create_admin.py /var/interlegis/sapl/create_admin.py \
COPY docker/create_admin.py $HOME && install -m 644 docker/startup_scripts/genkey.py /var/interlegis/sapl/genkey.py \
COPY docker/genkey.py $HOME && install -m 644 docker/startup_scripts/gunicorn.conf.py /var/interlegis/sapl/gunicorn.conf.py
COPY docker/gunicorn_start.sh $HOME
# (Se possível, evite copiar .env no build. Use secrets/variáveis em runtime.)
COPY docker/config/nginx/sapl.conf /etc/nginx/conf.d
COPY docker/config/nginx/nginx.conf /etc/nginx/nginx.conf
COPY docker/config/env_dockerfile /var/interlegis/sapl/sapl/.env COPY docker/config/env_dockerfile /var/interlegis/sapl/sapl/.env
RUN python3 manage.py collectstatic --noinput --clear # Logs (só se nginx estiver presente)
RUN if [ "$WITH_NGINX" = "1" ]; then \
# Remove .env(fake) e sapl.db da imagem ln -sf /dev/stdout /var/log/nginx/access.log; \
RUN rm -rf /var/interlegis/sapl/sapl/.env && \ ln -sf /dev/stderr /var/log/nginx/error.log; \
rm -rf /var/interlegis/sapl/sapl.db fi \
&& mkdir -p /var/log/sapl/ \
RUN chmod +x /var/interlegis/sapl/start.sh && \ && ln -sf /var/interlegis/sapl/sapl.log /var/log/sapl/sapl.log
chmod +x /var/interlegis/sapl/wait-for-solr.sh && \
chmod +x /var/interlegis/sapl/wait-for-pg.sh && \
ln -sf /dev/stdout /var/log/nginx/access.log && \
ln -sf /dev/stderr /var/log/nginx/error.log && \
mkdir /var/log/sapl/ && touch /var/interlegis/sapl/sapl.log && \
ln -s /var/interlegis/sapl/sapl.log /var/log/sapl/sapl.log
# Debian não possui usuário 'nginx' necessário para o Debian
RUN useradd --no-create-home nginx
ENV DEBIAN_FRONTEND teletype # Cache de fontes e collectstatic
# NÃO atualizamos pip aqui (já veio pronto do builder)
RUN fc-cache -fv \
&& python manage.py collectstatic --noinput --clear \
&& rm -f /var/interlegis/sapl/sapl/.env /var/interlegis/sapl/sapl.db || true
EXPOSE 80/tcp 443/tcp ENV DEBIAN_FRONTEND=teletype
EXPOSE 80 443
VOLUME ["/var/interlegis/sapl/data", "/var/interlegis/sapl/media", "/var/log/sapl/"] VOLUME ["/var/interlegis/sapl/data", "/var/interlegis/sapl/media", "/var/log/sapl/"]
ENTRYPOINT ["/usr/bin/tini","--"]
CMD ["/var/interlegis/sapl/start.sh"] CMD ["/var/interlegis/sapl/start.sh"]

2
docker/config/nginx/nginx.conf

@ -1,4 +1,4 @@
user nginx; user www-data nginx;
worker_processes 1; worker_processes 1;
error_log /var/log/nginx/error.log warn; error_log /var/log/nginx/error.log warn;

14
docker/config/nginx/sapl.conf

@ -4,10 +4,17 @@ upstream sapl_server {
} }
# Se o cliente já manda X-Request-ID, reaproveita; senão, usa $request_id (nginx)
map $http_x_request_id $req_id {
default $http_x_request_id;
"" $request_id;
}
server { server {
listen 80; listen 80;
server_name sapl.test; server_name sapl.prod;
client_max_body_size 4G; client_max_body_size 4G;
@ -30,7 +37,9 @@ server {
return 204; return 204;
} }
proxy_set_header X-Request-ID $req_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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_set_header Host $http_host;
proxy_redirect off; proxy_redirect off;
proxy_pass http://sapl_server; proxy_pass http://sapl_server;
@ -45,10 +54,11 @@ server {
} }
location / { location / {
proxy_set_header X-Request-ID $req_id;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 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_set_header Host $http_host;
proxy_redirect off; proxy_redirect off;
proxy_pass http://sapl_server; proxy_pass http://sapl_server;
} }

6
docker/docker-compose.yaml

@ -9,7 +9,9 @@ services:
POSTGRES_PASSWORD: sapl POSTGRES_PASSWORD: sapl
POSTGRES_USER: sapl POSTGRES_USER: sapl
POSTGRES_DB: sapl POSTGRES_DB: sapl
PGDATA : /var/lib/postgresql/data/ PGDATA: /var/lib/postgresql/data/
TZ: UTC
PG_TZ: UTC
volumes: volumes:
- sapldb_data:/var/lib/postgresql/data/ - sapldb_data:/var/lib/postgresql/data/
ports: ports:
@ -31,7 +33,7 @@ services:
networks: networks:
- sapl-net - sapl-net
sapl: sapl:
image: interlegis/sapl:3.1.164-RC0 image: interlegis/sapl:3.1.165-RC3
# build: # build:
# context: ../ # context: ../
# dockerfile: ./docker/Dockerfile # dockerfile: ./docker/Dockerfile

50
docker/gunicorn_start.sh

@ -1,50 +0,0 @@
#!/usr/bin/env bash
##
##
## PARA USO EXCLUSIVO DO CONTAINER DOCKER DO SAPL!!!
## EVITE USAR PARA CHAMADA DIRETAS
##
##
# As seen in http://tutos.readthedocs.org/en/latest/source/ndg.html
SAPL_DIR="/var/interlegis/sapl"
# Seta um novo diretório foi passado como raiz para o SAPL
# caso esse tenha sido passado como parâmetro
if [ "$1" ]
then
SAPL_DIR="$1"
fi
NAME="SAPL" # Name of the application (*)
DJANGODIR=/var/interlegis/sapl/ # Django project directory (*)
SOCKFILE=/var/interlegis/sapl/run/gunicorn.sock # we will communicate using this unix socket (*)
USER=`whoami` # the user to run as (*)
GROUP=`whoami` # the group to run as (*)
NUM_WORKERS=3 # how many worker processes should Gunicorn spawn (*)
# NUM_WORKERS = 2 * CPUS + 1
TIMEOUT=300
MAX_REQUESTS=100 # number of requests before restarting worker
DJANGO_SETTINGS_MODULE=sapl.settings # which settings file should Django use (*)
DJANGO_WSGI_MODULE=sapl.wsgi # WSGI module name (*)
echo "Starting $NAME as `whoami` on base dir $SAPL_DIR"
# Create the run directory if it doesn't exist
RUNDIR=$(dirname $SOCKFILE)
test -d $RUNDIR || mkdir -p $RUNDIR
# Start your Django Unicorn
# Programs meant to be run under supervisor should not daemonize themselves (do not use --daemon)
exec gunicorn ${DJANGO_WSGI_MODULE}:application \
--name $NAME \
--log-level debug \
--timeout $TIMEOUT \
--workers $NUM_WORKERS \
--max-requests $MAX_REQUESTS \
--user $USER \
--access-logfile /var/log/sapl/access.log \
--error-logfile /var/log/sapl/error.log \
--bind=unix:$SOCKFILE

12
docker/k8s/sapl-deploy.sh

@ -0,0 +1,12 @@
#!/bin/bash
kubectl create namespace sapl
mkdir -p ./sapl-secret-data
kubectl -n sapl create secret generic sapl-secretkey --from-file=./sapl-secret-data/
kubectl apply -f sapl-k8s.yaml
kubectl rollout status deployment/sapl -n sapl
POD=$(kubectl get pod -n sapl -l app=sapl -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n sapl "$POD" -- ls -l /var/interlegis/sapl/data

218
docker/k8s/sapl-k8s.yaml

@ -0,0 +1,218 @@
apiVersion: v1
kind: Namespace
metadata:
name: sapl
---
apiVersion: v1
kind: Service
metadata:
name: sapldb
namespace: sapl
spec:
selector:
app: sapldb
ports:
- name: postgres
port: 5432
targetPort: 5432
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: sapldb-data
namespace: sapl
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: local-path
resources:
requests:
storage: 5Gi # or 1Gi for solr-configsets
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: sapldb
namespace: sapl
spec:
serviceName: sapldb
replicas: 1
selector:
matchLabels:
app: sapldb
template:
metadata:
labels:
app: sapldb
spec:
containers:
- name: postgres
image: postgres:10.5-alpine
env:
- name: POSTGRES_PASSWORD
value: "sapl"
- name: POSTGRES_USER
value: "sapl"
- name: POSTGRES_DB
value: "sapl"
- name: PGDATA
value: /var/lib/postgresql/data/
- name: TZ
value: UTC
- name: PG_TZ
value: UTC
ports:
- containerPort: 5432
volumeMounts:
- name: sapldb-data
mountPath: /var/lib/postgresql/data/
volumes:
- name: sapldb-data
persistentVolumeClaim:
claimName: sapldb-data
---
apiVersion: v1
kind: Service
metadata:
name: saplsolr
namespace: sapl
spec:
selector:
app: saplsolr
ports:
- name: solr
port: 8983
targetPort: 8983
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: solr-data
namespace: sapl
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: local-path
resources:
requests:
storage: 5Gi # or 1Gi for solr-configsets
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: solr-configsets
namespace: sapl
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: local-path
resources:
requests:
storage: 5Gi # or 1Gi for solr-configsets
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: saplsolr
namespace: sapl
spec:
serviceName: saplsolr
replicas: 1
selector:
matchLabels:
app: saplsolr
template:
metadata:
labels:
app: saplsolr
spec:
containers:
- name: solr
image: solr:8.11
command: ["bash","-lc","bin/solr start -c -f"]
ports:
- containerPort: 8983
volumeMounts:
- name: solr-data
mountPath: /var/solr
- name: solr-configsets
mountPath: /opt/solr/server/solr/configsets
volumes:
- name: solr-data
persistentVolumeClaim:
claimName: solr-data
- name: solr-configsets
persistentVolumeClaim:
claimName: solr-configsets
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: sapl-media
namespace: sapl
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: local-path
resources:
requests:
storage: 5Gi # or 1Gi for solr-configsets
---
apiVersion: v1
kind: Service
metadata:
name: sapl
namespace: sapl
spec:
selector:
app: sapl
type: NodePort
ports:
- name: http
port: 80
targetPort: 80
nodePort: 30080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sapl
namespace: sapl
spec:
replicas: 1
selector:
matchLabels:
app: sapl
template:
metadata:
labels:
app: sapl
spec:
containers:
- name: sapl
image: eribeiro/sapl:debug-k8s-1
ports:
- containerPort: 80
volumeMounts:
- name: data
mountPath: /var/interlegis/sapl/data
readOnly: true # secrets are always mounted read-only
volumes:
- name: data
secret:
secretName: sapl-secretkey
defaultMode: 0440 # ensures read-only
env:
- name: ADMIN_PASSWORD
value: "interlegis"
- name: ADMIN_EMAIL
value: "email@dominio.net"
- name: DEBUG
value: "False"
- name: EMAIL_PORT
value: "587"
- name: EMAIL_USE_TLS
value: "False"
- name: EMAIL_HOST
value: "smtp.dominio.net"
- name: EMAIL_HOST_USER
value: "usuariosmtp"
- name: EMAIL_SEND_USER

3
docker/simple_gunicorn.sh

@ -12,4 +12,5 @@ export PYTHONPATH=$DJANGODIR:$PYTHONPATH
# Get eth0 IP and filter out the netmask portion (/24, e.g.) # Get eth0 IP and filter out the netmask portion (/24, e.g.)
IP=`ip addr | grep 'inet .* eth0' | awk '{print $2}' | sed 's/\/[0-9]*//'` IP=`ip addr | grep 'inet .* eth0' | awk '{print $2}' | sed 's/\/[0-9]*//'`
gunicorn --bind $IP:8000 sapl.wsgi:application #gunicorn --bind $IP:8000 sapl.wsgi:application
gunicorn -c gunicorn.conf.py sapl.wsgi:application

147
docker/start.sh

@ -1,147 +0,0 @@
#!/usr/bin/env bash
create_env() {
echo "[ENV FILE] creating .env file..."
# check if file exists
if [ -f "/var/interlegis/sapl/data/secret.key" ]; then
KEY=`cat /var/interlegis/sapl/data/secret.key`
else
KEY=`python3 genkey.py`
echo $KEY > data/secret.key
fi
FILENAME="/var/interlegis/sapl/sapl/.env"
if [ -z "${DATABASE_URL:-}" ]; then
DATABASE_URL="postgresql://sapl:sapl@sapldb:5432/sapl"
fi
# ALWAYS replace the content of .env variable
# If want to conditionally create only if absent then use IF below
# if [ ! -f $FILENAME ]; then
touch $FILENAME
# explicitly use '>' to erase any previous content
echo "SECRET_KEY="$KEY > $FILENAME
# now only appends
echo "DATABASE_URL = "$DATABASE_URL >> $FILENAME
echo "DEBUG = ""${DEBUG-False}" >> $FILENAME
echo "EMAIL_USE_TLS = ""${USE_TLS-True}" >> $FILENAME
echo "EMAIL_PORT = ""${EMAIL_PORT-587}" >> $FILENAME
echo "EMAIL_HOST = ""${EMAIL_HOST-''}" >> $FILENAME
echo "EMAIL_HOST_USER = ""${EMAIL_HOST_USER-''}" >> $FILENAME
echo "EMAIL_HOST_PASSWORD = ""${EMAIL_HOST_PASSWORD-''}" >> $FILENAME
echo "EMAIL_SEND_USER = ""${EMAIL_HOST_USER-''}" >> $FILENAME
echo "DEFAULT_FROM_EMAIL = ""${EMAIL_HOST_USER-''}" >> $FILENAME
echo "SERVER_EMAIL = ""${EMAIL_HOST_USER-''}" >> $FILENAME
echo "USE_SOLR = ""${USE_SOLR-False}" >> $FILENAME
echo "SOLR_COLLECTION = ""${SOLR_COLLECTION-sapl}" >> $FILENAME
echo "SOLR_URL = ""${SOLR_URL-http://localhost:8983}" >> $FILENAME
echo "IS_ZK_EMBEDDED = ""${IS_ZK_EMBEDDED-False}" >> $FILENAME
echo "ENABLE_SAPN = ""${ENABLE_SAPN-False}" >> $FILENAME
echo "[ENV FILE] done."
}
create_env
/bin/bash wait-for-pg.sh $DATABASE_URL
yes yes | python3 manage.py migrate
## SOLR
USE_SOLR="${USE_SOLR:=False}"
SOLR_URL="${SOLR_URL:=http://localhost:8983}"
SOLR_COLLECTION="${SOLR_COLLECTION:=sapl}"
NUM_SHARDS=${NUM_SHARDS:=1}
RF=${RF:=1}
MAX_SHARDS_PER_NODE=${MAX_SHARDS_PER_NODE:=1}
IS_ZK_EMBEDDED="${IS_ZK_EMBEDDED:=False}"
if [ "${USE_SOLR-False}" == "True" ] || [ "${USE_SOLR-False}" == "true" ]; then
echo "Solr configurations"
echo "==================="
echo "URL: $SOLR_URL"
echo "COLLECTION: $SOLR_COLLECTION"
echo "NUM_SHARDS: $NUM_SHARDS"
echo "REPLICATION FACTOR: $RF"
echo "MAX SHARDS PER NODE: $MAX_SHARDS_PER_NODE"
echo "ASSUME ZK EMBEDDED: $IS_ZK_EMBEDDED"
echo "========================================="
echo "running Solr script"
/bin/bash wait-for-solr.sh $SOLR_URL
CHECK_SOLR_RETURN=$?
if [ $CHECK_SOLR_RETURN == 1 ]; then
echo "Connecting to Solr..."
if [ "${IS_ZK_EMBEDDED-False}" == "True" ] || [ "${IS_ZK_EMBEDDED-False}" == "true" ]; then
ZK_EMBEDDED="--embedded_zk"
echo "Assuming embedded ZooKeeper instalation..."
fi
python3 solr_cli.py -u $SOLR_URL -c $SOLR_COLLECTION -s $NUM_SHARDS -rf $RF -ms $MAX_SHARDS_PER_NODE $ZK_EMBEDDED &
# Enable SOLR switch on, creating if it doesn't exist on database
./manage.py waffle_switch SOLR_SWITCH on --create
else
echo "Solr is offline, not possible to connect."
# Disable Solr switch off, creating if it doesn't exist on database
./manage.py waffle_switch SOLR_SWITCH off --create
fi
else
echo "Solr support is not initialized."
# Disable Solr switch off, creating if it doesn't exist on database
./manage.py waffle_switch SOLR_SWITCH off --create
fi
## Enable/Disable SAPN
if [ "${ENABLE_SAPN-False}" == "True" ] || [ "${ENABLE_SAPN-False}" == "true" ]; then
echo "Enabling SAPN"
./manage.py waffle_switch SAPLN_SWITCH on --create
else
echo "Enabling SAPL"
./manage.py waffle_switch SAPLN_SWITCH off --create
fi
echo "Creating admin user..."
user_created=$(python3 create_admin.py 2>&1)
echo $user_created
cmd=$(echo $user_created | grep 'ADMIN_USER_EXISTS')
user_exists=$?
cmd=$(echo $user_created | grep 'MISSING_ADMIN_PASSWORD')
lack_pwd=$?
if [ $user_exists -eq 0 ]; then
echo "[SUPERUSER CREATION] User admin already exists. Not creating"
fi
if [ $lack_pwd -eq 0 ]; then
echo "[SUPERUSER] Environment variable $ADMIN_PASSWORD for superuser admin was not set. Leaving container"
# return -1
fi
# Backfilling AuditLog's JSON field
time ./manage.py backfill_auditlog &
echo "-------------------------------------"
echo "| ███████╗ █████╗ ██████╗ ██╗ |"
echo "| ██╔════╝██╔══██╗██╔══██╗██║ |"
echo "| ███████╗███████║██████╔╝██║ |"
echo "| ╚════██║██╔══██║██╔═══╝ ██║ |"
echo "| ███████║██║ ██║██║ ███████╗ |"
echo "| ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝ |"
echo "-------------------------------------"
/bin/sh gunicorn_start.sh &
/usr/sbin/nginx -g "daemon off;"

0
docker/create_admin.py → docker/startup_scripts/create_admin.py

0
docker/genkey.py → docker/startup_scripts/genkey.py

81
docker/startup_scripts/gunicorn.conf.py

@ -0,0 +1,81 @@
# /var/interlegis/sapl/gunicorn.conf.py
import os
import pathlib
import multiprocessing
# ---- SAPL app configuration ----
NAME = "SAPL"
DJANGODIR = "/var/interlegis/sapl"
SOCKFILE = f"unix:{DJANGODIR}/run/gunicorn.sock"
USER = "sapl"
GROUP = "nginx"
NUM_WORKERS = int(os.getenv("WEB_CONCURRENCY", "3"))
THREADS = int(os.getenv("GUNICORN_THREADS", "8"))
TIMEOUT = int(os.getenv("GUNICORN_TIMEOUT", "300"))
MAX_REQUESTS = 1000
WORKER_CLASS = "gthread"
DJANGO_SETTINGS = "sapl.settings"
WSGI_APP = "sapl.wsgi:application"
# ---- gunicorn settings ----
# Equivalent of: --name
proc_name = NAME
# Equivalent of: --bind=unix:...
# For quick testing via browser, you can switch to: bind = "0.0.0.0:8000"
bind = f"unix:{SOCKFILE}"
umask = 0o007
user = USER
group = GROUP
# Ensure imports work like in your script’s working dir
chdir = DJANGODIR
# Allow starting with just: gunicorn -c gunicorn.conf.py
wsgi_app = WSGI_APP
# Logs
loglevel = "debug"
accesslog = "/var/log/sapl/access.log"
errorlog = "/var/log/sapl/error.log"
# errorlog = "-" # send to stderr (so you see it in docker logs or terminal)
# accesslog = "-" # send to stdout
capture_output = True # capture print/tracebacks from app
# Worker/process lifecycle
workers = NUM_WORKERS
worker_class = WORKER_CLASS
threads = THREADS
timeout = TIMEOUT
graceful_timeout = 30
keepalive = 10
backlog = 2048
max_requests = MAX_REQUESTS
max_requests_jitter = 200
worker_max_memory_per_child = 300 * 1024 * 1024 # 300 MB cap
# Environment (same as exporting before running)
raw_env = [
f"DJANGO_SETTINGS_MODULE={DJANGO_SETTINGS}",
# If you’re using ReportLab and seeing segfaults with PDFs, keep this:
# "RL_NOACCEL=1",
]
# If you previously enabled preload and saw segfaults with native libs, keep it off:
preload_app = False
# Create the run/ directory for the UNIX socket (your script did this)
def on_starting(server):
pathlib.Path(SOCKFILE).parent.mkdir(parents=True, exist_ok=True)
# Close DB connections after fork (safer when using preload or certain DB drivers)
def post_fork(server, worker):
try:
from django import db
db.connections.close_all()
except Exception:
# Django not initialized yet or not available
pass

18
docker/solr_cli.py → docker/startup_scripts/solr_cli.py

@ -20,6 +20,8 @@ from kazoo.client import KazooClient
# #
logging.basicConfig() logging.basicConfig()
logging.captureWarnings(True)
logger = logging.getLogger(__name__)
SECURITY_FILE_TEMPLATE = """ SECURITY_FILE_TEMPLATE = """
{ {
@ -49,6 +51,7 @@ def solr_hash_password(password: str, salt: str = None):
salt (optional): base64 salt string salt (optional): base64 salt string
returns: sha256 hash of password and salt (both base64 strings) returns: sha256 hash of password and salt (both base64 strings)
""" """
logger.debug("Generating Solr password")
m = sha256() m = sha256()
if salt is None: if salt is None:
salt = secrets.token_bytes(32) salt = secrets.token_bytes(32)
@ -67,32 +70,32 @@ def solr_hash_password(password: str, salt: str = None):
def create_security_file(username, password): def create_security_file(username, password):
print("Creating security.json file...") logger.info("Creating security.json file...")
with open("security.json", "w") as f: with open("security.json", "w") as f:
cypher, salt = solr_hash_password(password) cypher, salt = solr_hash_password(password)
f.write(SECURITY_FILE_TEMPLATE % (username, cypher, salt, username)) f.write(SECURITY_FILE_TEMPLATE % (username, cypher, salt, username))
print("file created!") logger.info("file created!")
def upload_security_file(zk_host): def upload_security_file(zk_host):
zk_port = 9983 # embedded ZK port zk_port = 9983 # embedded ZK port
print(f"Uploading security file to Solr, ZK server={zk_host}:{zk_port}...") logger.info(f"Uploading security file to Solr, ZK server={zk_host}:{zk_port}...")
try: try:
with open('security.json', 'r') as f: with open('security.json', 'r') as f:
data = f.read() data = f.read()
zk = KazooClient(hosts=f"{zk_host}:{zk_port}") zk = KazooClient(hosts=f"{zk_host}:{zk_port}")
zk.start() zk.start()
print("Uploading security.json file...") logger.info("Uploading security.json file...")
if zk.exists('/security.json'): if zk.exists('/security.json'):
zk.set("/security.json", str.encode(data)) zk.set("/security.json", str.encode(data))
else: else:
zk.create("/security.json", str.encode(data)) zk.create("/security.json", str.encode(data))
data, stat = zk.get('/security.json') data, stat = zk.get('/security.json')
print("file uploaded!") logger.info("file uploaded!")
print(data.decode('utf-8')) logger.info(data.decode('utf-8'))
zk.stop() zk.stop()
except Exception as e: except Exception as e:
print(e) logger.error(e)
sys.exit(-1) sys.exit(-1)
@ -250,6 +253,7 @@ def setup_embedded_zk(solr_url):
_, solr_user, solr_pwd, solr_host, solr_port = match.groups() _, solr_user, solr_pwd, solr_host, solr_port = match.groups()
if solr_user and solr_pwd and solr_host: if solr_user and solr_pwd and solr_host:
print(f"Creating Solr user {solr_user} with password {solr_pwd}")
create_security_file(solr_user, solr_pwd) create_security_file(solr_user, solr_pwd)
upload_security_file(solr_host) upload_security_file(solr_host)
else: else:

292
docker/startup_scripts/start.sh

@ -0,0 +1,292 @@
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
APP_DIR="/var/interlegis/sapl"
DATA_DIR="/var/interlegis/sapl/data"
RUN_DIR="/var/interlegis/sapl/run"
ENV_FILE="$APP_DIR/.env"
SECRET_FILE="$DATA_DIR/secret.key"
chown -R root:nginx "$RUN_DIR" || true
chmod -R g+rwX "$RUN_DIR" || true
# setgid bit on our writable trees (not data/)
find "$RUN_DIR" -type d -exec chmod g+s {} + 2>/dev/null || true
log() { printf '[%s] %s\n' "$(date -Is)" "$*"; }
err() { printf '[%s] ERROR: %s\n' "$(date -Is)" "$*" >&2; }
cleanup() { jobs -p | xargs -r kill 2>/dev/null || true; }
trap cleanup TERM INT EXIT
# --- new function ---
configure_pg_timezone() {
: "${DATABASE_URL:=postgresql://sapl:sapl@sapldb:5432/sapl}"
: "${DB_TIMEZONE:=America/Sao_Paulo}"
: "${DB_NAME:=}"
: "${DB_ROLE:=}"
log "Checking database/role timezone defaults…"
# Detect DB and role if not provided
if [[ -z "$DB_NAME" ]]; then
DB_NAME="$(psql "$DATABASE_URL" -At -v ON_ERROR_STOP=1 -c 'select current_database();')"
fi
if [[ -z "$DB_ROLE" ]]; then
DB_ROLE="$(psql "$DATABASE_URL" -At -v ON_ERROR_STOP=1 -c 'select current_user;')"
fi
# What is the effective timezone for this DB/role right now?
current_tz="$(psql "$DATABASE_URL" -At -v ON_ERROR_STOP=1 -c 'show time zone;')"
current_tz_lower="${current_tz,,}"
# Consider these as already UTC
if [[ "$current_tz_lower" == "utc" || "$current_tz_lower" == "etc/utc" ]]; then
log "Timezone already UTC for DB='$DB_NAME' ROLE='$DB_ROLE' (SHOW TIME ZONE => $current_tz). Skipping ALTERs."
return
fi
log "Timezone is '$current_tz' (not UTC). Applying persistent defaults…"
# Persist at database level (requires DB owner or superuser)
if psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q \
-c "ALTER DATABASE \"$DB_NAME\" SET timezone TO '$DB_TIMEZONE';"; then
log "ALTER DATABASE \"$DB_NAME\" SET timezone TO '$DB_TIMEZONE' applied."
else
err "ALTER DATABASE \"$DB_NAME\" failed. Need DB owner or superuser."
exit 1
fi
# Persist at role level (requires superuser)
if psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -q \
-c "ALTER ROLE \"$DB_ROLE\" SET timezone TO '$DB_TIMEZONE';"; then
log "ALTER ROLE \"$DB_ROLE\" SET timezone TO '$DB_TIMEZONE' applied."
else
err "ALTER ROLE \"$DB_ROLE\" failed. Need superuser privileges."
exit 1
fi
# Re-check (new session shows the new default)
verify_tz="$(psql "$DATABASE_URL" -At -v ON_ERROR_STOP=1 -c 'show time zone;')"
log "SHOW TIME ZONE now => $verify_tz (new sessions will inherit the defaults)."
}
create_secret() {
if [[ -f "$SECRET_FILE" ]]; then
SECRET_KEY="$(<"$SECRET_FILE")"
else
log "Generating SECRET_KEY..."
SECRET_KEY="$(python3 genkey.py)"
umask 177
printf '%s\n' "$SECRET_KEY" > "$SECRET_FILE"
fi
export SECRET_KEY
}
write_env_file() {
: "${DATABASE_URL:=postgresql://sapl:sapl@sapldb:5432/sapl}"
: "${DEBUG:=False}"
: "${EMAIL_USE_TLS:=True}"
: "${EMAIL_PORT:=587}"
: "${EMAIL_HOST:=}"
: "${EMAIL_HOST_USER:=}"
: "${EMAIL_HOST_PASSWORD:=}"
: "${DEFAULT_FROM_EMAIL:=$EMAIL_HOST_USER}"
: "${SERVER_EMAIL:=$EMAIL_HOST_USER}"
: "${USE_SOLR:=False}"
: "${SOLR_COLLECTION:=sapl}"
: "${SOLR_URL:=http://localhost:8983}"
: "${IS_ZK_EMBEDDED:=False}"
: "${NUM_SHARDS:=1}"
: "${RF:=1}"
: "${MAX_SHARDS_PER_NODE:=1}"
: "${ENABLE_SAPN:=False}"
tmp="$(mktemp)"
{
printf 'SECRET_KEY=%s\n' "$SECRET_KEY"
printf 'DATABASE_URL=%s\n' "$DATABASE_URL"
printf 'DEBUG=%s\n' "$DEBUG"
printf 'EMAIL_USE_TLS=%s\n' "$EMAIL_USE_TLS"
printf 'EMAIL_PORT=%s\n' "$EMAIL_PORT"
printf 'EMAIL_HOST=%s\n' "$EMAIL_HOST"
printf 'EMAIL_HOST_USER=%s\n' "$EMAIL_HOST_USER"
printf 'EMAIL_HOST_PASSWORD=%s\n' "$EMAIL_HOST_PASSWORD"
printf 'EMAIL_SEND_USER=%s\n' "$EMAIL_HOST_USER"
printf 'DEFAULT_FROM_EMAIL=%s\n' "$DEFAULT_FROM_EMAIL"
printf 'SERVER_EMAIL=%s\n' "$SERVER_EMAIL"
printf 'USE_SOLR=%s\n' "$USE_SOLR"
printf 'SOLR_COLLECTION=%s\n' "$SOLR_COLLECTION"
printf 'SOLR_URL=%s\n' "$SOLR_URL"
printf 'IS_ZK_EMBEDDED=%s\n' "$IS_ZK_EMBEDDED"
printf 'NUM_SHARDS=%s\n' "$NUM_SHARDS"
printf 'RF=%s\n' "$RF"
printf 'MAX_SHARDS_PER_NODE=%s\n' "$MAX_SHARDS_PER_NODE"
printf 'ENABLE_SAPN=%s\n' "$ENABLE_SAPN"
} > "$tmp"
chmod 600 "$tmp"
mv -f "$tmp" "$ENV_FILE"
log "[ENV] wrote $ENV_FILE"
}
wait_for_pg() {
: "${DATABASE_URL:=postgresql://sapl:sapl@sapldb:5432/sapl}"
log "Waiting for Postgres..."
/bin/bash wait-for-pg.sh "$DATABASE_URL"
}
migrate_db() {
log "Running Django migrations..."
python3 manage.py migrate --noinput
}
# In start.sh (near your other helpers)
configure_solr() {
# respect envs, with sane defaults
local USE="${USE_SOLR:-False}"
local URL="${SOLR_URL:-http://admin:solr@localhost:8983}"
local COL="${SOLR_COLLECTION:-sapl}"
local SHARDS="${NUM_SHARDS:-1}"
local RF="${RF:-1}"
local MS="${MAX_SHARDS_PER_NODE:-1}"
local IS_ZK="${IS_ZK_EMBEDDED:-False}"
# total wait time before we give up (seconds)
local WAIT_TIMEOUT="${SOLR_WAIT_TIMEOUT:-30}"
# per probe max seconds
local PROBE_TIMEOUT="${SOLR_PROBE_TIMEOUT:-3}"
# sleep between probes
local SLEEP_SECS="${SOLR_WAIT_INTERVAL:-2}"
# feature flag OFF by default unless we confirm Solr
./manage.py waffle_switch SOLR_SWITCH off --create || true
# Fast exit if disabled
if [[ "${USE,,}" != "true" ]]; then
echo "[SOLR] USE_SOLR=$USE → skipping Solr initialization."
return 0
fi
echo "[SOLR] Best-effort wait (<= ${WAIT_TIMEOUT}s): $URL, collection=$COL"
local deadline=$((SECONDS + WAIT_TIMEOUT))
while (( SECONDS < deadline )); do
# Try a cheap SolrCloud endpoint; swap for /solr/admin/info/system if you prefer
if curl -fsS --max-time "${PROBE_TIMEOUT}" \
"${URL%/}/solr/admin/collections?action=LIST" >/dev/null; then
echo "[SOLR] Reachable. Kicking off background configuration…"
# optional flag if ZK is embedded
local ZK_FLAG=""
if [[ "${IS_ZK,,}" == "true" ]]; then
ZK_FLAG="--embedded_zk"
fi
(
set -Eeuo pipefail
python3 solr_cli.py \
-u "$URL" -c "$COL" -s "$SHARDS" -rf "$RF" -ms "$MS" $ZK_FLAG
./manage.py waffle_switch SOLR_SWITCH on --create
echo "[SOLR] Configuration done, SOLR_SWITCH=on."
) >/var/log/sapl/solr_init.log 2>&1 & disown
return 0
fi
sleep "${SLEEP_SECS}"
done
echo "[SOLR] Not reachable within ${WAIT_TIMEOUT}s. Proceeding without Solr (SOLR_SWITCH=off)."
return 0
}
configure_sapn() {
if [[ "${ENABLE_SAPN,,}" == "true" ]]; then
log "Enabling SAPN"
python3 manage.py waffle_switch SAPN_SWITCH on --create
else
log "Disabling SAPN"
python3 manage.py waffle_switch SAPN_SWITCH off --create
fi
}
create_admin() {
log "Creating admin user..."
out="$(python3 create_admin.py 2>&1 || true)"
printf '%s\n' "$out"
if grep -q 'MISSING_ADMIN_PASSWORD' <<<"$out"; then
err "[SUPERUSER] ADMIN_PASSWORD not set. Exiting."
exit 1
fi
}
fix_logging_and_socket_perms() {
local APP_DIR="/var/interlegis/sapl"
local LOG_FILE="$APP_DIR/sapl.log"
# dirs
mkdir -p "$APP_DIR/run"
chmod 2775 "$APP_DIR/run"
# new files/sockets → 660
umask 0007
# ensure log file is owned by sapl and writable
install -Dm0660 /dev/null "$LOG_FILE"
chown sapl:nginx "$LOG_FILE"
# stale socket cleanup (if any)
rm -f "$APP_DIR/run/gunicorn.sock" 2>/dev/null || true
}
setup_cache_dir() {
# if you later move cache under /var/interlegis/sapl/cache, this line can read an env var
local CACHE_DIR="${DJANGO_CACHE_DIR:-/var/tmp/django_cache}"
mkdir -p "$CACHE_DIR"
chown -R sapl:nginx "$CACHE_DIR"
chmod -R 2775 "$CACHE_DIR"
find "$CACHE_DIR" -type d -exec chmod g+s {} +
# keep your global umask; 0007 ensures new files are rw for owner+group
umask 0007
}
start_services() {
log "Starting gunicorn..."
gunicorn -c gunicorn.conf.py &
log "Starting nginx..."
exec /usr/sbin/nginx -g "daemon off;"
}
main() {
create_secret
write_env_file
wait_for_pg
configure_pg_timezone
migrate_db
configure_solr || true
configure_sapn
create_admin
setup_cache_dir
fix_logging_and_socket_perms
cat <<'BANNER'
-------------------------------------
| ███████╗ █████╗ ██████╗ ██╗ |
| ██╔════╝██╔══██╗██╔══██╗██║ |
| ███████╗███████║██████╔╝██║ |
| ╚════██║██╔══██║██╔═══╝ ██║ |
| ███████║██║ ██║██║ ███████╗ |
| ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝ |
-------------------------------------
BANNER
start_services
}
main "$@"

0
docker/wait-for-pg.sh → docker/startup_scripts/wait-for-pg.sh

0
docker/wait-for-solr.sh → docker/startup_scripts/wait-for-solr.sh

239
drfautoapi/drfautoapi.py

@ -1,28 +1,34 @@
from collections import OrderedDict from collections import OrderedDict
from functools import cached_property
import django_filters
import importlib import importlib
import inspect import inspect
import logging import logging
import re import re
from django.apps.config import AppConfig
from django.apps.registry import apps
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields.jsonb import JSONField from django.contrib.postgres.fields.jsonb import JSONField
from django.contrib.auth import get_user_model
from django.db.models.base import ModelBase from django.db.models.base import ModelBase
from django.db.models.fields import TextField, CharField from django.db.models.fields import TextField, CharField
from django.db.models.fields.files import FileField from django.db.models.fields.files import FileField
from django.db.models.fields.related import ManyToManyField
from django.template.defaultfilters import capfirst from django.template.defaultfilters import capfirst
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
import django_filters from django.urls import reverse
from django_filters.constants import ALL_FIELDS, EMPTY_VALUES from django_filters.constants import ALL_FIELDS, EMPTY_VALUES
from django_filters.fields import ModelMultipleChoiceField
from django_filters.filters import CharFilter from django_filters.filters import CharFilter
from django_filters.filterset import FilterSet from django_filters.filterset import FilterSet
from django_filters.rest_framework.backends import DjangoFilterBackend from django_filters.rest_framework.backends import DjangoFilterBackend
from django_filters.utils import resolve_field, get_all_model_fields from django_filters.utils import resolve_field, get_all_model_fields
from rest_framework import serializers as rest_serializers from rest_framework import serializers as rest_serializers
from rest_framework.relations import ManyRelatedField
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework.urlpatterns import format_suffix_patterns
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -65,9 +71,25 @@ class SplitStringCharFilter(django_filters.CharFilter):
return qs return qs
class M2MFilter(django_filters.ModelMultipleChoiceFilter):
class M2MFieldFormField(ModelMultipleChoiceField):
def clean(self, value):
if value in EMPTY_VALUES:
return super().clean(value)
values = list(map(lambda x: x.replace(' ', '').split(','), value))
values = [v for subvalues in values for v in subvalues]
cleaned_values = tuple(super().clean(values))
return (cleaned_values,) if cleaned_values else cleaned_values
field_class = M2MFieldFormField
distinct = True
class ApiFilterSetMixin(FilterSet): class ApiFilterSetMixin(FilterSet):
o = CharFilter(method='filter_o') o = CharFilter(method='filter_o')
id__in = CharFilter(method='filter_id__in')
class Meta: class Meta:
fields = '__all__' fields = '__all__'
@ -92,6 +114,13 @@ class ApiFilterSetMixin(FilterSet):
}, },
} }
def filter_id__in(self, queryset, name, value):
try:
ids = [int(v.strip()) for v in value.split(',')]
return queryset.filter(id__in=ids)
except:
return queryset
def filter_o(self, queryset, name, value): def filter_o(self, queryset, name, value):
try: try:
return queryset.order_by( return queryset.order_by(
@ -117,7 +146,7 @@ class ApiFilterSetMixin(FilterSet):
f = model._meta.get_field(f_str) f = model._meta.get_field(f_str)
if f.many_to_many: if f.many_to_many:
fields[f_str] = ['exact'] fields[f_str] = ['exact', 'in', ]
continue continue
fields[f_str] = ['exact'] fields[f_str] = ['exact']
@ -173,6 +202,14 @@ class ApiFilterSetMixin(FilterSet):
return filter_class(**default) return filter_class(**default)
return None return None
@classmethod
def filter_for_lookup(cls, field, lookup_type):
f, p = super().filter_for_lookup(field, lookup_type)
if lookup_type == 'in' and isinstance(field, ManyToManyField):
return M2MFilter, p
return f, p
class BusinessRulesNotImplementedMixin: class BusinessRulesNotImplementedMixin:
http_method_names = ['get', 'head', 'options', 'trace'] http_method_names = ['get', 'head', 'options', 'trace']
@ -194,6 +231,17 @@ class ApiViewSetConstrutor():
class ApiViewSet(ModelViewSet): class ApiViewSet(ModelViewSet):
filter_backends = (DjangoFilterBackend,) filter_backends = (DjangoFilterBackend,)
class LastModifiedDecorator(object):
def __init__(self):
pass
def __call__(self, cls):
return cls
@classmethod
def last_modified_class(cls, klass):
cls.LastModifiedDecorator = klass
return cls
@classmethod @classmethod
def get_viewset_for_model(cls, model): def get_viewset_for_model(cls, model):
return cls._built_sets[model._meta.app_config][model] return cls._built_sets[model._meta.app_config][model]
@ -208,7 +256,7 @@ class ApiViewSetConstrutor():
importlib.import_module(m) importlib.import_module(m)
@classmethod @classmethod
def router(cls, router_class=DefaultRouter): def router(cls, router_class = DefaultRouter):
router = router_class() router = router_class()
for app, built_sets in cls._built_sets.items(): for app, built_sets in cls._built_sets.items():
for model, viewset in built_sets.items(): for model, viewset in built_sets.items():
@ -309,6 +357,7 @@ class ApiViewSetConstrutor():
if not hasattr(_meta_filterset, 'model'): if not hasattr(_meta_filterset, 'model'):
model = _model model = _model
@cls.LastModifiedDecorator()
class ModelApiViewSet(ApiViewSetConstrutor.ApiViewSet): class ModelApiViewSet(ApiViewSetConstrutor.ApiViewSet):
queryset = _model.objects.all() queryset = _model.objects.all()
filterset_class = ApiFilterSet filterset_class = ApiFilterSet
@ -407,3 +456,183 @@ class customize(object):
ApiViewSetConstrutor._built_sets[ ApiViewSetConstrutor._built_sets[
self.model._meta.app_config][self.model] = _ApiViewSet self.model._meta.app_config][self.model] = _ApiViewSet
return _ApiViewSet return _ApiViewSet
class DrfAutoApiSerializerMixin(rest_serializers.ModelSerializer):
"""
Mixin de Serializer que implementa expansão dinâmica de campos
via parâmetros de query string:
- `expand`: expande os campos informados
- `include`: inclui apenas os campos informados
- `exclude`: exclui os campos informados
Exemplo:
- ?expand=campo1;campo2.sub_campo1,sub_campo2;campo3.sub_campo1.sub_sub_campo1,sub_sub_campo2
- ?include=campo1;campo2.sub_campo1,sub_campo2;campo3.sub_campo1.sub_sub_campo1,sub_sub_campo2
- ?exclude=campo1;campo2.sub_campo1,sub_campo2;campo3.sub_campo1.sub_sub_campo1,sub_sub_campo2
- ?expand=campo1&include=campo1.id,name&exclude=campo1.secret_field
Onde:
- campo1, campo2, campo3 são campos do model raiz
- sub_campo1, sub_campo2 são campos relacionados do campo2
- sub_sub_campo1, sub_sub_campo2 são campos relacionados do sub_campo1
Ou seja:
";" separa campos independentes do nível atual
"," separa campos relacionados do mesmo campo pai
"." indica o nível de profundidade dos campos relacionados
e ainda:
- `expand` pode ser usado para expansão direta, ou seja, campo1.sub_campo1 já expande campo1
- `expand`, `include` e `exclude` podem ser usados juntos na mesma requisição
- `include` e `exclude` só funcionam em subniveis se o campo pai estiver expandido
- `include` tem precedência sobre `exclude` e já remove todo o resto
- `exclude` remove o campo do resultado final, mesmo que esteja em `include`
- Se nenhum dos parâmetros for informado, nenhum campo será expandido
- Filtros da API, paginação (`page` e `page_size`) e ordenação (`o`)
podem ser usados normalmente para filtrar os resultados
Atenção:
- A expansão não é aplicada para o model User do Django
- A expansão não é aplicada para campos customizados que utilizam SerializerMethodField
- Uma exceção é lançada e registrada no log caso ocorra algum erro na expansão de algum campo,
inclusive devido a recursão infinita
- A expansão automática de todos os campos relacionados (expand=all) está desabilitada
por necessidade de controle mais refinado.
- A expansão de campos relacionados ForeignKey e OneToOne é suportada.
- A expansão de campos relacionados ManyToMany é suportada.
- A expansão de campos relacionados reversos (related_name) não é suportada, mas pode ser implementada manualmente no serializer customizado, ou vir a ser implementada no futuro.
"""
__str__ = rest_serializers.SerializerMethodField()
class Meta:
fields = '__all__'
def get___str__(self, obj) -> str:
return str(obj)
@cached_property
def user_model(self):
return get_user_model()
def get_control_fields(self, control_field='expand'):
request = self.context.get('request', None)
if request:
param = request.query_params.get(control_field, '')
param = [e.strip() for e in param.split(';') if e.strip()]
param = [e.split('.') for e in param]
return param
return []
def get_fields(self):
fields = super().get_fields()
if not hasattr(self.root, 'drf_expand_fields'):
self.root.drf_expand_fields = self.get_control_fields('expand')
self.root.drf_include_fields = self.get_control_fields('include')
self.root.drf_exclude_fields = self.get_control_fields('exclude')
if not (self.root.drf_expand_fields or self.root.drf_include_fields or self.root.drf_exclude_fields):
return fields
model = self.Meta.model
expand_fields = self.root.drf_expand_fields
include_fields = self.root.drf_include_fields
exclude_fields = self.root.drf_exclude_fields
expand_all = False #['all'] in expand_fields
#if expand_all:
# request = self.context.get('request', None)
# user = getattr(request, 'user', None)
# expand_all = user and user.is_superuser
def parents(nd):
if not nd:
return []
return parents(nd.parent) + [nd.field_name]
sources = parents(self)
sources = list(filter(lambda x: x, sources))
if expand_fields:
exps = []
for exp in expand_fields:
if len(exp) > len(sources) and exp[0:len(sources)] == sources:
exps.extend(exp[len(sources)].split(','))
expand_fields = exps
if include_fields:
incls = []
for inf in include_fields:
if len(inf) - 1 == len(sources) and inf[:-1] == sources:
incls.extend(inf[-1].split(','))
if incls:
fields = OrderedDict(
[(k, v) for k, v in fields.items() if k in incls]
)
if exclude_fields:
excls = []
for inf in exclude_fields:
if len(inf) - 1 == len(sources) and inf[:-1] == sources:
excls.extend(inf[-1].split(','))
if excls:
fields = OrderedDict(
[(k, v) for k, v in fields.items() if k not in excls]
)
fields_with_relations_map_model = {f.name: f.related_model for f in model._meta.get_fields()
if f.is_relation and f.name in fields}
set_fields_with_relations = set(fields_with_relations_map_model.keys())
set_expand_fields = set(expand_fields)
set_fields_serialized = set(
map(
lambda kv: kv[0],
filter(
lambda kv: not isinstance(kv[1], rest_serializers.SerializerMethodField),
fields.items()
)
)
)
expand_fields = set_fields_with_relations.intersection(set_fields_serialized)
if not expand_all:
expand_fields = expand_fields.intersection(set_expand_fields)
# remove o User model da expansão
if self.user_model in fields_with_relations_map_model.values():
expand_fields = [f for f in expand_fields
if fields_with_relations_map_model[f] != self.user_model]
if not expand_fields:
return fields
for field_name in expand_fields:
field = fields[field_name]
model = fields_with_relations_map_model[field_name]
if model:
try:
serializer_class = ApiViewSetConstrutor.get_viewset_for_model(model).serializer_class
if hasattr(field, 'many') and field.many or isinstance(field, ManyRelatedField):
serializer_class = serializer_class(
many=True,
read_only=True,
context=self.context
)
else:
serializer_class = serializer_class(
read_only=True,
context=self.context
)
fields[field_name] = serializer_class
except Exception as e:
logger.error(f'Erro ao expandir campo {field_name} do model {model}: {e}')
return fields

70
frontend/src/__apps/compilacao/js/old/compilacao.js

@ -279,6 +279,69 @@ function DispositivoSearch (opts) {
}) })
} }
// Escala de leitura do texto articulado, preferência de cada navegador.
// Aplicada via variável CSS (--cp-font-scale) para não alterar o conteúdo
// dos dispositivos e para valer também nos que são recarregados via ajax.
const EscalaFonte = Object.freeze({
CHAVE: 'compilacao_escala_fonte',
PADRAO: 1,
PASSO: 0.1,
MIN: 0.8,
MAX: 2.5
})
let escalaFonteAtual = null
// Limita num ao intervalo [lower, upper].
function clamp (num, lower, upper) {
return Math.max(lower, Math.min(num, upper))
}
function lerEscalaFonte () {
if (escalaFonteAtual !== null) {
return escalaFonteAtual
}
let valor = null
try {
valor = window.localStorage.getItem(EscalaFonte.CHAVE)
} catch (e) {
// localStorage indisponível (ex.: navegação privada bloqueada)
console.warn('Escala de fonte: não foi possível ler o localStorage.', e)
return EscalaFonte.PADRAO
}
if (valor === null) {
return EscalaFonte.PADRAO
}
const escala = parseFloat(valor)
if (!Number.isFinite(escala)) {
console.warn('Escala de fonte: valor inválido no localStorage, usando o padrão.', valor)
return EscalaFonte.PADRAO
}
return clamp(escala, EscalaFonte.MIN, EscalaFonte.MAX)
}
function aplicarEscalaFonte (escala) {
document.documentElement.style.setProperty('--cp-font-scale', escala)
if (window.tinymce) {
window.tinymce.get().forEach(function (editor) {
if (editor.getBody()) {
editor.getBody().style.fontSize = (escala * 100) + '%'
}
})
}
}
function salvarEscalaFonte (escala) {
escala = Math.round(clamp(escala, EscalaFonte.MIN, EscalaFonte.MAX) * 10) / 10
try {
window.localStorage.setItem(EscalaFonte.CHAVE, escala)
} catch (e) {
// localStorage indisponível: a escala vale apenas até recarregar a página
console.warn('Escala de fonte: não foi possível salvar no localStorage.', escala, e)
}
escalaFonteAtual = escala
aplicarEscalaFonte(escala)
}
function InitViewTAs () { function InitViewTAs () {
setTimeout(function () { setTimeout(function () {
var href = location.href.split('#') var href = location.href.split('#')
@ -297,11 +360,12 @@ function InitViewTAs () {
} }
}, 100) }, 100)
aplicarEscalaFonte(lerEscalaFonte())
$('#btn_font_menos').click(function () { $('#btn_font_menos').click(function () {
$('.dpt').css('font-size', '-=1') salvarEscalaFonte(lerEscalaFonte() - EscalaFonte.PASSO)
}) })
$('#btn_font_mais').click(function () { $('#btn_font_mais').click(function () {
$('.dpt').css('font-size', '+=1') salvarEscalaFonte(lerEscalaFonte() + EscalaFonte.PASSO)
}) })
$('.dpt.bloco_alteracao .dpt').each(function () { $('.dpt.bloco_alteracao .dpt').each(function () {
@ -327,5 +391,7 @@ export default {
ReadCookie, ReadCookie,
insertWaitAjax, insertWaitAjax,
InitViewTAs, InitViewTAs,
lerEscalaFonte,
aplicarEscalaFonte,
DispositivoSearch DispositivoSearch
} }

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

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

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

@ -164,12 +164,21 @@ a:link:after, a:visited:after {
} }
.cp { .cp {
/* --cp-font-scale: escala de leitura escolhida pelo usuário (a/A) */
font-size: calc(100% * var(--cp-font-scale, 1));
.dpt-form textarea.form-control {
font-size: calc(1rem * var(--cp-font-scale, 1));
}
.vigencia-active { .vigencia-active {
margin-top: 30px; margin-top: 30px;
display: block; display: block;
} }
.cp-linha-vigencias { .cp-linha-vigencias {
/* navegação entre vigências não acompanha a escala de leitura */
font-size: calc(100% / var(--cp-font-scale, 1));
display: flex; display: flex;
flex-direction: row; flex-direction: row;
list-style: none; list-style: none;

6
frontend/src/__apps/parlamentar/main.js

@ -3,6 +3,12 @@ import Vue from 'vue'
import { FormSelectPlugin } from 'bootstrap-vue' import { FormSelectPlugin } from 'bootstrap-vue'
import axios from 'axios' import axios from 'axios'
// TODO: refatorar para aproveitar cache e LastModified no frontend oficial
// Configuração global para todas as requisições GET do axios
axios.defaults.headers.get['Cache-Control'] = 'no-cache, no-store, must-revalidate'
axios.defaults.headers.get['Pragma'] = 'no-cache' // Suporte para navegadores mais antigos
axios.defaults.headers.get['Expires'] = '0' // Expira imediatamente
axios.defaults.xsrfCookieName = 'csrftoken' axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = 'X-CSRFToken' axios.defaults.xsrfHeaderName = 'X-CSRFToken'

3
frontend/src/__global/js/functions.js

@ -66,7 +66,8 @@ window.autorModal = function () {
$('#pesquisar').click(function () { $('#pesquisar').click(function () {
const json_data = { const json_data = {
q: $('#q').val() q: $('#q').val(),
utc_time: Date.now(),
// get_all: true // get_all: true
} }
$.get('/api/base/autor', json_data, function (data) { $.get('/api/base/autor', json_data, function (data) {

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

@ -14,7 +14,23 @@ import 'tinymce/plugins/table'
import './langs/pt_BR.js' import './langs/pt_BR.js'
window.tinymce = tinymce window.tinymce = tinymce
window.initTextRichEditor = function (elements, readonly = false, paste_as_text = false) { // Títulos (h1-h6) vindos de estilos de título do Word ou de páginas web
// chegam em negrito e não podem ser desfeitos pelo botão de negrito nem
// por "remover formatação". Converte-os em parágrafos, mantendo o alinhamento.
function colarTitulosComoParagrafo (editor, args) {
args.node.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(function (titulo) {
const paragrafo = document.createElement('p')
if (titulo.style.textAlign) {
paragrafo.style.textAlign = titulo.style.textAlign
}
while (titulo.firstChild) {
paragrafo.appendChild(titulo.firstChild)
}
titulo.replaceWith(paragrafo)
})
}
window.initTextRichEditor = function (elements, readonly = false, paste_as_text = false, paste_titulos_como_paragrafo = false) {
const configTinymce = { const configTinymce = {
selector: elements === null || elements === undefined ? 'textarea' : elements, selector: elements === null || elements === undefined ? 'textarea' : elements,
language: 'pt_BR', language: 'pt_BR',
@ -22,14 +38,17 @@ window.initTextRichEditor = function (elements, readonly = false, paste_as_text
forced_root_block: 'p', forced_root_block: 'p',
paste_as_text, paste_as_text,
plugins: 'table lists advlist link code', plugins: 'table lists advlist link code',
toolbar: 'undo redo | styleselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | code | removeformat ', toolbar: 'undo redo | blocks | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | code | removeformat ',
menubar: 'file edit view insert format table', menubar: 'file edit view insert format table',
license_key: 'gpl' license_key: 'gpl'
} }
if (paste_titulos_como_paragrafo) {
configTinymce.paste_postprocess = colarTitulosComoParagrafo
}
if (readonly) { if (readonly) {
configTinymce.readonly = 1 configTinymce.readonly = 1
configTinymce.menubar = false configTinymce.menubar = false
configTinymce.toolbar = false configTinymce.toolbar = false
} }
window.tinymce.init(configTinymce) return window.tinymce.init(configTinymce)
} }

3
frontend/src/__global/scss/layouts/_globals.scss

@ -187,4 +187,7 @@ small {
a[href]:after { a[href]:after {
content: none !important; content: none !important;
} }
.btn-vlibras {
display: none !important;
}
} }

530
frontend/webpack-stats.json

File diff suppressed because it is too large

7
release.sh

@ -46,13 +46,11 @@ function change_files {
if [[ "$OSTYPE" == "darwin"* ]]; then if [[ "$OSTYPE" == "darwin"* ]]; then
# MacOS (BSD sed) # MacOS (BSD sed)
sed -E -i "" "s|$OLD_VERSION|$FINAL_VERSION|g" docker/docker-compose.yaml sed -E -i "" "s|$OLD_VERSION|$FINAL_VERSION|g" docker/docker-compose.yaml
sed -E -i "" "s|$OLD_VERSION|$FINAL_VERSION|g" setup.py
sed -E -i "" "s|$OLD_VERSION|$FINAL_VERSION|g" sapl/templates/base.html sed -E -i "" "s|$OLD_VERSION|$FINAL_VERSION|g" sapl/templates/base.html
sed -E -i "" "s|$OLD_VERSION|$FINAL_VERSION|g" sapl/settings.py sed -E -i "" "s|$OLD_VERSION|$FINAL_VERSION|g" sapl/settings.py
else else
# Linux (GNU sed) # Linux (GNU sed)
sed -i -E "s|$OLD_VERSION|$FINAL_VERSION|g" docker/docker-compose.yaml sed -i -E "s|$OLD_VERSION|$FINAL_VERSION|g" docker/docker-compose.yaml
sed -i -E "s|$OLD_VERSION|$FINAL_VERSION|g" setup.py
sed -i -E "s|$OLD_VERSION|$FINAL_VERSION|g" sapl/templates/base.html sed -i -E "s|$OLD_VERSION|$FINAL_VERSION|g" sapl/templates/base.html
sed -i -E "s|$OLD_VERSION|$FINAL_VERSION|g" sapl/settings.py sed -i -E "s|$OLD_VERSION|$FINAL_VERSION|g" sapl/settings.py
fi fi
@ -76,9 +74,6 @@ function set_rc_version {
fi fi
FINAL_VERSION=$NEXT_RC_VERSION FINAL_VERSION=$NEXT_RC_VERSION
## DEBUG
# echo "OLD_VERSION: $OLD_VERSION"
# echo "FINAL_VERSION: $FINAL_VERSION"
} }
# Function to display Yes/No prompt with colored message # Function to display Yes/No prompt with colored message
@ -96,7 +91,7 @@ prompt_yes_no() {
function commit_and_push { function commit_and_push {
echo -e "${green_color}Committing new release $FINAL_VERSION...${color_reset}" echo -e "${green_color}Committing new release $FINAL_VERSION...${color_reset}"
git add docker/docker-compose.yaml setup.py sapl/settings.py sapl/templates/base.html git add docker/docker-compose.yaml sapl/settings.py sapl/templates/base.html
git changelog --tag $FINAL_VERSION --prune-old -x > latest_changes.md git changelog --tag $FINAL_VERSION --prune-old -x > latest_changes.md
cat latest_changes.md CHANGES.md > CHANGES.tmp cat latest_changes.md CHANGES.md > CHANGES.tmp
mv CHANGES.tmp CHANGES.md mv CHANGES.tmp CHANGES.md

16
requirements/dev-requirements.txt

@ -1,11 +1,11 @@
-r test-requirements.txt -r test-requirements.txt
autopep8==1.2.4 autopep8==2.3.2
beautifulsoup4==4.9.1 beautifulsoup4==4.13.5
django-debug-toolbar==1.11.1 django-debug-toolbar==3.2.4
ipdb==0.13.3 ipdb==0.13.13
fancycompleter==0.9.1 fancycompleter==0.11.1
pdbpp==0.10.3 pdbpp==0.11.7
pip-review==0.4 pip-review==1.3.0
pipdeptree==0.10.1 pipdeptree==2.28.0
pydevd-pycharm~=203.7148.7 pydevd-pycharm~=203.7148.7

13
requirements/requirements.txt

@ -2,7 +2,6 @@ django==2.2.28
django-haystack==3.1.1 django-haystack==3.1.1
django-filter==2.4.0 django-filter==2.4.0
djangorestframework==3.12.4 djangorestframework==3.12.4
dj-database-url==0.5.0
django-braces==1.14.0 django-braces==1.14.0
django-crispy-forms==1.7.2 django-crispy-forms==1.7.2
django-contrib-postgres==0.0.1 django-contrib-postgres==0.0.1
@ -16,14 +15,17 @@ drf-spectacular==0.18.2
django-ratelimit==3.0.1 django-ratelimit==3.0.1
easy-thumbnails==2.8.5 easy-thumbnails==2.8.5
python-decouple==3.1 python-decouple==3.1
psycopg2-binary==2.8.6 dj-database-url==0.5.0
psycopg2-binary==2.9.9
pyyaml==6.0.1 pyyaml==6.0.1
pytz==2019.3 pytz==2019.3
python-magic==0.4.15 python-magic==0.4.15
unipath==1.1 unipath==1.1
WeasyPrint==51
Pillow==10.3.0 Pillow==10.3.0
gunicorn==22.0.0 reportlab==3.6.13
WeasyPrint==66
trml2pdf==0.6
gunicorn==23.0.0
more-itertools==8.2.0 more-itertools==8.2.0
pysolr==3.6.0 pysolr==3.6.0
PyPDF4==1.27.0 PyPDF4==1.27.0
@ -37,5 +39,6 @@ django-prometheus==2.2.0
asn1crypto==1.5.1 asn1crypto==1.5.1
XlsxWriter==3.2.0 XlsxWriter==3.2.0
git+https://github.com/interlegis/trml2pdf setuptools==80.9.0
git+https://github.com/interlegis/django-admin-bootstrapped git+https://github.com/interlegis/django-admin-bootstrapped

19
requirements/test-requirements.txt

@ -1,11 +1,10 @@
-r requirements.txt -r requirements.txt
coverage==4.4 coverage==7.6.1
django-webtest==1.9.7 django-webtest==1.9.8
flake8==2.6.2 flake8==7.1.1
isort==4.2.5 isort==5.13.2
model-bakery==1.1.0 model-bakery==1.5.0
pep8==1.7.0 pycodestyle==2.12.1
pytest==5.4.3 pytest==8.3.3
pytest-cov==2.10.0 pytest-cov==5.0.0
pytest-django==3.8.0 WebTest==3.0.6
webtest==2.0.21

18
sapl/api/serializers.py

@ -4,6 +4,8 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.db.models import Q from django.db.models import Q
from django.urls import reverse
from drfautoapi.drfautoapi import DrfAutoApiSerializerMixin
from image_cropping.utils import get_backend from image_cropping.utils import get_backend
from rest_framework import serializers from rest_framework import serializers
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
@ -13,16 +15,13 @@ from sapl.parlamentares.models import Parlamentar, Mandato, Legislatura
from sapl.sessao.models import OrdemDia, SessaoPlenaria from sapl.sessao.models import OrdemDia, SessaoPlenaria
class SaplSerializerMixin(serializers.ModelSerializer): class SaplSerializerMixin(DrfAutoApiSerializerMixin):
__str__ = SerializerMethodField() link_detail_backend = serializers.SerializerMethodField()
metadata = SerializerMethodField() metadata = SerializerMethodField()
class Meta: class Meta(DrfAutoApiSerializerMixin.Meta):
fields = '__all__' fields = '__all__'
def get___str__(self, obj) -> str:
return str(obj)
def get_metadata(self, obj) -> dict: def get_metadata(self, obj) -> dict:
try: try:
metadata = Metadata.objects.get( metadata = Metadata.objects.get(
@ -35,6 +34,13 @@ class SaplSerializerMixin(serializers.ModelSerializer):
finally: finally:
return metadata return metadata
def get_link_detail_backend(self, obj) -> str:
try:
return reverse(f'{self.Meta.model._meta.app_config.name}:{self.Meta.model._meta.model_name}_detail',
kwargs={'pk': obj.pk})
except:
return ''
class ChoiceSerializer(serializers.Serializer): class ChoiceSerializer(serializers.Serializer):
value = serializers.SerializerMethodField() value = serializers.SerializerMethodField()

7
sapl/api/urls.py

@ -1,15 +1,13 @@
from django.conf.urls import include, url from django.conf.urls import include, url
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, \ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, \
SpectacularRedocView SpectacularRedocView
from rest_framework.authtoken.views import obtain_auth_token from rest_framework.authtoken.views import obtain_auth_token
from sapl.api.deprecated import SessaoPlenariaViewSet from sapl.api.deprecated import SessaoPlenariaViewSet
from sapl.api.views import AppVersionView, recria_token,\ from sapl.api.views import recria_token, SaplApiViewSetConstrutor
SaplApiViewSetConstrutor
from .apps import AppConfig from .apps import AppConfig
from .views_health import HealthzView, ReadyzView
app_name = AppConfig.name app_name = AppConfig.name
@ -38,7 +36,6 @@ urlpatterns = [
url(r'^api/', include(urlpatterns_api_doc)), url(r'^api/', include(urlpatterns_api_doc)),
url(r'^api/', include(urlpatterns_router)), url(r'^api/', include(urlpatterns_router)),
url(r'^api/version', AppVersionView.as_view()),
url(r'^api/auth/token$', obtain_auth_token), url(r'^api/auth/token$', obtain_auth_token),
url(r'^api/recriar-token/(?P<pk>\d*)$', recria_token, name="recria_token"), url(r'^api/recriar-token/(?P<pk>\d*)$', recria_token, name="recria_token"),
] ]

132
sapl/api/views.py

@ -1,6 +1,7 @@
import logging import logging
from django.conf import settings from django.conf import settings
from django.http import HttpResponse, JsonResponse
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, IsAdminUser from rest_framework.permissions import IsAuthenticated, IsAdminUser
@ -8,6 +9,9 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from drfautoapi.drfautoapi import ApiViewSetConstrutor from drfautoapi.drfautoapi import ApiViewSetConstrutor
from django.views.decorators.http import condition as django_condition
from sapl.base.models import AuditLog
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -20,22 +24,122 @@ def recria_token(request, pk):
return Response({"message": "Token recriado com sucesso!", "token": token.key}) return Response({"message": "Token recriado com sucesso!", "token": token.key})
class LastModifiedDecorator:
class AppVersionView(APIView): """ - Decorator para adicionar suporte a Last-Modified em ViewSets
permission_classes = (IsAuthenticated,) - Baseado em django.views.decorators.http.condition
def get(self, request): - Por padrão utiliza o AuditLog para determinar a data do last_modified
content = { - Pode ser sobrescrito na customização do ViewSet caso necessário
'name': 'SAPL', - Existe um exemplo de sobrescrita em sapl/api/views_materia.py
'description': 'Sistema de Apoio ao Processo Legislativo',
'version': settings.SAPL_VERSION, - Devolve Last-Modified baseado no timestamp do último AuditLog
'user': request.user.username, - Se for uma listagem, considera o último AuditLog de todos os objetos
'is_authenticated': request.user.is_authenticated, retornados na listagem levando em consideração resultado de FilterSet.
} - Retorna 304 Not Modified se o recurso não foi modificado
return Response(content) """
def __call__(self, cls):
original_dispatch = cls.dispatch
self.model = cls.queryset.model
def wrapped_dispatch(view, request, *args, **kwargs):
drf_request = request
wsgi_request = view.initialize_request(request, *args, **kwargs)
last_modified_func = self.last_modified_func if not hasattr(view, 'last_modified_func') else view.last_modified_func
def patched_viewset_method(*_args, **_kwargs):
return original_dispatch(view, drf_request, *args, **kwargs)
django_decorator = django_condition(last_modified_func=last_modified_func)
decorated_viewset_method = django_decorator(patched_viewset_method)
return decorated_viewset_method(wsgi_request, *args, view=view, **kwargs)
cls.dispatch = wrapped_dispatch
return cls
def last_modified_func(self, request, *args, **kwargs):
default_fields_for_timestamp = (
'data_ultima_atualizacao',
'ultima_edicao'
)
# tenta encontrar um dos campos acima para usar como last_modified
fields = tuple(
filter(
lambda f: f in default_fields_for_timestamp,
map(
lambda f: f.name, self.model._meta.get_fields()
)
)
)
field_name = fields[0] if fields else None
# Retornando None. que indica para o decorator retornar 200 OK sem Last-Modified
# Na prática, desativando o AuditLog como last_modified
# Porém, mantendo a estrutura para possível uso futuro
if not field_name:
# sem campo definido para last_modified, usa AuditLog
return None # self.last_modified_func__auditlog(request, *args, **kwargs)
pk = kwargs.get('pk', None)
if pk:
return self.model.objects.filter(pk=pk).values_list(field_name, flat=True)[:1].first()
view = kwargs.get('view', None)
if view:
queryset = view.get_queryset()
for backend in list(view.filter_backends):
queryset = backend().filter_queryset(request, queryset, view)
else:
queryset = self.model.objects.all()
timestamp = queryset.order_by(f'-{field_name}').values_list(field_name, flat=True)[:1].first()
return timestamp
def last_modified_func__auditlog(self, request, *args, **kwargs):
""" - Método padrão para obter o last_modified baseado no AuditLog
- Pode ser sobrescrito na customização do ViewSet caso necessário
- Existe um exemplo de sobrescrita em sapl/api/views_materia.py
"""
try:
if 'pk' in kwargs:
obj_id = kwargs['pk']
last_log = AuditLog.objects.filter(
model_name=self.model._meta.model_name,
object_id=obj_id
).order_by('-timestamp').values_list('timestamp', flat=True)[:1].first()
else:
view = kwargs.get('view', None)
if view:
for backend in list(view.filter_backends):
queryset = backend().filter_queryset(request, view.queryset, view)
if queryset.exists():
last_log = AuditLog.objects.filter(
model_name=self.model._meta.model_name,
object_id__in=queryset.values_list('pk', flat=True)
).order_by('-timestamp').values_list('timestamp', flat=True)[:1].first()
else:
last_log = None
else:
last_log = AuditLog.objects.filter(
model_name=self.model._meta.model_name,
object_id__in=self.model.objects.values_list('pk', flat=True)
).order_by('-timestamp').values_list('timestamp', flat=True)[:1].first()
if last_log:
return last_log
except Exception as e:
logger.error(f"Erro ao obter last_modified: {e}")
return None
SaplApiViewSetConstrutor = ApiViewSetConstrutor SaplApiViewSetConstrutor = ApiViewSetConstrutor
SaplApiViewSetConstrutor.last_modified_class(LastModifiedDecorator)
SaplApiViewSetConstrutor.import_modules([ SaplApiViewSetConstrutor.import_modules([
'sapl.api.views_audiencia', 'sapl.api.views_audiencia',
'sapl.api.views_base', 'sapl.api.views_base',

106
sapl/api/views_health.py

@ -0,0 +1,106 @@
import logging
import traceback
from django.http import HttpResponse, JsonResponse
from django.utils import timezone
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.conf import settings
from sapl.health import check_app, check_db, check_cache
COMMON_HEADERS = {
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
"Pragma": "no-cache",
}
def _format_plain(ok: bool) -> HttpResponse:
return HttpResponse("OK\n" if ok else "UNHEALTHY\n",
status=status.HTTP_200_OK if ok else status.HTTP_503_SERVICE_UNAVAILABLE,
content_type="text/plain")
class HealthzView(APIView):
authentication_classes = []
permission_classes = []
logger = logging.getLogger(__name__)
def get(self, request):
try:
ok, msg, ms = check_app()
payload = {
"status": "OK" if ok else "UNHEALTHY",
"checks": {"app": {"ok": ok, "latency_ms": round(ms, 1), "error": msg}},
"version": settings.SAPL_VERSION,
"time": timezone.now().isoformat(),
}
if request.query_params.get("fmt") == "txt":
return _format_plain(ok)
return Response(payload,
status=status.HTTP_200_OK if ok else status.HTTP_503_SERVICE_UNAVAILABLE,
headers=COMMON_HEADERS)
except Exception as e:
self.logger.error(traceback.format_exc())
return "An internal error has occurred!"
class ReadyzView(APIView):
authentication_classes = []
permission_classes = []
logger = logging.getLogger(__name__)
def get(self, request):
try:
checks = {
"app": check_app(),
"db": check_db(),
"cache": check_cache(),
}
payload_checks = {
name: {"ok": r[0], "latency_ms": round(r[2], 1), "error": r[1]}
for name, r in checks.items()
}
ok = all(r[0] for r in checks.values())
payload = {
"status": "ok" if ok else "unhealthy",
"checks": payload_checks,
"version": settings.SAPL_VERSION,
"time": timezone.now().isoformat(),
}
if request.query_params.get("fmt") == "txt":
return _format_plain(ok)
return Response(payload,
status=status.HTTP_200_OK if ok else status.HTTP_503_SERVICE_UNAVAILABLE,
headers=COMMON_HEADERS)
except Exception as e:
self.logger.error(traceback.format_exc())
return "An internal error has occurred!"
class AppzVersionView(APIView):
authentication_classes = []
permission_classes = []
logger = logging.getLogger(__name__)
def get(self, request):
try:
payload = {
'name': 'SAPL',
'description': 'Sistema de Apoio ao Processo Legislativo',
'version': settings.SAPL_VERSION,
}
if request.query_params.get("fmt") == "txt":
return HttpResponse(f"{payload['version']} {payload['name']}",
status=status.HTTP_200_OK,
content_type="text/plain")
return Response(payload,
status=status.HTTP_200_OK,
headers=COMMON_HEADERS)
except Exception as e:
self.logger.error(traceback.format_exc())
return "An internal error has occurred!"

75
sapl/api/views_materia.py

@ -1,7 +1,9 @@
from django.apps.registry import apps from django.apps.registry import apps
from django.db import IntegrityError, transaction
from django.db.models import Q from django.db.models import Q
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.status import HTTP_201_CREATED, HTTP_409_CONFLICT
from rest_framework.response import Response from rest_framework.response import Response
from drfautoapi.drfautoapi import ApiViewSetConstrutor, \ from drfautoapi.drfautoapi import ApiViewSetConstrutor, \
@ -90,6 +92,60 @@ class _MateriaLegislativaViewSet:
class Meta: class Meta:
ordering = ['-ano', 'tipo', 'numero'] ordering = ['-ano', 'tipo', 'numero']
_MAX_RETRIES_NUMERO = 3
def create(self, request, *args, **kwargs):
data = request.data.copy()
tipo = data.get('tipo', None)
numero = data.get('numero', None)
ano = data.get('ano', None)
if tipo and not numero:
# Número não fornecido pelo cliente: auto-gerar próximo disponível.
# select_for_update() em get_proximo_numero previne race conditions.
# Retry como camada extra de segurança contra IntegrityError residual.
for tentativa in range(self._MAX_RETRIES_NUMERO):
try:
with transaction.atomic():
numero_gerado, ano_gerado = MateriaLegislativa.get_proximo_numero(
tipo=tipo, ano=ano
)
data['numero'] = numero_gerado
data['ano'] = ano_gerado
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=HTTP_201_CREATED, headers=headers)
except IntegrityError:
if tentativa == self._MAX_RETRIES_NUMERO - 1:
return Response(
{'detail': 'Não foi possível gerar um número único após '
'%d tentativas. Tente novamente.' % self._MAX_RETRIES_NUMERO},
status=HTTP_409_CONFLICT
)
continue
# Número fornecido pelo cliente (ou tipo ausente):
# respeitar os dados enviados. Se houver conflito de unicidade,
# retornar erro explícito em vez de sobrescrever silenciosamente.
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
try:
with transaction.atomic():
self.perform_create(serializer)
except IntegrityError:
return Response(
{'numero': [
'O número %s já está em uso para este tipo/ano. '
'Remova o campo "numero" para auto-gerar o próximo disponível.' % numero
]},
status=HTTP_409_CONFLICT
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=HTTP_201_CREATED, headers=headers)
@action(detail=True, methods=['GET']) @action(detail=True, methods=['GET'])
def ultima_tramitacao(self, request, *args, **kwargs): def ultima_tramitacao(self, request, *args, **kwargs):
@ -110,6 +166,25 @@ class _MateriaLegislativaViewSet:
self.queryset = self.get_object().anexadas.all() self.queryset = self.get_object().anexadas.all()
return self.list(request, *args, **kwargs) return self.list(request, *args, **kwargs)
"""
O próprio decorator LastModifiedDecorator já implementa o método last_modified_func que atende o caso
específico de MateriaLegislativa, baseado no campo data_ultima_atualizacao.
Portanto, não há necessidade de reimplementar este método aqui. Mas segue o código comentado
para referência futura caso necessário e como exemplo de implementação customizada que sobrescreve
o comportamento padrão do decorator.
def last_modified_func(self, request, *args, **kwargs):
pk = kwargs.get('pk', None)
if pk:
return MateriaLegislativa.objects.filter(pk=pk).values_list('data_ultima_atualizacao', flat=True)[:1].first()
queryset = self.get_queryset()
for backend in list(self.filter_backends):
queryset = backend().filter_queryset(request, self.queryset, self)
timestamp = queryset.order_by('-data_ultima_atualizacao').values_list('data_ultima_atualizacao', flat=True)[:1].first()
return timestamp
"""
@customize(DocumentoAcessorio) @customize(DocumentoAcessorio)

22
sapl/audiencia/forms.py

@ -10,9 +10,9 @@ from django.utils.translation import ugettext_lazy as _
from crispy_forms.layout import Button, Column, Fieldset, HTML, Layout from crispy_forms.layout import Button, Column, Fieldset, HTML, Layout
from sapl.audiencia.models import AudienciaPublica, TipoAudienciaPublica, AnexoAudienciaPublica from sapl.audiencia.models import AudienciaPublica, TipoAudienciaPublica, AnexoAudienciaPublica
from sapl.base.models import Autor
from sapl.crispy_layout_mixin import form_actions, SaplFormHelper, SaplFormLayout, to_row from sapl.crispy_layout_mixin import form_actions, SaplFormHelper, SaplFormLayout, to_row
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.parlamentares.models import Parlamentar
from sapl.utils import timezone, FileFieldCheckMixin, validar_arquivo from sapl.utils import timezone, FileFieldCheckMixin, validar_arquivo
@ -44,10 +44,10 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
widget=forms.HiddenInput(), widget=forms.HiddenInput(),
queryset=MateriaLegislativa.objects.all()) queryset=MateriaLegislativa.objects.all())
parlamentar_autor = forms.ModelChoiceField( autor = forms.ModelChoiceField(
label=_("Parlamentar Autor"), label=_("Autor"),
required=False, required=False,
queryset=Parlamentar.objects.all()) queryset=Autor.objects.all().order_by('nome'))
requerimento = forms.ModelChoiceField( requerimento = forms.ModelChoiceField(
label=_("Requerimento"), label=_("Requerimento"),
@ -58,7 +58,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
model = AudienciaPublica model = AudienciaPublica
fields = ['tipo', 'numero', 'ano', 'nome', fields = ['tipo', 'numero', 'ano', 'nome',
'tema', 'data', 'hora_inicio', 'hora_fim', 'tema', 'data', 'hora_inicio', 'hora_fim',
'observacao', 'audiencia_cancelada', 'parlamentar_autor', 'requerimento', 'url_audio', 'observacao', 'audiencia_cancelada', 'autor', 'requerimento', 'url_audio',
'url_video', 'upload_pauta', 'upload_ata', 'url_video', 'upload_pauta', 'upload_ata',
'upload_anexo', 'tipo_materia', 'numero_materia', 'upload_anexo', 'tipo_materia', 'numero_materia',
'ano_materia', 'materia'] 'ano_materia', 'materia']
@ -85,7 +85,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
materia = cleaned_data['numero_materia'] materia = cleaned_data['numero_materia']
ano_materia = cleaned_data['ano_materia'] ano_materia = cleaned_data['ano_materia']
tipo_materia = cleaned_data['tipo_materia'] tipo_materia = cleaned_data['tipo_materia']
parlamentar_autor = cleaned_data["parlamentar_autor"] autor = cleaned_data["autor"]
requerimento = cleaned_data["requerimento"] requerimento = cleaned_data["requerimento"]
if cleaned_data["ano"] != cleaned_data["data"].year: if cleaned_data["ano"] != cleaned_data["data"].year:
@ -115,7 +115,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
numero=materia, numero=materia,
ano=ano_materia, ano=ano_materia,
tipo=tipo_materia) tipo=tipo_materia)
except ObjectDoesNotExist: except (ObjectDoesNotExist, ValueError):
msg = _('A matéria %s nº %s/%s não existe no cadastro' msg = _('A matéria %s nº %s/%s não existe no cadastro'
' de matérias legislativas.' % (tipo_materia, materia, ano_materia)) ' de matérias legislativas.' % (tipo_materia, materia, ano_materia))
self.logger.warning( self.logger.warning(
@ -157,12 +157,12 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
raise ValidationError(msg) raise ValidationError(msg)
# requerimento é optativo # requerimento é optativo
if parlamentar_autor and requerimento: if autor and requerimento:
if parlamentar_autor.autor.first() not in requerimento.autores.all(): if autor not in requerimento.autores.all():
raise ValidationError("Parlamentar Autor selecionado não faz" raise ValidationError("Autor selecionado não faz"
" parte da autoria do Requerimento " " parte da autoria do Requerimento "
"selecionado.") "selecionado.")
elif parlamentar_autor: elif autor:
raise ValidationError("Para informar um autor deve-se informar um requerimento.") raise ValidationError("Para informar um autor deve-se informar um requerimento.")
elif requerimento: elif requerimento:
raise ValidationError("Para informar um requerimento deve-se informar um autor.") raise ValidationError("Para informar um requerimento deve-se informar um autor.")

28
sapl/audiencia/migrations/0020_auto_20251201_1450.py

@ -0,0 +1,28 @@
# Generated by Django 2.2.28 on 2025-12-01 17:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('audiencia', '0019_auto_20240711_1400'),
]
operations = [
migrations.AlterField(
model_name='audienciapublica',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'),
),
migrations.AlterField(
model_name='audienciapublica',
name='nome',
field=models.CharField(max_length=250, verbose_name='Nome da Audiência Pública'),
),
migrations.AlterField(
model_name='audienciapublica',
name='tema',
field=models.CharField(max_length=250, verbose_name='Tema da Audiência Pública'),
),
]

59
sapl/audiencia/migrations/0021_auto_20260623_1938.py

@ -0,0 +1,59 @@
# Generated by Django 2.2.28 on 2026-06-23 22:38
from django.db import migrations, models
import django.db.models.deletion
def copia_parlamentar_para_autor(apps, schema_editor):
"""Converte o antigo parlamentar_autor no Autor genérico correspondente."""
AudienciaPublica = apps.get_model('audiencia', 'AudienciaPublica')
Autor = apps.get_model('base', 'Autor')
ContentType = apps.get_model('contenttypes', 'ContentType')
try:
ct = ContentType.objects.get(
app_label='parlamentares', model='parlamentar')
except ContentType.DoesNotExist:
return
for ap in AudienciaPublica.objects.filter(parlamentar_autor__isnull=False):
autor = Autor.objects.filter(
content_type=ct, object_id=ap.parlamentar_autor_id).first()
if autor:
ap.autor = autor
ap.save(update_fields=['autor'])
class Migration(migrations.Migration):
dependencies = [
('base', '0060_auto_20240812_1628'),
('audiencia', '0020_auto_20251201_1450'),
]
operations = [
migrations.AddField(
model_name='audienciapublica',
name='autor',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='base.Autor', verbose_name='Autor'),
),
migrations.RunPython(
copia_parlamentar_para_autor, migrations.RunPython.noop),
# Mantém a coluna parlamentar_autor no banco (removida apenas do estado
# do Django). Assim uma casa pode dar rollback da imagem sem perder o
# vínculo, e o código novo simplesmente ignora a coluna. O DROP
# definitivo fica para uma migração posterior, quando toda a frota
# estiver estável nesta versão.
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(
model_name='audienciapublica',
name='parlamentar_autor',
),
],
database_operations=[],
),
migrations.AlterField(
model_name='audienciapublica',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'),
),
]

18
sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2026-06-26 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('audiencia', '0021_auto_20260623_1938'),
]
operations = [
migrations.AddField(
model_name='audienciapublica',
name='data_ultima_atualizacao',
field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'),
),
]

15
sapl/audiencia/models.py

@ -3,7 +3,7 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from model_utils import Choices from model_utils import Choices
from sapl.materia.models import MateriaLegislativa from sapl.materia.models import MateriaLegislativa
from sapl.parlamentares.models import (CargoMesa, Parlamentar) from sapl.parlamentares.models import CargoMesa
from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericRelation, from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericRelation,
restringe_tipos_de_arquivo_txt, texto_upload_path, restringe_tipos_de_arquivo_txt, texto_upload_path,
@ -65,9 +65,9 @@ class AudienciaPublica(models.Model):
ano = models.PositiveSmallIntegerField(verbose_name=_('Ano'), ano = models.PositiveSmallIntegerField(verbose_name=_('Ano'),
choices=RANGE_ANOS) choices=RANGE_ANOS)
nome = models.CharField( nome = models.CharField(
max_length=100, verbose_name=_('Nome da Audiência Pública')) max_length=250, verbose_name=_('Nome da Audiência Pública'))
tema = models.CharField( tema = models.CharField(
max_length=100, verbose_name=_('Tema da Audiência Pública')) max_length=250, verbose_name=_('Tema da Audiência Pública'))
data = models.DateField(verbose_name=_('Data')) data = models.DateField(verbose_name=_('Data'))
hora_inicio = models.CharField( hora_inicio = models.CharField(
max_length=5, verbose_name=_('Horário Início(hh:mm)')) max_length=5, verbose_name=_('Horário Início(hh:mm)'))
@ -79,12 +79,15 @@ class AudienciaPublica(models.Model):
default=False, default=False,
choices=YES_NO_CHOICES, choices=YES_NO_CHOICES,
verbose_name=_('Audiência Cancelada?')) verbose_name=_('Audiência Cancelada?'))
parlamentar_autor = models.ForeignKey( autor = models.ForeignKey(
Parlamentar, 'base.Autor',
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=True, blank=True,
verbose_name=_('Parlamentar Autor')) verbose_name=_('Autor'))
data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True,
verbose_name=_('Data da Última Atualização'))
requerimento = models.ForeignKey( requerimento = models.ForeignKey(
MateriaLegislativa, MateriaLegislativa,
null=True, null=True,

9
sapl/audiencia/views.py

@ -9,6 +9,12 @@ from sapl.crud.base import RP_DETAIL, RP_LIST, Crud, MasterDetailCrud
from .forms import AudienciaForm, AnexoAudienciaPublicaForm from .forms import AudienciaForm, AnexoAudienciaPublicaForm
from .models import AudienciaPublica, AnexoAudienciaPublica from .models import AudienciaPublica, AnexoAudienciaPublica
from ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
from ..settings import RATE_LIMITER_RATE
from ..utils import ratelimit_ip
def index(request): def index(request):
return HttpResponse("Audiência Pública") return HttpResponse("Audiência Pública")
@ -105,6 +111,3 @@ class AnexoAudienciaPublicaCrud(MasterDetailCrud):
qs = super(MasterDetailCrud.ListView, self).get_queryset() qs = super(MasterDetailCrud.ListView, self).get_queryset()
kwargs = {self.crud.parent_field: self.kwargs['pk']} kwargs = {self.crud.parent_field: self.kwargs['pk']}
return qs.filter(**kwargs).order_by('-data', '-id') return qs.filter(**kwargs).order_by('-data', '-id')
class DetailView(AudienciaPublicaMixin, MasterDetailCrud.DetailView):
pass

1
sapl/base/apps.py

@ -1,4 +1,3 @@
import django import django
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _

2
sapl/base/email_utils.py

@ -21,7 +21,7 @@ def load_email_templates(templates, context={}):
tpl = loader.get_template(t) tpl = loader.get_template(t)
email = tpl.render(context) email = tpl.render(context)
if t.endswith(".html"): if t.endswith(".html"):
email = email.replace('\n', '').replace('\r', '') email = email.replace('\\n', '').replace('\r', '')
emails.append(email) emails.append(email)
return emails return emails

19
sapl/base/migrations/0061_casalegislativa_informacao_geral.py

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

4
sapl/base/models.py

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

4
sapl/base/receivers.py

@ -225,7 +225,7 @@ def signed_files_extraction_function(sender, instance, **kwargs):
issuer = cert.native['tbs_certificate']['issuer'] issuer = cert.native['tbs_certificate']['issuer']
oname = issuer.get('organization_name', '') oname = issuer.get('organization_name', '')
if oname == 'Gov-Br': if oname in ('Gov-Br', '1Doc'):
nome = subject['common_name'].split(':')[0] nome = subject['common_name'].split(':')[0]
continue continue
@ -336,7 +336,7 @@ def signed_files_extraction_function(sender, instance, **kwargs):
issuer = cert.native['tbs_certificate']['issuer'] issuer = cert.native['tbs_certificate']['issuer']
oname = issuer.get('organization_name', '') oname = issuer.get('organization_name', '')
if oname == 'Gov-Br': if oname in ('Gov-Br', '1Doc'):
nome = subject['common_name'].split(':')[0] nome = subject['common_name'].split(':')[0]
continue continue

2
sapl/base/search_indexes.py

@ -108,7 +108,7 @@ class TextExtractField(CharField):
continue continue
data += getattr(self, func)(value) + ' ' data += getattr(self, func)(value) + ' '
data = data.replace('\n', ' ') data = data.replace('\\n', ' ')
return data return data

4
sapl/base/templatetags/common_tags.py

@ -300,7 +300,7 @@ def youtube_url(value):
# Test if YouTube video # Test if YouTube video
# tested on https://pythex.org/ # tested on https://pythex.org/
value = value.lower() value = value.lower()
youtube_pattern = "^((https?://)?(www\.)?youtube\.com\/watch\?v=)" youtube_pattern = r"^((https?://)?(www\.)?youtube\.com\/watch\?v=)"
r = re.findall(youtube_pattern, value) r = re.findall(youtube_pattern, value)
return True if r else False return True if r else False
@ -308,7 +308,7 @@ def youtube_url(value):
@register.filter @register.filter
def facebook_url(value): def facebook_url(value):
value = value.lower() value = value.lower()
facebook_pattern = "^((https?://)?((www|pt-br)\.)?facebook\.com(\/.+)?\/videos(\/.*)?)" facebook_pattern = r"^((https?://)?((www|pt-br)\.)?facebook\.com(\/.+)?\/videos(\/.*)?)"
r = re.findall(facebook_pattern, value) r = re.findall(facebook_pattern, value)
return True if r else False return True if r else False

27
sapl/base/tests/test_form.py

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

15
sapl/base/views.py

@ -48,10 +48,10 @@ from sapl.parlamentares.models import (
from sapl.protocoloadm.models import (Anexado, Protocolo) from sapl.protocoloadm.models import (Anexado, Protocolo)
from sapl.relatorios.views import (relatorio_estatisticas_acesso_normas) from sapl.relatorios.views import (relatorio_estatisticas_acesso_normas)
from sapl.sessao.models import (Bancada, SessaoPlenaria) from sapl.sessao.models import (Bancada, SessaoPlenaria)
from sapl.settings import EMAIL_SEND_USER from sapl.settings import EMAIL_SEND_USER, RATE_LIMITER_RATE
from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_service_configured, from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_service_configured,
SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, google_recaptcha_configured, SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, google_recaptcha_configured,
get_client_ip, sapn_is_enabled, is_weak_password) get_client_ip, sapn_is_enabled, is_weak_password, ratelimit_ip)
from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, EstatisticasAcessoNormasForm) from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, EstatisticasAcessoNormasForm)
from .models import AppConfig, CasaLegislativa from .models import AppConfig, CasaLegislativa
@ -67,10 +67,11 @@ class IndexView(TemplateView):
return TemplateView.get(self, request, *args, **kwargs) return TemplateView.get(self, request, *args, **kwargs)
@method_decorator(ratelimit(key=lambda group, request: get_client_ip(request), @method_decorator(ratelimit(key=ratelimit_ip,
rate='20/m', rate=RATE_LIMITER_RATE,
method=ratelimit.UNSAFE, method=ratelimit.UNSAFE,
block=True), name='dispatch') block=True),
name='dispatch')
class LoginSapl(views.LoginView): class LoginSapl(views.LoginView):
template_name = 'base/login.html' template_name = 'base/login.html'
authentication_form = LoginForm authentication_form = LoginForm
@ -1399,6 +1400,10 @@ class SaplSearchView(SearchView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class PesquisarAuditLogView(PermissionRequiredMixin, FilterView): class PesquisarAuditLogView(PermissionRequiredMixin, FilterView):
model = AuditLog model = AuditLog
filterset_class = AuditLogFilterSet filterset_class = AuditLogFilterSet

25
sapl/comissoes/forms.py

@ -558,3 +558,28 @@ class DocumentoAcessorioEditForm(FileFieldCheckMixin, forms.ModelForm):
# raise ValidationError(f'Favor anexar arquivo em {nome_arquivo}') # raise ValidationError(f'Favor anexar arquivo em {nome_arquivo}')
return self.cleaned_data return self.cleaned_data
class ReuniaoFilterSet(django_filters.FilterSet):
nome = django_filters.CharFilter(
lookup_expr='icontains', label=_('Nome da Reunião'))
tema = django_filters.CharFilter(
lookup_expr='icontains', label=_('Tema da Reunião'))
class Meta(FilterOverridesMetaMixin):
model = Reuniao
fields = ['numero', 'nome', 'tema', 'data']
def __init__(self, *args, **kwargs):
super(ReuniaoFilterSet, self).__init__(*args, **kwargs)
row1 = to_row([('numero', 3), ('nome', 5), ('tema', 4)])
row2 = to_row([('data', 6)])
self.form.helper = SaplFormHelper()
self.form.helper.form_method = 'GET'
self.form.helper.layout = Layout(
Fieldset(_('Pesquisar Reunião'),
row1, row2,
form_actions(label=_('Pesquisar'))))

18
sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2026-06-26 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('comissoes', '0030_auto_20231007_2149'),
]
operations = [
migrations.AddField(
model_name='comissao',
name='data_ultima_atualizacao',
field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'),
),
]

3
sapl/comissoes/models.py

@ -34,6 +34,9 @@ class Comissao(models.Model):
tipo = models.ForeignKey(TipoComissao, tipo = models.ForeignKey(TipoComissao,
on_delete=models.PROTECT, on_delete=models.PROTECT,
verbose_name=_('Tipo')) verbose_name=_('Tipo'))
data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True,
verbose_name=_('Data da Última Atualização'))
nome = models.CharField(max_length=100, verbose_name=_('Nome')) nome = models.CharField(max_length=100, verbose_name=_('Nome'))
sigla = models.CharField(max_length=10, verbose_name=_('Sigla')) sigla = models.CharField(max_length=10, verbose_name=_('Sigla'))
data_criacao = models.DateField(verbose_name=_('Data de Criação')) data_criacao = models.DateField(verbose_name=_('Data de Criação'))

22
sapl/comissoes/views.py

@ -22,17 +22,22 @@ from sapl.comissoes.forms import (ComissaoForm, ComposicaoForm,
ParticipacaoCreateForm, ParticipacaoCreateForm,
ParticipacaoEditForm, ParticipacaoEditForm,
PautaReuniaoFilterSet, PautaReuniaoForm, PautaReuniaoFilterSet, PautaReuniaoForm,
PeriodoForm, ReuniaoForm) PeriodoForm, ReuniaoFilterSet, ReuniaoForm)
from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud, from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud,
PermissionRequiredForAppCrudMixin, RP_DETAIL, PermissionRequiredForAppCrudMixin, RP_DETAIL,
RP_LIST) RP_LIST)
from sapl.materia.models import (MateriaEmTramitacao, MateriaLegislativa, from sapl.materia.models import (MateriaEmTramitacao, MateriaLegislativa,
PautaReuniao, Tramitacao) PautaReuniao, Tramitacao)
from sapl.utils import show_results_filter_set from sapl.utils import show_results_filter_set, ratelimit_ip
from .models import (CargoComissao, Comissao, Composicao, DocumentoAcessorio, from .models import (CargoComissao, Comissao, Composicao, DocumentoAcessorio,
Participacao, Periodo, Reuniao, TipoComissao) Participacao, Periodo, Reuniao, TipoComissao)
from ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
from ..settings import RATE_LIMITER_RATE
def pegar_url_composicao(pk): def pegar_url_composicao(pk):
participacao = Participacao.objects.get(id=pk) participacao = Participacao.objects.get(id=pk)
@ -211,7 +216,7 @@ class ReuniaoCrud(MasterDetailCrud):
public = [RP_LIST, RP_DETAIL, ] public = [RP_LIST, RP_DETAIL, ]
class BaseMixin(MasterDetailCrud.BaseMixin): class BaseMixin(MasterDetailCrud.BaseMixin):
list_field_names = ['data', 'nome', 'tema', 'upload_ata'] list_field_names = ['data', 'nome', 'tema', 'upload_pauta', 'upload_ata']
class DetailView(MasterDetailCrud.DetailView): class DetailView(MasterDetailCrud.DetailView):
template_name = "comissoes/reuniao_detail.html" template_name = "comissoes/reuniao_detail.html"
@ -243,6 +248,11 @@ class ReuniaoCrud(MasterDetailCrud):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
paginate_by = 10 paginate_by = 10
def get_queryset(self):
qs = super().get_queryset()
self.filterset = ReuniaoFilterSet(self.request.GET, queryset=qs)
return self.filterset.qs
def take_reuniao_pk(self): def take_reuniao_pk(self):
username = self.request.user.username username = self.request.user.username
@ -271,6 +281,8 @@ class ReuniaoCrud(MasterDetailCrud):
context['documentoacessorio_set'] = DocumentoAcessorio.objects.filter( context['documentoacessorio_set'] = DocumentoAcessorio.objects.filter(
reuniao__pk=context['reuniao_pk'] reuniao__pk=context['reuniao_pk']
).order_by('id') ).order_by('id')
context['form'] = self.filterset.form
return context return context
class UpdateView(MasterDetailCrud.UpdateView): class UpdateView(MasterDetailCrud.UpdateView):
@ -333,6 +345,10 @@ class RemovePautaView(PermissionRequiredMixin, CreateView):
return HttpResponseRedirect(success_url) return HttpResponseRedirect(success_url)
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class AdicionaPautaView(PermissionRequiredMixin, FilterView): class AdicionaPautaView(PermissionRequiredMixin, FilterView):
filterset_class = PautaReuniaoFilterSet filterset_class = PautaReuniaoFilterSet
template_name = 'comissoes/pauta.html' template_name = 'comissoes/pauta.html'

2
sapl/compilacao/forms.py

@ -987,7 +987,7 @@ class DispositivoEdicaoVigenciaForm(ModelForm):
p.pk, _('%s realizada em %s. %s') % ( p.pk, _('%s realizada em %s. %s') % (
p.tipo_publicacao, p.tipo_publicacao,
defaultfilters.date( defaultfilters.date(
p.data, "d \d\e F \d\e Y"), p.data, r"d \d\e F \d\e Y"),
str(p.ta))) for p in pubs] str(p.ta))) for p in pubs]
dvs = Dispositivo.objects.order_by('ordem').filter( dvs = Dispositivo.objects.order_by('ordem').filter(

4
sapl/compilacao/models.py

@ -287,7 +287,7 @@ class TextoArticulado(TimestampedMixin):
return _('%(tipo)s nº %(numero)s, de %(data)s') % { return _('%(tipo)s nº %(numero)s, de %(data)s') % {
'tipo': self.tipo_ta, 'tipo': self.tipo_ta,
'numero': numero, 'numero': numero,
'data': defaultfilters.date(self.data, "d \d\e F \d\e Y").lower()} 'data': defaultfilters.date(self.data, r"d \d\e F \d\e Y").lower()}
def hash(self): def hash(self):
from django.core import serializers from django.core import serializers
@ -943,7 +943,7 @@ class Publicacao(TimestampedMixin):
def __str__(self): def __str__(self):
return _('%s realizada em %s \n <small>%s</small>') % ( return _('%s realizada em %s \n <small>%s</small>') % (
self.tipo_publicacao, self.tipo_publicacao,
defaultfilters.date(self.data, "d \d\e F \d\e Y"), defaultfilters.date(self.data, r"d \d\e F \d\e Y"),
self.ta) self.ta)

13
sapl/compilacao/utils.py

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

44
sapl/compilacao/views.py

@ -49,7 +49,7 @@ from sapl.compilacao.models import (STATUS_TA_EDITION, STATUS_TA_PRIVATE,
VeiculoPublicacao, Vide) VeiculoPublicacao, Vide)
from sapl.compilacao.utils import (DISPOSITIVO_SELECT_RELATED, from sapl.compilacao.utils import (DISPOSITIVO_SELECT_RELATED,
DISPOSITIVO_SELECT_RELATED_EDIT, DISPOSITIVO_SELECT_RELATED_EDIT,
get_integrations_view_names) get_integrations_view_names, valid_int)
from sapl.crud.base import RP_DETAIL, RP_LIST, Crud, CrudAux, CrudListView,\ from sapl.crud.base import RP_DETAIL, RP_LIST, Crud, CrudAux, CrudListView,\
make_pagination make_pagination
from sapl.settings import BASE_DIR from sapl.settings import BASE_DIR
@ -3177,14 +3177,14 @@ class DispositivoSearchFragmentFormView(ListView):
result = [] result = []
try: try:
tipo_model = self.request.GET.get('tipo_model', '') tipo_model = valid_int(self.request.GET.get('tipo_model', ''))
limit = int(self.request.GET.get('max_results', 100)) limit = int(self.request.GET.get('max_results', 100))
tipo_ta = self.request.GET.get('tipo_ta', '') tipo_ta = valid_int(self.request.GET.get('tipo_ta', ''))
num_ta = self.request.GET.get('num_ta', '') num_ta = self.request.GET.get('num_ta', '')
ano_ta = self.request.GET.get('ano_ta', '') ano_ta = valid_int(self.request.GET.get('ano_ta', ''))
rotulo = self.request.GET.get('rotulo', '') rotulo = self.request.GET.get('rotulo', '')
str_texto = self.request.GET.get('texto', '') str_texto = self.request.GET.get('texto', '')
texto = str_texto.split(' ') texto_valores = str_texto.split(' ')
tipo_resultado = self.request.GET.get('tipo_resultado', '') tipo_resultado = self.request.GET.get('tipo_resultado', '')
tipo_resultado = '' if tipo_resultado == 'False' else tipo_resultado tipo_resultado = '' if tipo_resultado == 'False' else tipo_resultado
@ -3228,20 +3228,24 @@ class DispositivoSearchFragmentFormView(ListView):
AND_CONTROLS = '''AND td.dispositivo_de_alteracao = true AND_CONTROLS = '''AND td.dispositivo_de_alteracao = true
AND td.dispositivo_de_articulacao = true''' AND td.dispositivo_de_articulacao = true'''
texto = list(map("d.texto ~* '{}'".format, texto)) texto = ['d.texto ~* %s'] * len(texto_valores)
AND_TEXTO_ROTULO = '' AND_TEXTO_ROTULO = ''
texto_rotulo_params = []
if str_texto and rotulo: if str_texto and rotulo:
AND_TEXTO_ROTULO = '''AND ( ({BUSCA_TEXTO} AND d.rotulo ~* '{BUSCA_ROTULO}') OR busca_texto = ' AND '.join(texto)
({BUSCA_TEXTO} AND d.rotulo = '' AND dp.rotulo ~* '{BUSCA_ROTULO}') AND_TEXTO_ROTULO = '''AND ( ({BUSCA_TEXTO} AND d.rotulo ~* %s) OR
({BUSCA_TEXTO} AND d.rotulo = '' AND dp.rotulo ~* %s)
)'''.format( )'''.format(
BUSCA_TEXTO=' AND '.join(texto), BUSCA_TEXTO=busca_texto,
BUSCA_ROTULO=rotulo
) )
texto_rotulo_params = (
texto_valores + [rotulo] + texto_valores + [rotulo])
elif str_texto: elif str_texto:
AND_TEXTO_ROTULO = ' AND %s' % ' AND '.join(texto) AND_TEXTO_ROTULO = ' AND ' + ' AND '.join(texto)
texto_rotulo_params = texto_valores
elif rotulo: elif rotulo:
AND_TEXTO_ROTULO = "AND d.rotulo ~* '{BUSCA_ROTULO}'".format( AND_TEXTO_ROTULO = "AND d.rotulo ~* %s"
BUSCA_ROTULO=rotulo) texto_rotulo_params = [rotulo]
else: else:
AND_TEXTO_ROTULO = '' AND_TEXTO_ROTULO = ''
@ -3288,17 +3292,21 @@ class DispositivoSearchFragmentFormView(ListView):
AND3_TIPO_TA="AND ta.tipo_ta_id = {}".format( AND3_TIPO_TA="AND ta.tipo_ta_id = {}".format(
tipo_ta.id) if tipo_ta else '', tipo_ta.id) if tipo_ta else '',
AND2_ANO="AND ta.ano = {}".format( AND2_ANO="AND ta.ano = %s" if ano_ta else '',
ano_ta) if ano_ta else '',
AND1_NUMERO="AND ta.numero ~* '{}'".format( AND1_NUMERO="AND ta.numero ~* %s" if num_ta else '',
num_ta) if num_ta else '',
AND_TEXTO_ROTULO=AND_TEXTO_ROTULO if AND_TEXTO_ROTULO else '', AND_TEXTO_ROTULO=AND_TEXTO_ROTULO if AND_TEXTO_ROTULO else '',
AND_CONTROLS=AND_CONTROLS if AND_CONTROLS else '' AND_CONTROLS=AND_CONTROLS if AND_CONTROLS else ''
) )
result = Dispositivo.objects.raw(sql) params = texto_rotulo_params.copy()
if num_ta:
params.append(num_ta)
if ano_ta:
params.append(ano_ta)
result = Dispositivo.objects.raw(sql, params)
r = [] r = []
ids = set() ids = set()

101
sapl/crud/base.py

@ -26,7 +26,11 @@ from sapl.crispy_layout_mixin import CrispyLayoutFormMixin, get_field_display
from sapl.crispy_layout_mixin import SaplFormHelper from sapl.crispy_layout_mixin import SaplFormHelper
from sapl.rules import (RP_ADD, RP_CHANGE, RP_DELETE, RP_DETAIL, from sapl.rules import (RP_ADD, RP_CHANGE, RP_DELETE, RP_DETAIL,
RP_LIST) RP_LIST)
from sapl.utils import normalize from sapl.settings import RATE_LIMITER_RATE
from sapl.utils import normalize, ratelimit_ip
from ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
logger = logging.getLogger(settings.BASE_DIR.name) logger = logging.getLogger(settings.BASE_DIR.name)
@ -101,7 +105,6 @@ variáveis do crud:
class SearchMixin(models.Model): class SearchMixin(models.Model):
search = models.TextField(blank=True, default='') search = models.TextField(blank=True, default='')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -238,7 +241,7 @@ class PermissionRequiredContainerCrudMixin(PermissionRequiredMixin):
@property @property
def container_field_set(self): def container_field_set(self):
if hasattr(self, 'crud') and\ if hasattr(self, 'crud') and \
not hasattr(self.crud, 'container_field_set'): not hasattr(self.crud, 'container_field_set'):
self.crud.container_field_set = '' self.crud.container_field_set = ''
if hasattr(self, 'crud'): if hasattr(self, 'crud'):
@ -267,12 +270,11 @@ class CrudBaseMixin(CrispyLayoutFormMixin):
obj.public = [] obj.public = []
if hasattr(self, 'permission_required') and self.permission_required: if hasattr(self, 'permission_required') and self.permission_required:
self.permission_required = tuple( self.permission_required = tuple(
( (
self.permission(pr) for pr in ( self.permission(pr) for pr in (
set(self.permission_required) - set(obj.public) set(self.permission_required) - set(obj.public)
) )
) )
) )
@ -337,9 +339,9 @@ class CrudBaseMixin(CrispyLayoutFormMixin):
if not obj.DetailView.permission_required: if not obj.DetailView.permission_required:
return self.resolve_url(ACTION_DETAIL, args=(self.object.id,)) return self.resolve_url(ACTION_DETAIL, args=(self.object.id,))
else: else:
return self.resolve_url(ACTION_DETAIL, args=(self.object.id,))\ return self.resolve_url(ACTION_DETAIL, args=(self.object.id,)) \
if self.request.user.has_perm( if self.request.user.has_perm(
self.permission(RP_DETAIL)) else '' self.permission(RP_DETAIL)) else ''
@property @property
def update_url(self): def update_url(self):
@ -347,9 +349,9 @@ class CrudBaseMixin(CrispyLayoutFormMixin):
if not obj.UpdateView.permission_required: if not obj.UpdateView.permission_required:
return self.resolve_url(ACTION_UPDATE, args=(self.object.id,)) return self.resolve_url(ACTION_UPDATE, args=(self.object.id,))
else: else:
return self.resolve_url(ACTION_UPDATE, args=(self.object.id,))\ return self.resolve_url(ACTION_UPDATE, args=(self.object.id,)) \
if self.request.user.has_perm( if self.request.user.has_perm(
self.permission(RP_CHANGE)) else '' self.permission(RP_CHANGE)) else ''
@property @property
def delete_url(self): def delete_url(self):
@ -357,9 +359,9 @@ class CrudBaseMixin(CrispyLayoutFormMixin):
if not obj.DeleteView.permission_required: if not obj.DeleteView.permission_required:
return self.resolve_url(ACTION_DELETE, args=(self.object.id,)) return self.resolve_url(ACTION_DELETE, args=(self.object.id,))
else: else:
return self.resolve_url(ACTION_DELETE, args=(self.object.id,))\ return self.resolve_url(ACTION_DELETE, args=(self.object.id,)) \
if self.request.user.has_perm( if self.request.user.has_perm(
self.permission(RP_DELETE)) else '' self.permission(RP_DELETE)) else ''
@property @property
def openapi_url(self): def openapi_url(self):
@ -388,6 +390,10 @@ class CrudBaseMixin(CrispyLayoutFormMixin):
return self.model._meta.verbose_name_plural return self.model._meta.verbose_name_plural
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class CrudListView(PermissionRequiredContainerCrudMixin, ListView): class CrudListView(PermissionRequiredContainerCrudMixin, ListView):
permission_required = (RP_LIST,) permission_required = (RP_LIST,)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -496,7 +502,7 @@ class CrudListView(PermissionRequiredContainerCrudMixin, ListView):
if m: if m:
ss = get_field_display(m, n[-1])[1] ss = get_field_display(m, n[-1])[1]
ss = ( ss = (
('<br>' if '<ul>' in ss else ' - ') + ss) \ ('<br>' if '<ul>' in ss else ' - ') + ss) \
if ss and j != 0 and s else ss if ss and j != 0 and s else ss
except: except:
pass pass
@ -516,7 +522,7 @@ class CrudListView(PermissionRequiredContainerCrudMixin, ListView):
um formulário de pesquisa herdado ou o próprio ListWithSearchForm. um formulário de pesquisa herdado ou o próprio ListWithSearchForm.
Só pode ser usado se o model relativo herdar de SearchMixin""" Só pode ser usado se o model relativo herdar de SearchMixin"""
if hasattr(self, 'form_search_class'): if hasattr(self, 'form_search_class'):
q = str(self.request.GET.get('q'))\ q = str(self.request.GET.get('q')) \
if 'q' in self.request.GET else '' if 'q' in self.request.GET else ''
o = self.request.GET['o'] if 'o' in self.request.GET else '1' o = self.request.GET['o'] if 'o' in self.request.GET else '1'
@ -551,13 +557,13 @@ class CrudListView(PermissionRequiredContainerCrudMixin, ListView):
if 'page' in qr: if 'page' in qr:
del qr['page'] del qr['page']
context['filter_url'] = ( context['filter_url'] = (
'&' + qr.urlencode()) if len(qr) > 0 else '' '&' + qr.urlencode()) if len(qr) > 0 else ''
if self.ordered_list: if self.ordered_list:
if 'o' in qr: if 'o' in qr:
del qr['o'] del qr['o']
context['ordering_url'] = ( context['ordering_url'] = (
'&' + qr.urlencode()) if len(qr) > 0 else '' '&' + qr.urlencode()) if len(qr) > 0 else ''
return context return context
def get_queryset(self): def get_queryset(self):
@ -612,7 +618,7 @@ class CrudListView(PermissionRequiredContainerCrudMixin, ListView):
) )
pass pass
if fm and hasattr(fm, 'related_model')\ if fm and hasattr(fm, 'related_model') \
and fm.related_model: and fm.related_model:
rmo = fm.related_model._meta.ordering rmo = fm.related_model._meta.ordering
if rmo: if rmo:
@ -710,7 +716,7 @@ class CrudCreateView(PermissionRequiredContainerCrudMixin,
'sem estar em um Container %s' 'sem estar em um Container %s'
) % container_model._meta.verbose_name) ) % container_model._meta.verbose_name)
if hasattr(self, 'crud') and\ if hasattr(self, 'crud') and \
hasattr(self.crud, 'is_m2m') and self.crud.is_m2m: hasattr(self.crud, 'is_m2m') and self.crud.is_m2m:
setattr( setattr(
self.object, container[1], getattr( self.object, container[1], getattr(
@ -724,9 +730,12 @@ class CrudCreateView(PermissionRequiredContainerCrudMixin,
return super().form_valid(form) return super().form_valid(form)
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class CrudDetailView(PermissionRequiredContainerCrudMixin, class CrudDetailView(PermissionRequiredContainerCrudMixin,
DetailView, MultipleObjectMixin): DetailView, MultipleObjectMixin):
permission_required = (RP_DETAIL,) permission_required = (RP_DETAIL,)
no_entries_msg = _('Nenhum registro Associado.') no_entries_msg = _('Nenhum registro Associado.')
paginate_by = 10 paginate_by = 10
@ -749,9 +758,9 @@ class CrudDetailView(PermissionRequiredContainerCrudMixin,
self.object, obj.model_set).model._meta.get_field( self.object, obj.model_set).model._meta.get_field(
fieldname).verbose_name fieldname).verbose_name
if hasattr(self.object, fieldname) else if hasattr(self.object, fieldname) else
getattr( getattr(
self.object, obj.model_set).model._meta.get_field( self.object, obj.model_set).model._meta.get_field(
fieldname).related_model._meta.verbose_name_plural) fieldname).related_model._meta.verbose_name_plural)
for fieldname in self.list_field_names_set] for fieldname in self.list_field_names_set]
except Exception as e: except Exception as e:
username = self.request.user.username username = self.request.user.username
@ -773,7 +782,7 @@ class CrudDetailView(PermissionRequiredContainerCrudMixin,
self.object, obj.model_set).model._meta.app_config.name self.object, obj.model_set).model._meta.app_config.name
return reverse('%s:%s' % ( return reverse('%s:%s' % (
namespace, self.url_model_set_name(suffix)), namespace, self.url_model_set_name(suffix)),
args=args) args=args)
def _as_row(self, obj): def _as_row(self, obj):
try: try:
@ -854,6 +863,11 @@ class CrudDetailView(PermissionRequiredContainerCrudMixin,
context[context_object_name] = self.object context[context_object_name] = self.object
context.update(kwargs) context.update(kwargs)
# Mensagem de atualidade (PNTP): data de última atualização da entidade,
# quando o modelo possuir o campo. Consumido pelo banner em base.html.
context['data_atualizacao_conteudo'] = getattr(
self.object, 'data_ultima_atualizacao', None)
return context return context
@property @property
@ -940,7 +954,7 @@ class CrudDeleteView(PermissionRequiredContainerCrudMixin,
username = request.user.username username = request.user.username
self.logger.error("user=" + username + ". Registro não pode ser removido, pois " self.logger.error("user=" + username + ". Registro não pode ser removido, pois "
"é referenciado por outros registros: " + error_msg2) "é referenciado por outros registros: " + error_msg2)
messages.add_message(request, messages.add_message(request,
messages.ERROR, messages.ERROR,
error_msg) error_msg)
@ -976,9 +990,8 @@ class Crud:
view.permission_required and \ view.permission_required and \
hasattr(cls, 'public') and \ hasattr(cls, 'public') and \
cls.public: cls.public:
# print(view.permission_required, view)
#print(view.permission_required, view) # print(cls.public, cls)
#print(cls.public, cls)
pr = pr - set(cls.public) pr = pr - set(cls.public)
@ -1036,7 +1049,6 @@ class Crud:
def build(cls, _model, _help_topic, _model_set=None, list_field_names=[]): def build(cls, _model, _help_topic, _model_set=None, list_field_names=[]):
def create_class(_list_field_names): def create_class(_list_field_names):
class ModelCrud(cls): class ModelCrud(cls):
model = _model model = _model
model_set = _model_set model_set = _model_set
@ -1080,7 +1092,6 @@ class CrudAux(Crud):
@classonlymethod @classonlymethod
def build(cls, _model, _help_topic, _model_set=None, list_field_names=[]): def build(cls, _model, _help_topic, _model_set=None, list_field_names=[]):
ModelCrud = Crud.build( ModelCrud = Crud.build(
_model, _help_topic, _model_set, list_field_names) _model, _help_topic, _model_set, list_field_names)
@ -1101,7 +1112,7 @@ class MasterDetailCrud(Crud):
obj = self.crud if hasattr(self, 'crud') else self obj = self.crud if hasattr(self, 'crud') else self
if not obj.ListView: if not obj.ListView:
return '' return ''
return self.resolve_url(ACTION_LIST, args=(self.kwargs['pk'],))\ return self.resolve_url(ACTION_LIST, args=(self.kwargs['pk'],)) \
if self.request.user.has_perm(self.permission(RP_LIST)) else '' if self.request.user.has_perm(self.permission(RP_LIST)) else ''
@property @property
@ -1109,7 +1120,7 @@ class MasterDetailCrud(Crud):
obj = self.crud if hasattr(self, 'crud') else self obj = self.crud if hasattr(self, 'crud') else self
if not obj.CreateView: if not obj.CreateView:
return '' return ''
return self.resolve_url(ACTION_CREATE, args=(self.kwargs['pk'],))\ return self.resolve_url(ACTION_CREATE, args=(self.kwargs['pk'],)) \
if self.request.user.has_perm(self.permission(RP_ADD)) else '' if self.request.user.has_perm(self.permission(RP_ADD)) else ''
@property @property
@ -1118,9 +1129,9 @@ class MasterDetailCrud(Crud):
if not obj.DetailView: if not obj.DetailView:
return '' return ''
pkk = self.request.GET['pkk'] if 'pkk' in self.request.GET else '' pkk = self.request.GET['pkk'] if 'pkk' in self.request.GET else ''
return (super().detail_url + (('?pkk=' + pkk) if pkk else ''))\ return (super().detail_url + (('?pkk=' + pkk) if pkk else '')) \
if self.request.user.has_perm( if self.request.user.has_perm(
self.permission(RP_DETAIL)) else '' self.permission(RP_DETAIL)) else ''
@property @property
def update_url(self): def update_url(self):
@ -1128,18 +1139,18 @@ class MasterDetailCrud(Crud):
if not obj.UpdateView: if not obj.UpdateView:
return '' return ''
pkk = self.request.GET['pkk'] if 'pkk' in self.request.GET else '' pkk = self.request.GET['pkk'] if 'pkk' in self.request.GET else ''
return (super().update_url + (('?pkk=' + pkk) if pkk else ''))\ return (super().update_url + (('?pkk=' + pkk) if pkk else '')) \
if self.request.user.has_perm( if self.request.user.has_perm(
self.permission(RP_CHANGE)) else '' self.permission(RP_CHANGE)) else ''
@property @property
def delete_url(self): def delete_url(self):
obj = self.crud if hasattr(self, 'crud') else self obj = self.crud if hasattr(self, 'crud') else self
if not obj.DeleteView: if not obj.DeleteView:
return '' return ''
return super().delete_url\ return super().delete_url \
if self.request.user.has_perm( if self.request.user.has_perm(
self.permission(RP_DELETE)) else '' self.permission(RP_DELETE)) else ''
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
obj = self.crud if hasattr(self, 'crud') else self obj = self.crud if hasattr(self, 'crud') else self
@ -1168,7 +1179,7 @@ class MasterDetailCrud(Crud):
root_pk = parent_object.pk root_pk = parent_object.pk
else: else:
root_pk = self.kwargs['pk'] if 'pkk' not in self.request.GET\ root_pk = self.kwargs['pk'] if 'pkk' not in self.request.GET \
else self.request.GET['pkk'] else self.request.GET['pkk']
kwargs.setdefault('root_pk', root_pk) kwargs.setdefault('root_pk', root_pk)
@ -1182,6 +1193,10 @@ class MasterDetailCrud(Crud):
context['title'] = title context['title'] = title
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class ListView(Crud.ListView): class ListView(Crud.ListView):
permission_required = RP_LIST, permission_required = RP_LIST,
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1410,10 +1425,14 @@ class MasterDetailCrud(Crud):
return reverse('%s:%s' % ( return reverse('%s:%s' % (
namespace, namespace,
'%s_%s' % (parent_object._meta.model_name, ACTION_DETAIL)), '%s_%s' % (parent_object._meta.model_name, ACTION_DETAIL)),
args=(pk,)) args=(pk,))
else: else:
return self.resolve_url(ACTION_LIST, args=(pk,)) return self.resolve_url(ACTION_LIST, args=(pk,))
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class DetailView(Crud.DetailView): class DetailView(Crud.DetailView):
permission_required = RP_DETAIL, permission_required = RP_DETAIL,
template_name = 'crud/detail_detail.html' template_name = 'crud/detail_detail.html'
@ -1429,7 +1448,7 @@ class MasterDetailCrud(Crud):
if not obj.ListView: if not obj.ListView:
return '' return ''
if obj.ListView.permission_required not in obj.public or\ if obj.ListView.permission_required not in obj.public or \
self.request.user.has_perm(self.permission(RP_LIST)): self.request.user.has_perm(self.permission(RP_LIST)):
if '__' in obj.parent_field: if '__' in obj.parent_field:
fields = obj.parent_field.split('__') fields = obj.parent_field.split('__')
@ -1499,9 +1518,9 @@ class MasterDetailCrud(Crud):
@property @property
def detail_set_create_url(self): def detail_set_create_url(self):
obj = self.crud if hasattr(self, 'crud') else self obj = self.crud if hasattr(self, 'crud') else self
if hasattr(obj, 'model_set') and obj.model_set\ if hasattr(obj, 'model_set') and obj.model_set \
and self.request.user.has_perm( and self.request.user.has_perm(
self.permission_set(RP_ADD)): self.permission_set(RP_ADD)):
root_pk = self.object.pk root_pk = self.object.pk
pk = root_pk pk = root_pk

36
sapl/health.py

@ -0,0 +1,36 @@
# core/health.py
import logging
import time
from typing import Tuple, Optional
from django.db import connection
from django.core.cache import cache
logger = logging.getLogger(__name__)
def check_app() -> Tuple[bool, Optional[str], float]:
t0 = time.monotonic()
return True, None, (time.monotonic() - t0) * 1000
def check_db() -> Tuple[bool, Optional[str], float]:
t0 = time.monotonic()
try:
with connection.cursor() as cur:
cur.execute("SELECT 1")
cur.fetchone()
return True, None, (time.monotonic() - t0) * 1000
except Exception as e:
logging.error(e)
return False, "An internal error has occurred!", (time.monotonic() - t0) * 1000
def check_cache() -> Tuple[bool, Optional[str], float]:
t0 = time.monotonic()
try:
cache.set("_hc", "1", 5)
ok = cache.get("_hc") == "1"
return ok, None if ok else "Cache get/set failed", (time.monotonic() - t0) * 1000
except Exception as e:
logging.error(e)
return False, "An internal error has occurred!", (time.monotonic() - t0) * 1000

2
sapl/lexml/forms.py

@ -31,7 +31,7 @@ class LexmlProvedorForm(ModelForm):
return cd return cd
if cd["xml"]: if cd["xml"]:
xml = re.sub("\n|\t", "", cd["xml"].strip()) xml = re.sub(r"\n|\t", "", cd["xml"].strip())
validar_xml(xml) validar_xml(xml)
validar_schema(xml) validar_schema(xml)

2
sapl/lexml/models.py

@ -25,7 +25,7 @@ class LexmlProvedor(models.Model): # LexmlRegistroProvedor
def pretty_xml(self): def pretty_xml(self):
import html import html
safe_xml = html.escape(self.xml) safe_xml = html.escape(self.xml)
return safe_xml.replace('\n', '<br/>').replace(' ', '&nbsp;') return safe_xml.replace('\\n', '<br/>').replace(' ', '&nbsp;')
class Meta: class Meta:
verbose_name = _('Provedor Lexml') verbose_name = _('Provedor Lexml')

21
sapl/logging/filters.py

@ -0,0 +1,21 @@
# sapl/logging/filters.py
import logging
import contextvars
_request_id = contextvars.ContextVar("request_id", default="-")
def set_request_id(value: str):
_request_id.set(value)
def get_request_id() -> str:
return _request_id.get()
class RequestIdFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
# garante que SEMPRE existe
if not hasattr(record, "request_id"):
record.request_id = get_request_id()
return True

51
sapl/materia/forms.py

@ -1722,7 +1722,7 @@ class TramitacaoEmLoteForm(ModelForm):
('texto', 12) ('texto', 12)
]) ])
documentos_checkbox_HTML = ''' documentos_checkbox_HTML = r'''
<br\><br\><br\> <br\><br\><br\>
<fieldset> <fieldset>
<legend style="font-size: 24px;">Selecione as matérias para tramitação:</legend> <legend style="font-size: 24px;">Selecione as matérias para tramitação:</legend>
@ -1950,10 +1950,6 @@ class ProposicaoForm(FileFieldCheckMixin, forms.ModelForm):
fields = [ fields = [
to_column((Fieldset( to_column((Fieldset(
TipoProposicao._meta.verbose_name, Field('tipo')), 12)), 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(('descricao', 12)),
to_column(('observacao', 12)), to_column(('observacao', 12)),
@ -2464,47 +2460,12 @@ class ConfirmarProposicaoForm(ProposicaoForm):
if self.instance.tipo.content_type.model_class( if self.instance.tipo.content_type.model_class(
) == TipoMateriaLegislativa: ) == TipoMateriaLegislativa:
numeracao = None
try:
self.logger.debug(
"Tentando obter modelo de sequência de numeração.")
numeracao = BaseAppConfig.objects.last(
).sequencia_numeracao_protocolo
except AttributeError as e:
self.logger.error("Erro ao obter modelo. " + str(e))
pass
tipo = self.instance.tipo.tipo_conteudo_related tipo = self.instance.tipo.tipo_conteudo_related
if tipo.sequencia_numeracao: max_numero, ano = MateriaLegislativa.get_proximo_numero(
numeracao = tipo.sequencia_numeracao tipo=tipo,
ano = timezone.now().year ano=None,
if numeracao == 'A': numero_candidato=cd.get('numero_materia_futuro', None)
numero = MateriaLegislativa.objects.filter( )
ano=ano, tipo=tipo).aggregate(Max('numero'))
elif numeracao == 'L':
legislatura = Legislatura.objects.filter(
data_inicio__year__lte=ano,
data_fim__year__gte=ano).first()
data_inicio = legislatura.data_inicio
data_fim = legislatura.data_fim
numero = MateriaLegislativa.objects.filter(
data_apresentacao__gte=data_inicio,
data_apresentacao__lte=data_fim,
tipo=tipo).aggregate(
Max('numero'))
elif numeracao == 'U':
numero = MateriaLegislativa.objects.filter(
tipo=tipo).aggregate(Max('numero'))
if numeracao is None:
numero['numero__max'] = 0
if cd['numero_materia_futuro'] and not MateriaLegislativa.objects.filter(tipo=tipo,
ano=ano,
numero=cd['numero_materia_futuro']):
max_numero = cd['numero_materia_futuro']
else:
max_numero = numero['numero__max'] + \
1 if numero['numero__max'] else 1
# dados básicos # dados básicos
materia = MateriaLegislativa() materia = MateriaLegislativa()

134
sapl/materia/models.py

@ -1,18 +1,21 @@
from datetime import datetime from datetime import datetime
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Max
from django.db.models.functions import Concat from django.db.models.functions import Concat
from django.template import defaultfilters from django.template import defaultfilters
from django.utils import formats, timezone from django.utils import formats, timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from model_utils import Choices from model_utils import Choices
from sapl.base.models import SEQUENCIA_NUMERACAO_PROTOCOLO, Autor
from sapl.base.models import SEQUENCIA_NUMERACAO_PROTOCOLO, Autor, AppConfig as BaseAppConfig
from sapl.comissoes.models import Comissao, Reuniao from sapl.comissoes.models import Comissao, Reuniao
from sapl.parlamentares.models import Legislatura
from sapl.compilacao.models import (PerfilEstruturalTextoArticulado, from sapl.compilacao.models import (PerfilEstruturalTextoArticulado,
TextoArticulado) TextoArticulado)
from sapl.parlamentares.models import Parlamentar from sapl.parlamentares.models import Parlamentar
@ -21,8 +24,7 @@ from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericForeignKey,
texto_upload_path, get_settings_auth_user_model, texto_upload_path, get_settings_auth_user_model,
OverwriteStorage) OverwriteStorage)
# from sapl.protocoloadm.models import Protocolo
#from sapl.protocoloadm.models import Protocolo
EM_TRAMITACAO = [(1, 'Sim'), EM_TRAMITACAO = [(1, 'Sim'),
(0, 'Não')] (0, 'Não')]
@ -185,7 +187,6 @@ def anexo_upload_path(instance, filename):
class MateriaLegislativa(models.Model): class MateriaLegislativa(models.Model):
tipo = models.ForeignKey( tipo = models.ForeignKey(
TipoMateriaLegislativa, TipoMateriaLegislativa,
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -282,7 +283,7 @@ class MateriaLegislativa(models.Model):
Autor, Autor,
through='Autoria', through='Autoria',
through_fields=('materia', 'autor'), through_fields=('materia', 'autor'),
symmetrical=False,) symmetrical=False, )
data_ultima_atualizacao = models.DateTimeField( data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, blank=True, null=True,
@ -325,7 +326,7 @@ class MateriaLegislativa(models.Model):
'numero': self.numero, 'numero': self.numero,
'data': defaultfilters.date( 'data': defaultfilters.date(
self.data_apresentacao, self.data_apresentacao,
"d \d\e F \d\e Y" r"d \d\e F \d\e Y"
)} )}
def data_entrada_protocolo(self): def data_entrada_protocolo(self):
@ -385,6 +386,106 @@ class MateriaLegislativa(models.Model):
using=using, using=using,
update_fields=update_fields) update_fields=update_fields)
@staticmethod
def get_proximo_numero(tipo, ano=None, numero_candidato=None):
"""
Retorna o próximo número disponível para uma MateriaLegislativa
baseado no tipo e nas configurações de numeração.
IMPORTANTE: Este método utiliza select_for_update() e DEVE ser
chamado dentro de uma transação (transaction.atomic) para garantir
proteção contra race conditions em acessos concorrentes.
Args:
tipo: TipoMateriaLegislativa ou int/str - o tipo da matéria
ano: int - o ano da matéria (default: ano atual)
numero_candidato: int - número candidato/desejado (opcional).
Se fornecido e disponível, será retornado. Caso contrário,
retorna o próximo sequencial.
Returns:
tuple[int, int]: Uma tupla contendo (numero, ano) da matéria.
"""
if ano is None:
ano = timezone.now().year
# Obtém a configuração de numeração
numeracao = None
try:
numeracao = BaseAppConfig.objects.last(
).sequencia_numeracao_protocolo
except AttributeError:
pass
if not isinstance(tipo, TipoMateriaLegislativa):
if tipo is None:
raise ValidationError(_("O tipo é obrigatório."))
try:
tipo_id = int(tipo)
except (ValueError, TypeError):
raise ValidationError(_("Tipo inválido: '%s'") % tipo)
try:
tipo = TipoMateriaLegislativa.objects.get(pk=tipo_id)
except TipoMateriaLegislativa.DoesNotExist:
raise TipoMateriaLegislativa.DoesNotExist(
_("TipoMateriaLegislativa with pk '%s' does not exist.") % tipo_id
)
# Lock na linha do TipoMateriaLegislativa para serializar
# gerações concorrentes de número do mesmo tipo.
# Requer que o chamador esteja dentro de transaction.atomic().
TipoMateriaLegislativa.objects.select_for_update().get(pk=tipo.pk)
# O tipo pode sobrescrever a configuração global
if tipo.sequencia_numeracao:
numeracao = tipo.sequencia_numeracao
# Calcula o próximo número baseado no tipo de numeração
materias_select_for_update = MateriaLegislativa.objects.select_for_update()
if numeracao == 'A': # Por ano
numero = materias_select_for_update.filter(
ano=ano, tipo=tipo).aggregate(Max('numero'))
elif numeracao == 'L': # Por legislatura
legislatura = Legislatura.objects.filter(
data_inicio__year__lte=ano,
data_fim__year__gte=ano).first()
if legislatura:
data_inicio = legislatura.data_inicio
data_fim = legislatura.data_fim
numero = materias_select_for_update.filter(
data_apresentacao__gte=data_inicio,
data_apresentacao__lte=data_fim,
tipo=tipo).aggregate(Max('numero'))
else:
numero = {'numero__max': 0}
elif numeracao == 'U': # Único/Universal
numero = materias_select_for_update.filter(
tipo=tipo).aggregate(Max('numero'))
else:
numero = {'numero__max': 0}
# Converte o número candidato para inteiro, se possível
numero_candidato_int = None
if numero_candidato is not None:
try:
numero_candidato_int = int(numero_candidato)
except (TypeError, ValueError):
numero_candidato_int = None
# Verifica se o número candidato está disponível
if numero_candidato_int is not None and not materias_select_for_update.filter(
tipo=tipo,
ano=ano,
numero=numero_candidato_int).exists():
return numero_candidato_int, ano
# Retorna o próximo número sequencial
max_numero = numero['numero__max']
return ((max_numero + 1) if max_numero else 1), ano
class Autoria(models.Model): class Autoria(models.Model):
autor = models.ForeignKey(Autor, autor = models.ForeignKey(Autor,
@ -400,7 +501,7 @@ class Autoria(models.Model):
class Meta: class Meta:
verbose_name = _('Autoria') verbose_name = _('Autoria')
verbose_name_plural = _('Autorias') verbose_name_plural = _('Autorias')
unique_together = (('autor', 'materia'), ) unique_together = (('autor', 'materia'),)
ordering = ('-primeiro_autor', 'autor__nome') ordering = ('-primeiro_autor', 'autor__nome')
def __str__(self): def __str__(self):
@ -456,9 +557,9 @@ class PautaReuniao(models.Model):
def __str__(self): def __str__(self):
return _('Reunião: %(reuniao)s' return _('Reunião: %(reuniao)s'
' - Matéria: %(materia)s') % { ' - Matéria: %(materia)s') % {
'reuniao': self.reuniao, 'reuniao': self.reuniao,
'materia': self.materia 'materia': self.materia
} }
class Anexada(models.Model): class Anexada(models.Model):
@ -482,8 +583,8 @@ class Anexada(models.Model):
def __str__(self): def __str__(self):
return _('Principal: %(materia_principal)s' return _('Principal: %(materia_principal)s'
' - Anexada: %(materia_anexada)s') % { ' - Anexada: %(materia_anexada)s') % {
'materia_principal': self.materia_principal, 'materia_principal': self.materia_principal,
'materia_anexada': self.materia_anexada} 'materia_anexada': self.materia_anexada}
class AssuntoMateria(models.Model): class AssuntoMateria(models.Model):
@ -760,7 +861,6 @@ class Parecer(models.Model):
class Proposicao(models.Model): class Proposicao(models.Model):
autor = models.ForeignKey( autor = models.ForeignKey(
Autor, Autor,
null=True, null=True,
@ -983,13 +1083,13 @@ class Proposicao(models.Model):
return '%s nº _____ %s' % ( return '%s nº _____ %s' % (
self.tipo, formats.date_format( self.tipo, formats.date_format(
self.data_envio if self.data_envio else timezone.now(), self.data_envio if self.data_envio else timezone.now(),
"\d\e d \d\e F \d\e Y")) r"\d\e d \d\e F \d\e Y"))
class Meta: class Meta:
ordering = ['-data_recebimento'] ordering = ['-data_recebimento']
verbose_name = _('Proposição') verbose_name = _('Proposição')
verbose_name_plural = _('Proposições') verbose_name_plural = _('Proposições')
unique_together = (('content_type', 'object_id'), ) unique_together = (('content_type', 'object_id'),)
permissions = ( permissions = (
('detail_proposicao_enviada', ('detail_proposicao_enviada',
_('Pode acessar detalhes de uma proposição enviada.')), _('Pode acessar detalhes de uma proposição enviada.')),
@ -1021,7 +1121,7 @@ class Proposicao(models.Model):
'numero': self.numero_proposicao, 'numero': self.numero_proposicao,
'data': defaultfilters.date( 'data': defaultfilters.date(
self.data_envio if self.data_envio else timezone.now(), self.data_envio if self.data_envio else timezone.now(),
"d \d\e F \d\e Y" r"d \d\e F \d\e Y"
)} )}
def delete(self, using=None, keep_parents=False): def delete(self, using=None, keep_parents=False):

385
sapl/materia/tests/test_materia_proximo_numero.py

@ -0,0 +1,385 @@
from datetime import date
import pytest
from django.db import IntegrityError, transaction
from model_bakery import baker
from rest_framework.test import APIClient
from sapl.base.models import AppConfig as BaseAppConfig
from sapl.materia.models import (MateriaLegislativa, RegimeTramitacao,
TipoMateriaLegislativa)
from sapl.parlamentares.models import Legislatura
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def tipo_materia(db):
"""TipoMateriaLegislativa sem sequencia_numeracao própria (usa global)."""
return baker.make(TipoMateriaLegislativa, descricao='Projeto de Lei',
sigla='PL', sequencia_numeracao='')
@pytest.fixture
def tipo_materia_anual(db):
return baker.make(TipoMateriaLegislativa, descricao='Requerimento',
sigla='REQ', sequencia_numeracao='A')
@pytest.fixture
def tipo_materia_unico(db):
return baker.make(TipoMateriaLegislativa, descricao='Indicação',
sigla='IND', sequencia_numeracao='U')
@pytest.fixture
def tipo_materia_legislatura(db):
return baker.make(TipoMateriaLegislativa, descricao='PEC',
sigla='PEC', sequencia_numeracao='L')
@pytest.fixture
def regime(db):
return baker.make(RegimeTramitacao, descricao='Normal')
@pytest.fixture
def app_config(db):
return BaseAppConfig.objects.create(sequencia_numeracao_protocolo='A')
def _criar_materia(tipo, numero, ano, regime, **kwargs):
"""Atalho para criar MateriaLegislativa com campos obrigatórios."""
defaults = dict(
tipo=tipo,
numero=numero,
ano=ano,
data_apresentacao=date(ano, 1, 1),
regime_tramitacao=regime,
ementa='Ementa de teste',
)
defaults.update(kwargs)
return MateriaLegislativa.objects.create(**defaults)
# ===========================================================================
# Testes de get_proximo_numero – numeração por ano (padrão)
# ===========================================================================
@pytest.mark.django_db(transaction=False)
def test_proximo_numero_primeiro_do_ano(tipo_materia_anual, regime, app_config):
"""Sem matérias existentes, o primeiro número gerado deve ser 1."""
with transaction.atomic():
numero, ano = MateriaLegislativa.get_proximo_numero(
tipo=tipo_materia_anual, ano=2024)
assert numero == 1
assert ano == 2024
@pytest.mark.django_db(transaction=False)
def test_proximo_numero_sequencial(tipo_materia_anual, regime, app_config):
"""Com matérias existentes 1, 2, 3 → próximo deve ser 4."""
for n in (1, 2, 3):
_criar_materia(tipo_materia_anual, n, 2024, regime)
with transaction.atomic():
numero, ano = MateriaLegislativa.get_proximo_numero(
tipo=tipo_materia_anual, ano=2024)
assert numero == 4
assert ano == 2024
@pytest.mark.django_db(transaction=False)
def test_proximo_numero_ignora_outro_ano(tipo_materia_anual, regime, app_config):
"""Matérias em anos diferentes não influenciam a sequência."""
_criar_materia(tipo_materia_anual, 10, 2023, regime,
data_apresentacao=date(2023, 6, 1))
_criar_materia(tipo_materia_anual, 1, 2024, regime)
with transaction.atomic():
numero, ano = MateriaLegislativa.get_proximo_numero(
tipo=tipo_materia_anual, ano=2024)
assert numero == 2
@pytest.mark.django_db(transaction=False)
def test_proximo_numero_ignora_outro_tipo(tipo_materia_anual, regime, app_config):
"""Matérias de outro tipo não influenciam a sequência."""
outro_tipo = baker.make(TipoMateriaLegislativa, descricao='Moção',
sigla='MOC', sequencia_numeracao='A')
_criar_materia(outro_tipo, 50, 2024, regime)
_criar_materia(tipo_materia_anual, 3, 2024, regime)
with transaction.atomic():
numero, _ = MateriaLegislativa.get_proximo_numero(
tipo=tipo_materia_anual, ano=2024)
assert numero == 4
# ===========================================================================
# Testes de get_proximo_numero – numeração única
# ===========================================================================
@pytest.mark.django_db(transaction=False)
def test_proximo_numero_unico(tipo_materia_unico, regime, app_config):
"""Numeração 'U' ignora o ano – sequência é global por tipo."""
_criar_materia(tipo_materia_unico, 1, 2022, regime,
data_apresentacao=date(2022, 1, 1))
_criar_materia(tipo_materia_unico, 2, 2023, regime,
data_apresentacao=date(2023, 1, 1))
with transaction.atomic():
numero, _ = MateriaLegislativa.get_proximo_numero(
tipo=tipo_materia_unico, ano=2024)
assert numero == 3
# ===========================================================================
# Testes de get_proximo_numero – numeração por legislatura
# ===========================================================================
@pytest.mark.django_db(transaction=False)
def test_proximo_numero_por_legislatura(tipo_materia_legislatura, regime,
app_config):
"""Numeração 'L' conta apenas matérias dentro da legislatura vigente."""
baker.make(Legislatura, numero=1,
data_inicio=date(2021, 1, 1),
data_fim=date(2024, 12, 31),
data_eleicao=date(2020, 11, 15))
_criar_materia(tipo_materia_legislatura, 1, 2022, regime,
data_apresentacao=date(2022, 3, 1))
_criar_materia(tipo_materia_legislatura, 2, 2023, regime,
data_apresentacao=date(2023, 5, 1))
with transaction.atomic():
numero, _ = MateriaLegislativa.get_proximo_numero(
tipo=tipo_materia_legislatura, ano=2024)
assert numero == 3
@pytest.mark.django_db(transaction=False)
def test_proximo_numero_legislatura_inexistente(tipo_materia_legislatura,
regime, app_config):
"""Sem legislatura vigente, número inicia em 1."""
with transaction.atomic():
numero, _ = MateriaLegislativa.get_proximo_numero(
tipo=tipo_materia_legislatura, ano=2050)
assert numero == 1
# ===========================================================================
# Testes de get_proximo_numero – numero_candidato
# ===========================================================================
@pytest.mark.django_db(transaction=False)
def test_numero_candidato_disponivel(tipo_materia_anual, regime, app_config):
"""Se o número candidato está disponível, deve ser retornado."""
_criar_materia(tipo_materia_anual, 1, 2024, regime)
with transaction.atomic():
numero, _ = MateriaLegislativa.get_proximo_numero(
tipo=tipo_materia_anual, ano=2024, numero_candidato=5)
assert numero == 5
@pytest.mark.django_db(transaction=False)
def test_numero_candidato_em_uso_retorna_sequencial(tipo_materia_anual,
regime, app_config):
"""Se o número candidato já existe, retorna o sequencial."""
_criar_materia(tipo_materia_anual, 5, 2024, regime)
with transaction.atomic():
numero, _ = MateriaLegislativa.get_proximo_numero(
tipo=tipo_materia_anual, ano=2024, numero_candidato=5)
assert numero == 6
# ===========================================================================
# Testes de get_proximo_numero – resolução de tipo por pk (int/str)
# ===========================================================================
@pytest.mark.django_db(transaction=False)
def test_tipo_passado_como_int(tipo_materia_anual, app_config):
"""O tipo pode ser passado como int (pk)."""
with transaction.atomic():
numero, _ = MateriaLegislativa.get_proximo_numero(
tipo=tipo_materia_anual.pk, ano=2024)
assert numero == 1
@pytest.mark.django_db(transaction=False)
def test_tipo_passado_como_str(tipo_materia_anual, app_config):
"""O tipo pode ser passado como str representando o pk."""
with transaction.atomic():
numero, _ = MateriaLegislativa.get_proximo_numero(
tipo=str(tipo_materia_anual.pk), ano=2024)
assert numero == 1
@pytest.mark.django_db(transaction=False)
def test_tipo_inexistente_levanta_excecao(app_config):
"""Pk inexistente deve levantar DoesNotExist."""
with pytest.raises(TipoMateriaLegislativa.DoesNotExist):
with transaction.atomic():
MateriaLegislativa.get_proximo_numero(tipo=99999, ano=2024)
@pytest.mark.django_db(transaction=False)
def test_tipo_invalido_levanta_validation_error(app_config):
"""Tipo não conversível para int deve levantar ValidationError."""
from django.core.exceptions import ValidationError
with pytest.raises(ValidationError):
with transaction.atomic():
MateriaLegislativa.get_proximo_numero(tipo='abc', ano=2024)
# ===========================================================================
# Testes de get_proximo_numero – ano padrão
# ===========================================================================
@pytest.mark.django_db(transaction=False)
def test_ano_none_usa_ano_atual(tipo_materia_anual, app_config):
"""Se ano=None, deve usar o ano corrente."""
from django.utils import timezone
with transaction.atomic():
_, ano = MateriaLegislativa.get_proximo_numero(
tipo=tipo_materia_anual, ano=None)
assert ano == timezone.now().year
# ===========================================================================
# Testes de get_proximo_numero – select_for_update (proteção concorrência)
# ===========================================================================
@pytest.mark.django_db(transaction=False)
def test_unique_together_protege_duplicata(tipo_materia_anual, regime,
app_config):
"""O unique_together (tipo, numero, ano) impede duplicatas no banco."""
_criar_materia(tipo_materia_anual, 1, 2024, regime)
with pytest.raises(IntegrityError):
with transaction.atomic():
_criar_materia(tipo_materia_anual, 1, 2024, regime)
# ===========================================================================
# Testes de global config – tipo sem sequencia_numeracao usa global
# ===========================================================================
@pytest.mark.django_db(transaction=False)
def test_tipo_sem_sequencia_usa_config_global(tipo_materia, regime, app_config):
"""Tipo com sequencia_numeracao vazio deve usar a config global ('A')."""
_criar_materia(tipo_materia, 1, 2024, regime)
_criar_materia(tipo_materia, 2, 2024, regime)
_criar_materia(tipo_materia, 10, 2023, regime,
data_apresentacao=date(2023, 1, 1))
with transaction.atomic():
numero, _ = MateriaLegislativa.get_proximo_numero(
tipo=tipo_materia, ano=2024)
# global default 'A' → sequencial por ano → MAX(2024) = 2 → próximo = 3
assert numero == 3
# ===========================================================================
# Testes do endpoint API create (MateriaLegislativaViewSet)
# ===========================================================================
@pytest.fixture
def api_client_autenticado(db):
"""APIClient autenticado como superusuário (tem todas as permissões)."""
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.create_superuser(
username='admin_api', password='secret123', email='a@b.com')
client = APIClient()
client.force_authenticate(user=user)
return client
@pytest.mark.django_db(transaction=False)
def test_api_create_sem_numero_auto_gera(api_client_autenticado,
tipo_materia_anual, regime,
app_config):
"""POST sem 'numero' deve auto-gerar próximo sequencial."""
_criar_materia(tipo_materia_anual, 1, 2024, regime)
response = api_client_autenticado.post(
'/api/materia/materialegislativa/',
{
'tipo': tipo_materia_anual.pk,
'ano': 2024,
'data_apresentacao': '2024-06-01',
'regime_tramitacao': regime.pk,
'ementa': 'Teste auto-gerar número',
},
format='json',
)
assert response.status_code == 201
assert response.data['numero'] == 2
@pytest.mark.django_db(transaction=False)
def test_api_create_com_numero_respeita_dado(api_client_autenticado,
tipo_materia_anual, regime,
app_config):
"""POST com 'numero' explícito deve criar com o número informado."""
response = api_client_autenticado.post(
'/api/materia/materialegislativa/',
{
'tipo': tipo_materia_anual.pk,
'numero': 42,
'ano': 2024,
'data_apresentacao': '2024-06-01',
'regime_tramitacao': regime.pk,
'ementa': 'Número exato',
},
format='json',
)
assert response.status_code == 201
assert response.data['numero'] == 42
@pytest.mark.django_db(transaction=False)
def test_api_create_numero_duplicado_retorna_erro(api_client_autenticado,
tipo_materia_anual, regime,
app_config):
"""POST com número já existente retorna erro.
O DRF detecta a violação de unique_together via UniqueTogetherValidator
no serializer e retorna 400 antes de chegar ao banco.
"""
_criar_materia(tipo_materia_anual, 42, 2024, regime)
response = api_client_autenticado.post(
'/api/materia/materialegislativa/',
{
'tipo': tipo_materia_anual.pk,
'numero': 42,
'ano': 2024,
'data_apresentacao': '2024-06-01',
'regime_tramitacao': regime.pk,
'ementa': 'Duplicata',
},
format='json',
)
assert response.status_code == 400
@pytest.mark.django_db(transaction=False)
def test_api_create_sem_tipo_valida_serializer(api_client_autenticado, regime):
"""POST sem 'tipo' deve falhar na validação do serializer (400)."""
response = api_client_autenticado.post(
'/api/materia/materialegislativa/',
{
'numero': 1,
'ano': 2024,
'data_apresentacao': '2024-06-01',
'regime_tramitacao': regime.pk,
'ementa': 'Sem tipo',
},
format='json',
)
assert response.status_code == 400

2
sapl/materia/urls.py

@ -143,7 +143,7 @@ urlpatterns_proposicao = [
url(r'^proposicao/devolvida/', ProposicaoDevolvida.as_view(), url(r'^proposicao/devolvida/', ProposicaoDevolvida.as_view(),
name='proposicao-devolvida'), name='proposicao-devolvida'),
url(r'^proposicao/confirmar/P(?P<hash>[0-9A-Fa-f]+)/' url(r'^proposicao/confirmar/P(?P<hash>[0-9A-Fa-f]+)/'
'(?P<pk>\d+)', ConfirmarProposicao.as_view(), r'(?P<pk>\d+)', ConfirmarProposicao.as_view(),
name='proposicao-confirmar'), name='proposicao-confirmar'),
url(r'^sistema/proposicao/tipo/', url(r'^sistema/proposicao/tipo/',
include(TipoProposicaoCrud.get_urls())), include(TipoProposicaoCrud.get_urls())),

106
sapl/materia/views.py

@ -16,6 +16,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import permission_required from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, ValidationError from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned, ValidationError
from django.db import transaction
from django.db.models import Max, Q from django.db.models import Max, Q
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
from django.http.response import Http404, HttpResponseRedirect from django.http.response import Http404, HttpResponseRedirect
@ -24,7 +25,6 @@ from django.shortcuts import render
from django.template import loader from django.template import loader
from django.urls import reverse from django.urls import reverse
from django.utils import formats, timezone from django.utils import formats, timezone
from django.utils.encoding import force_text
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.views.generic import CreateView, ListView, TemplateView, UpdateView from django.views.generic import CreateView, ListView, TemplateView, UpdateView
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
@ -32,6 +32,9 @@ from django.views.generic.edit import FormView
from django_filters.views import FilterView from django_filters.views import FilterView
import weasyprint import weasyprint
from ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
import sapl import sapl
from sapl.base.email_utils import do_envia_email_confirmacao from sapl.base.email_utils import do_envia_email_confirmacao
from sapl.base.models import Autor, CasaLegislativa, AppConfig as BaseAppConfig from sapl.base.models import Autor, CasaLegislativa, AppConfig as BaseAppConfig
@ -49,12 +52,12 @@ from sapl.materia.forms import (AnexadaForm, AutoriaForm, AutoriaMultiCreateForm
from sapl.norma.models import LegislacaoCitada from sapl.norma.models import LegislacaoCitada
from sapl.parlamentares.models import Legislatura from sapl.parlamentares.models import Legislatura
from sapl.protocoloadm.models import Protocolo from sapl.protocoloadm.models import Protocolo
from sapl.settings import MAX_DOC_UPLOAD_SIZE, MEDIA_ROOT from sapl.settings import MAX_DOC_UPLOAD_SIZE, MEDIA_ROOT, RATE_LIMITER_RATE
from sapl.utils import (autor_label, autor_modal, gerar_hash_arquivo, get_base_url, 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, get_client_ip, get_mime_type_from_file_extension, lista_anexados,
mail_service_configured, montar_row_autor, SEPARADOR_HASH_PROPOSICAO, mail_service_configured, montar_row_autor, SEPARADOR_HASH_PROPOSICAO,
show_results_filter_set, get_tempfile_dir, show_results_filter_set, get_tempfile_dir,
google_recaptcha_configured, MultiFormatOutputMixin) google_recaptcha_configured, MultiFormatOutputMixin, ratelimit_ip)
from .forms import (AcessorioEmLoteFilterSet, AcompanhamentoMateriaForm, from .forms import (AcessorioEmLoteFilterSet, AcompanhamentoMateriaForm,
AnexadaEmLoteFilterSet, AdicionarVariasAutoriasFilterSet, AnexadaEmLoteFilterSet, AdicionarVariasAutoriasFilterSet,
@ -132,6 +135,10 @@ def proposicao_texto(request, pk):
raise Http404 raise Http404
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class AdicionarVariasAutorias(PermissionRequiredForAppCrudMixin, FilterView): class AdicionarVariasAutorias(PermissionRequiredForAppCrudMixin, FilterView):
app_label = sapl.materia.apps.AppConfig.label app_label = sapl.materia.apps.AppConfig.label
filterset_class = AdicionarVariasAutoriasFilterSet filterset_class = AdicionarVariasAutoriasFilterSet
@ -331,56 +338,18 @@ class ProposicaoTaView(IntegracaoTaView):
else: else:
return self.get_redirect_deactivated() return self.get_redirect_deactivated()
@transaction.atomic
@permission_required('materia.detail_materialegislativa') @permission_required('materia.detail_materialegislativa')
def recuperar_materia(request): def recuperar_materia(request):
logger = logging.getLogger(__name__)
username = request.user.username
tipo = TipoMateriaLegislativa.objects.get(pk=request.GET['tipo']) tipo = TipoMateriaLegislativa.objects.get(pk=request.GET['tipo'])
ano = request.GET.get('ano', '') ano = request.GET.get('ano', None)
if not (tipo and ano):
return JsonResponse({'numero': '', 'ano': ''})
numeracao = None max_numero, ano = MateriaLegislativa.get_proximo_numero(
try: tipo=tipo,
logger.debug("user=" + username + ano=int(ano) if ano else None
". Tentando obter numeração da matéria.") )
numeracao = sapl.base.models.AppConfig.objects.last(
).sequencia_numeracao_protocolo
except AttributeError as e:
logger.error("user=" + username + ". " + str(e) +
" Numeracao da matéria definida como None.")
pass
if tipo.sequencia_numeracao:
numeracao = tipo.sequencia_numeracao
if numeracao == 'A':
numero = MateriaLegislativa.objects.filter(
ano=ano, tipo=tipo).aggregate(Max('numero'))
elif numeracao == 'L':
legislatura = Legislatura.objects.filter(
data_inicio__year__lte=ano,
data_fim__year__gte=ano).first()
data_inicio = legislatura.data_inicio
data_fim = legislatura.data_fim
numero = MateriaLegislativa.objects.filter(
data_apresentacao__gte=data_inicio,
data_apresentacao__lte=data_fim,
tipo=tipo).aggregate(
Max('numero'))
elif numeracao == 'U':
numero = MateriaLegislativa.objects.filter(
tipo=tipo).aggregate(Max('numero'))
if numeracao is None:
numero['numero__max'] = 0
max_numero = numero['numero__max'] + 1 if numero['numero__max'] else 1
response = JsonResponse({'numero': max_numero, 'ano': ano}) response = JsonResponse({'numero': max_numero, 'ano': ano})
return response return response
@ -392,6 +361,10 @@ class StatusTramitacaoCrud(CrudAux):
return reverse('sapl.materia:pesquisar_statustramitacao') return reverse('sapl.materia:pesquisar_statustramitacao')
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class PesquisarStatusTramitacaoView(FilterView): class PesquisarStatusTramitacaoView(FilterView):
model = StatusTramitacao model = StatusTramitacao
filterset_class = StatusTramitacaoFilterSet filterset_class = StatusTramitacaoFilterSet
@ -2080,27 +2053,38 @@ class AcompanhamentoExcluirView(TemplateView):
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView): class MateriaLegislativaPesquisaView(MultiFormatOutputMixin, FilterView):
model = MateriaLegislativa model = MateriaLegislativa
filterset_class = MateriaLegislativaFilterSet filterset_class = MateriaLegislativaFilterSet
paginate_by = 50 paginate_by = 50
fields_base_report = [ export_fields = [
'id', 'ano', 'numero', 'tipo__sigla', 'tipo__descricao', 'autoria__autor__nome', 'texto_original', 'ementa' 'id', 'ano', 'numero', 'tipo__sigla', 'tipo__descricao', 'autoria', 'texto_original', 'ementa'
] ]
fields_report = {
'csv': fields_base_report,
'xlsx': fields_base_report,
'json': fields_base_report,
}
def hook_texto_original(self, obj): def hook_texto_original(self, obj):
url = self.request.build_absolute_uri('/')[:-1] url = self.request.build_absolute_uri('/')[:-1]
texto_original = obj.texto_original if not isinstance( texto_original = obj.texto_original if not isinstance(
obj, dict) else obj["texto_original"] obj, dict) else obj["texto_original"]
return f'{url}/media/{texto_original}' return f'{url}/media/{texto_original}'
def hook_autoria(self, obj):
"""
Hook específico para pegar nomes dos autores (reverse query)
"""
try:
autores = [
str(autoria.autor.nome)
for autoria in obj.autoria_set.select_related('autor').all()
]
return ', '.join(autores)
except AttributeError:
return ''
def get_filterset_kwargs(self, filterset_class): def get_filterset_kwargs(self, filterset_class):
super().get_filterset_kwargs(filterset_class) super().get_filterset_kwargs(filterset_class)
@ -2334,6 +2318,10 @@ class AcompanhamentoMateriaView(CreateView):
kwargs={'pk': self.kwargs['pk']}) kwargs={'pk': self.kwargs['pk']})
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView): class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView):
filterset_class = AcessorioEmLoteFilterSet filterset_class = AcessorioEmLoteFilterSet
template_name = 'materia/em_lote/acessorio.html' template_name = 'materia/em_lote/acessorio.html'
@ -2446,6 +2434,10 @@ class DocumentoAcessorioEmLoteView(PermissionRequiredMixin, FilterView):
return self.get(request, self.kwargs) return self.get(request, self.kwargs)
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class MateriaAnexadaEmLoteView(PermissionRequiredMixin, FilterView): class MateriaAnexadaEmLoteView(PermissionRequiredMixin, FilterView):
filterset_class = AnexadaEmLoteFilterSet filterset_class = AnexadaEmLoteFilterSet
template_name = 'materia/em_lote/anexada.html' template_name = 'materia/em_lote/anexada.html'
@ -2570,6 +2562,10 @@ class MateriaAnexadaEmLoteView(PermissionRequiredMixin, FilterView):
return HttpResponseRedirect(success_url) return HttpResponseRedirect(success_url)
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class PrimeiraTramitacaoEmLoteView(PermissionRequiredMixin, FilterView): class PrimeiraTramitacaoEmLoteView(PermissionRequiredMixin, FilterView):
filterset_class = PrimeiraTramitacaoEmLoteFilterSet filterset_class = PrimeiraTramitacaoEmLoteFilterSet
template_name = 'materia/em_lote/tramitacao.html' template_name = 'materia/em_lote/tramitacao.html'

12
sapl/metrics.py

@ -0,0 +1,12 @@
from prometheus_client import CollectorRegistry, Gauge, generate_latest, CONTENT_TYPE_LATEST
def health_registry(check_results: dict) -> bytes:
"""
check_results: {"app": True/False, "db": True/False, ...}
"""
reg = CollectorRegistry()
g = Gauge("app_health", "1 if healthy, 0 if unhealthy", ["component"], registry=reg)
for comp, ok in check_results.items():
g.labels(component=comp).set(1 if ok else 0)
return generate_latest(reg)

0
sapl/middleware/__init__.py

3
sapl/middleware.py → sapl/middleware/check_password.py

@ -19,3 +19,6 @@ class CheckWeakPasswordMiddleware:
return redirect('sapl.base:alterar_senha') return redirect('sapl.base:alterar_senha')
return self.get_response(request) return self.get_response(request)

2
sapl/endpoint_restriction_middleware.py → sapl/middleware/endpoint_restriction.py

@ -18,7 +18,7 @@ ALLOWED_IPS = [
'ff00::/8' 'ff00::/8'
] ]
RESTRICTED_ENDPOINTS = ['/metrics'] RESTRICTED_ENDPOINTS = ['/metrics', '/health', '/ready', '/version']
class EndpointRestrictionMiddleware: class EndpointRestrictionMiddleware:

24
sapl/middleware/request_id.py

@ -0,0 +1,24 @@
import uuid
from sapl.logging.filters import set_request_id
HEADER_NAME = "HTTP_X_REQUEST_ID"
RESPONSE_HEADER = "X-Request-ID"
def _new_id():
return uuid.uuid4().hex
class RequestIdMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# recebe `request_id` do nginx ou do cliente senão cria um
request_id = request.META.get(HEADER_NAME) or _new_id()
request_id = str(request_id)[:64]
request.request_id = request_id
set_request_id(request_id)
response = self.get_response(request)
response[RESPONSE_HEADER] = request_id
return response

2
sapl/norma/forms.py

@ -224,7 +224,7 @@ class NormaJuridicaForm(FileFieldCheckMixin, ModelForm):
return cleaned_data return cleaned_data
import re import re
has_digits = re.sub('[^0-9]', '', cleaned_data['numero']) has_digits = re.sub(r'[^0-9]', '', cleaned_data['numero'])
if not has_digits: if not has_digits:
self.logger.error("Número de norma ({}) não pode conter somente letras.".format( self.logger.error("Número de norma ({}) não pode conter somente letras.".format(
cleaned_data['numero'])) cleaned_data['numero']))

4
sapl/norma/models.py

@ -263,7 +263,7 @@ class NormaJuridica(models.Model):
'tipo': self.tipo, 'tipo': self.tipo,
'orgao_sigla': f'-{self.orgao.sigla}' if self.orgao else '', 'orgao_sigla': f'-{self.orgao.sigla}' if self.orgao else '',
'numero': numero_norma, 'numero': numero_norma,
'data': defaultfilters.date(self.data, "d \d\e F \d\e Y").lower()} 'data': defaultfilters.date(self.data, r"d \d\e F \d\e Y").lower()}
@property @property
def epigrafe(self): def epigrafe(self):
@ -278,7 +278,7 @@ class NormaJuridica(models.Model):
return _('%(tipo)s nº %(numero)s, de %(data)s') % { return _('%(tipo)s nº %(numero)s, de %(data)s') % {
'tipo': self.tipo, 'tipo': self.tipo,
'numero': numero_norma, 'numero': numero_norma,
'data': defaultfilters.date(self.data, "d \d\e F \d\e Y").lower()} 'data': defaultfilters.date(self.data, r"d \d\e F \d\e Y").lower()}
def delete(self, using=None, keep_parents=False): def delete(self, using=None, keep_parents=False):
texto_integral = self.texto_integral texto_integral = self.texto_integral

26
sapl/norma/views.py

@ -19,6 +19,9 @@ from django.views.generic.edit import FormView
from django_filters.views import FilterView from django_filters.views import FilterView
import weasyprint import weasyprint
from ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
from sapl import settings from sapl import settings
import sapl import sapl
from sapl.base.models import AppConfig from sapl.base.models import AppConfig
@ -27,15 +30,15 @@ from sapl.compilacao.views import IntegracaoTaView
from sapl.crud.base import (RP_DETAIL, RP_LIST, Crud, CrudAux, from sapl.crud.base import (RP_DETAIL, RP_LIST, Crud, CrudAux,
MasterDetailCrud, make_pagination) MasterDetailCrud, make_pagination)
from sapl.materia.models import Orgao from sapl.materia.models import Orgao
from sapl.utils import show_results_filter_set, get_client_ip,\ from sapl.utils import show_results_filter_set, get_client_ip, \
sapn_is_enabled, MultiFormatOutputMixin sapn_is_enabled, MultiFormatOutputMixin, ratelimit_ip
from .forms import (AnexoNormaJuridicaForm, NormaFilterSet, NormaJuridicaForm, from .forms import (AnexoNormaJuridicaForm, NormaFilterSet, NormaJuridicaForm,
NormaPesquisaSimplesForm, NormaRelacionadaForm, NormaPesquisaSimplesForm, NormaRelacionadaForm,
AutoriaNormaForm, AssuntoNormaFilterSet) AutoriaNormaForm, AssuntoNormaFilterSet)
from .models import (AnexoNormaJuridica, AssuntoNorma, NormaJuridica, NormaRelacionada, from .models import (AnexoNormaJuridica, AssuntoNorma, NormaJuridica, NormaRelacionada,
TipoNormaJuridica, TipoVinculoNormaJuridica, AutoriaNorma, NormaEstatisticas) TipoNormaJuridica, TipoVinculoNormaJuridica, AutoriaNorma, NormaEstatisticas)
from ..settings import RATE_LIMITER_RATE
# LegislacaoCitadaCrud = Crud.build(LegislacaoCitada, '') # LegislacaoCitadaCrud = Crud.build(LegislacaoCitada, '')
TipoNormaCrud = CrudAux.build( TipoNormaCrud = CrudAux.build(
@ -57,6 +60,10 @@ class AssuntoNormaCrud(CrudAux):
return reverse('sapl.norma:pesquisar_assuntonorma') return reverse('sapl.norma:pesquisar_assuntonorma')
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class PesquisarAssuntoNormaView(FilterView): class PesquisarAssuntoNormaView(FilterView):
model = AssuntoNorma model = AssuntoNorma
filterset_class = AssuntoNormaFilterSet filterset_class = AssuntoNormaFilterSet
@ -147,19 +154,18 @@ class NormaRelacionadaCrud(MasterDetailCrud):
layout_key = 'NormaRelacionadaDetail' layout_key = 'NormaRelacionadaDetail'
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class NormaPesquisaView(MultiFormatOutputMixin, FilterView): class NormaPesquisaView(MultiFormatOutputMixin, FilterView):
model = NormaJuridica model = NormaJuridica
filterset_class = NormaFilterSet filterset_class = NormaFilterSet
paginate_by = 50 paginate_by = 50
fields_base_report = [ export_fields = [
'id', 'ano', 'numero', 'tipo__sigla', 'tipo__descricao', 'texto_integral', 'ementa' 'id', 'ano', 'numero', 'tipo__sigla', 'tipo__descricao', 'texto_integral', 'ementa'
] ]
fields_report = {
'csv': fields_base_report,
'xlsx': fields_base_report,
'json': fields_base_report,
}
def hook_texto_integral(self, obj): def hook_texto_integral(self, obj):
url = self.request.build_absolute_uri('/')[:-1] url = self.request.build_absolute_uri('/')[:-1]
@ -494,7 +500,7 @@ def recuperar_numero_norma(request):
norma = NormaJuridica.objects.filter(**param).order_by( norma = NormaJuridica.objects.filter(**param).order_by(
'tipo', 'ano', 'numero').values_list('numero', flat=True) 'tipo', 'ano', 'numero').values_list('numero', flat=True)
if norma: if norma:
numeros = sorted([int(re.sub("[^0-9].*", '', n)) for n in norma]) numeros = sorted([int(re.sub(r"[^0-9].*", '', n)) for n in norma])
next_num = numeros.pop() + 1 next_num = numeros.pop() + 1
response = JsonResponse({'numero': next_num, response = JsonResponse({'numero': next_num,
'ano': param['ano']}) 'ano': param['ano']})

161
sapl/painel/views.py

@ -3,8 +3,9 @@ import json
import logging import logging
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.decorators import (login_required, permission_required,
from django.core.exceptions import ObjectDoesNotExist user_passes_test)
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.urls import reverse from django.urls import reverse
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
@ -12,6 +13,7 @@ from django.http.response import Http404, HttpResponseRedirect
from django.shortcuts import render from django.shortcuts import render
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.views.decorators.cache import never_cache
from sapl.base.models import AppConfig as ConfiguracoesAplicacao from sapl.base.models import AppConfig as ConfiguracoesAplicacao
from sapl.base.models import CasaLegislativa from sapl.base.models import CasaLegislativa
@ -58,11 +60,11 @@ def votacao_aberta(request):
kwargs={'pk': v.id}), kwargs={'pk': v.id}),
v.__str__())) v.__str__()))
logger.info('user=' + username + '. Existe mais de uma votações aberta. Elas se encontram ' logger.info('user=' + username + '. Existe mais de uma votações aberta. Elas se encontram '
'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. ' 'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. '
'Para votar, peça para que o Operador feche-as.') 'Para votar, peça para que o Operador feche-as.')
msg = _('Existe mais de uma votações aberta. Elas se encontram ' msg = _('Existe mais de uma votações aberta. Elas se encontram '
'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. ' 'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. '
'Para votar, peça para que o Operador feche-as.') 'Para votar, peça para que o Operador feche-as.')
messages.add_message(request, messages.INFO, msg) messages.add_message(request, messages.INFO, msg)
return None, msg return None, msg
@ -78,9 +80,9 @@ def votacao_aberta(request):
if numero_materias_abertas > 1: if numero_materias_abertas > 1:
logger.info('user=' + username + '. Existe mais de uma votação aberta na Sessão: ' + logger.info('user=' + username + '. Existe mais de uma votação aberta na Sessão: ' +
('''<li><a href="%s">%s</a></li>''' % ( ('''<li><a href="%s">%s</a></li>''' % (
reverse('sapl.sessao:sessaoplenaria_detail', reverse('sapl.sessao:sessaoplenaria_detail',
kwargs={'pk': votacoes_abertas.first().id}), kwargs={'pk': votacoes_abertas.first().id}),
votacoes_abertas.first().__str__()))) votacoes_abertas.first().__str__())))
msg = _('Existe mais de uma votação aberta na Sessão: ' + msg = _('Existe mais de uma votação aberta na Sessão: ' +
('''<li><a href="%s">%s</a></li>''' % ( ('''<li><a href="%s">%s</a></li>''' % (
reverse('sapl.sessao:sessaoplenaria_detail', reverse('sapl.sessao:sessaoplenaria_detail',
@ -93,7 +95,7 @@ def votacao_aberta(request):
return votacoes_abertas.first(), None return votacoes_abertas.first(), None
def votacao(context,context_vars): def votacao(context, context_vars):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
parlamentar = context_vars['votante'].parlamentar parlamentar = context_vars['votante'].parlamentar
parlamentar_presente = False parlamentar_presente = False
@ -102,8 +104,8 @@ def votacao(context,context_vars):
context_vars.update({'parlamentar': parlamentar}) context_vars.update({'parlamentar': parlamentar})
else: else:
context.update({'error_message': context.update({'error_message':
'Não há presentes na Sessão com a ' 'Não há presentes na Sessão com a '
'matéria em votação.'}) 'matéria em votação.'})
if parlamentar_presente: if parlamentar_presente:
voto = [] voto = []
@ -116,23 +118,48 @@ def votacao(context,context_vars):
if voto: if voto:
try: try:
logger.debug("Tentando obter objeto VotoParlamentar com parlamentar={}.".format(context_vars['parlamentar'])) logger.debug(
"Tentando obter objeto VotoParlamentar com parlamentar={}.".format(context_vars['parlamentar']))
voto = voto.get(parlamentar=context_vars['parlamentar']) voto = voto.get(parlamentar=context_vars['parlamentar'])
context.update({'voto_parlamentar': voto.voto}) context.update({'voto_parlamentar': voto.voto})
except ObjectDoesNotExist: except ObjectDoesNotExist:
logger.error("Voto do parlamentar {} não computado.".format(context_vars['parlamentar'])) logger.error("Voto do parlamentar {} não computado.".format(context_vars['parlamentar']))
context.update( context.update(
{'voto_parlamentar': 'Voto não ' {'voto_parlamentar': 'Voto não '
'computado.'}) 'computado.'})
else: else:
logger.error("Parlamentar com id={} não está presente na " logger.error("Parlamentar com id={} não está presente na "
"Ordem do Dia/Expediente em votação.".format(parlamentar.id)) "Ordem do Dia/Expediente em votação.".format(parlamentar.id))
context.update({'error_message': context.update({'error_message':
'Você não está presente na ' 'Você não está presente na '
'Ordem do Dia/Expediente em votação.'}) 'Ordem do Dia/Expediente em votação.'})
return context, context_vars return context, context_vars
def sessao_votacao(context,context_vars):
@never_cache
@user_passes_test(check_permission)
def painel_view(request, pk):
logger = logging.getLogger(__name__)
utc_now = timezone.now()
local_now = timezone.localtime(utc_now)
utc_offset = int(local_now.utcoffset().total_seconds() / 60)
server_epoch_ms = int(utc_now.timestamp() * 1000)
logger.info(
"painel_view pk=%s utc_now=%s local_now=%s utc_offset=%s server_epoch_ms=%s",
pk, utc_now, local_now, utc_offset, server_epoch_ms
)
context = {'head_title': str(_('Painel Plenário')),
'sessao_id': pk,
'server_epoch_ms': server_epoch_ms,
'utc_offset': utc_offset,
}
return render(request, 'painel/index.html', context)
def sessao_votacao(context, context_vars):
pk = context_vars['sessao'].pk pk = context_vars['sessao'].pk
context.update({'sessao_id': pk}) context.update({'sessao_id': pk})
context.update({'sessao': context_vars['sessao'], context.update({'sessao': context_vars['sessao'],
@ -143,9 +170,9 @@ def sessao_votacao(context,context_vars):
presentes = [] presentes = []
ordem_dia = get_materia_aberta(pk) ordem_dia = get_materia_aberta(pk)
expediente = get_materia_expediente_aberta(pk) expediente = get_materia_expediente_aberta(pk)
errors_msgs = {'materia':'Não há nenhuma matéria aberta.', errors_msgs = {'materia': 'Não há nenhuma matéria aberta.',
'registro':'A votação para esta matéria já encerrou.', 'registro': 'A votação para esta matéria já encerrou.',
'tipo':'A matéria aberta não é do tipo votação nominal.'} 'tipo': 'A matéria aberta não é do tipo votação nominal.'}
materia_aberta = None materia_aberta = None
if ordem_dia: if ordem_dia:
@ -160,8 +187,8 @@ def sessao_votacao(context,context_vars):
'parlamentar_id', flat=True).distinct() 'parlamentar_id', flat=True).distinct()
context_vars.update({'ordem_dia': ordem_dia, context_vars.update({'ordem_dia': ordem_dia,
'expediente':expediente, 'expediente': expediente,
'presentes': presentes}) 'presentes': presentes})
# Verifica votação aberta # Verifica votação aberta
# Se aberta, verifica se é nominal. ID nominal == 2 # Se aberta, verifica se é nominal. ID nominal == 2
@ -188,7 +215,7 @@ def can_vote(context, context_vars, request):
# Pega sessão # Pega sessão
sessao, msg = votacao_aberta(request) sessao, msg = votacao_aberta(request)
context_vars.update({'sessao':sessao}) context_vars.update({'sessao': sessao})
if sessao and not msg: if sessao and not msg:
context, context_vars = sessao_votacao(context, context_vars) context, context_vars = sessao_votacao(context, context_vars)
elif not sessao and msg: elif not sessao and msg:
@ -199,51 +226,37 @@ def can_vote(context, context_vars, request):
return context, context_vars return context, context_vars
@login_required
@permission_required('parlamentares.can_vote', raise_exception=True)
def votante_view(request): def votante_view(request):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
username = request.user.username if request.user.is_authenticated else 'AnonymousUser' username = request.user.username
# Pega o votante relacionado ao usuário if not Votante.objects.filter(user=request.user).exists():
template_name = 'painel/voto_nominal.html' logger.warning(
context = {} f'user={username} sem cadastro de Votante tentou acessar /voto-individual/.'
context_vars = {} )
raise PermissionDenied
try: template_name = 'painel/voto_individual.html'
logger.debug(f'user={username}. Tentando obter objeto Votante com user={request.user}.')
if not request.user.is_anonymous and request.user.is_authenticated:
votante = Votante.objects.get(user=request.user)
else:
raise ObjectDoesNotExist
except ObjectDoesNotExist:
logger.error(f"user={username}. Usuário (user={request.user}) não cadastrado como votante na tela de parlamentares. "
"Contate a administração de sua Casa Legislativa!")
msg = _("Usuário não cadastrado como votante na tela de parlamentares. Contate a administração de sua Casa Legislativa!")
context.update({'error_message': msg})
return render(request, template_name, context)
context_vars = {'votante': votante}
context = {'head_title': str(_('Votação Individual'))} context = {'head_title': str(_('Votação Individual'))}
context_vars = {'votante': Votante.objects.get(user=request.user)}
# Verifica se usuário possui permissão para votar context, context_vars = can_vote(context, context_vars, request)
if 'parlamentares.can_vote' in request.user.get_all_permissions():
context, context_vars = can_vote(context, context_vars, request)
logger.debug("user=" + username + ". Verificando se usuário {} possui permissão para votar.".format(request.user))
else:
logger.error("user=" + username + ". Usuário {} sem permissão para votar.".format(request.user))
context.update({'permissao': False,
'error_message': 'Usuário sem permissão para votar.'})
# Salva o voto # Salva o voto
if request.method == 'POST': if request.method == 'POST':
if context_vars['ordem_dia']: if context_vars['ordem_dia']:
try: try:
logger.info("user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e ordem={}." logger.info("user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e "
"ordem={}. "
.format(context_vars['parlamentar'], context_vars['ordem_dia'])) .format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto = VotoParlamentar.objects.get( voto = VotoParlamentar.objects.get(
parlamentar=context_vars['parlamentar'], parlamentar=context_vars['parlamentar'],
ordem=context_vars['ordem_dia']) ordem=context_vars['ordem_dia'])
except ObjectDoesNotExist: except ObjectDoesNotExist:
logger.error("user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e ordem={}. Criando objeto." logger.error("user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e ordem={}. "
"Criando objeto. "
.format(context_vars['parlamentar'], context_vars['ordem_dia'])) .format(context_vars['parlamentar'], context_vars['ordem_dia']))
voto = VotoParlamentar.objects.create( voto = VotoParlamentar.objects.create(
parlamentar=context_vars['parlamentar'], parlamentar=context_vars['parlamentar'],
@ -261,14 +274,16 @@ def votante_view(request):
elif context_vars['expediente']: elif context_vars['expediente']:
try: try:
logger.info("user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e expediente={}." logger.info(
.format(context_vars['parlamentar'], context_vars['expediente'])) "user=" + username + ". Tentando obter objeto VotoParlamentar para parlamentar={} e expediente={}."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto = VotoParlamentar.objects.get( voto = VotoParlamentar.objects.get(
parlamentar=context_vars['parlamentar'], parlamentar=context_vars['parlamentar'],
expediente=context_vars['expediente']) expediente=context_vars['expediente'])
except ObjectDoesNotExist: except ObjectDoesNotExist:
logger.error("user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e expediente={}. Criando objeto." logger.error(
.format(context_vars['parlamentar'], context_vars['expediente'])) "user=" + username + ". Erro ao obter VotoParlamentar para parlamentar={} e expediente={}. Criando objeto."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto = VotoParlamentar.objects.create( voto = VotoParlamentar.objects.create(
parlamentar=context_vars['parlamentar'], parlamentar=context_vars['parlamentar'],
voto=request.POST['voto'], voto=request.POST['voto'],
@ -276,8 +291,9 @@ def votante_view(request):
ip=get_client_ip(request), ip=get_client_ip(request),
expediente=context_vars['expediente']) expediente=context_vars['expediente'])
else: else:
logger.info("user=" + username + ". VotoParlamentar para parlamentar={} e expediente={} obtido com sucesso." logger.info(
.format(context_vars['parlamentar'], context_vars['expediente'])) "user=" + username + ". VotoParlamentar para parlamentar={} e expediente={} obtido com sucesso."
.format(context_vars['parlamentar'], context_vars['expediente']))
voto.voto = request.POST['voto'] voto.voto = request.POST['voto']
voto.ip = get_client_ip(request) voto.ip = get_client_ip(request)
voto.user = request.user voto.user = request.user
@ -289,15 +305,6 @@ def votante_view(request):
return render(request, template_name, context) return render(request, template_name, context)
@user_passes_test(check_permission)
def painel_view(request, pk):
now = timezone.localtime(timezone.now())
utc_offset = now.utcoffset().total_seconds() / 60
context = {'head_title': str(_('Painel Plenário')), 'sessao_id': pk, 'utc_offset': utc_offset }
return render(request, 'painel/index.html', context)
@user_passes_test(check_permission) @user_passes_test(check_permission)
def switch_painel(request): def switch_painel(request):
sessao = SessaoPlenaria.objects.get(id=request.POST['pk_sessao']) sessao = SessaoPlenaria.objects.get(id=request.POST['pk_sessao'])
@ -374,7 +381,6 @@ def get_presentes(pk, response, materia):
oradores_list = [] oradores_list = []
for o in oradores: for o in oradores:
oradores_list.append( oradores_list.append(
{ {
'nome': o.parlamentar.nome_parlamentar, 'nome': o.parlamentar.nome_parlamentar,
@ -483,11 +489,11 @@ def get_votos(response, materia, mostrar_voto):
if tipo == 'ordem': if tipo == 'ordem':
votos_parlamentares = VotoParlamentar.objects.filter( votos_parlamentares = VotoParlamentar.objects.filter(
ordem_id=materia.id).order_by( ordem_id=materia.id).order_by(
'parlamentar__nome_parlamentar') 'parlamentar__nome_parlamentar')
else: else:
votos_parlamentares = VotoParlamentar.objects.filter( votos_parlamentares = VotoParlamentar.objects.filter(
expediente_id=materia.id).order_by( expediente_id=materia.id).order_by(
'parlamentar__nome_parlamentar') 'parlamentar__nome_parlamentar')
for i, p in enumerate(response['presentes']): for i, p in enumerate(response['presentes']):
try: try:
@ -520,7 +526,7 @@ def get_votos(response, materia, mostrar_voto):
if materia.tipo_votacao == 2: if materia.tipo_votacao == 2:
votos_parlamentares = VotoParlamentar.objects.filter( votos_parlamentares = VotoParlamentar.objects.filter(
votacao_id=registro.id).order_by( votacao_id=registro.id).order_by(
'parlamentar__nome_parlamentar') 'parlamentar__nome_parlamentar')
for i, p in enumerate(response['presentes']): for i, p in enumerate(response['presentes']):
try: try:
@ -528,7 +534,8 @@ def get_votos(response, materia, mostrar_voto):
response['presentes'][i]['voto'] = votos_parlamentares.get( response['presentes'][i]['voto'] = votos_parlamentares.get(
parlamentar_id=p['parlamentar_id']).voto parlamentar_id=p['parlamentar_id']).voto
except ObjectDoesNotExist: except ObjectDoesNotExist:
logger.error("Votos do parlamentar (id={}) não encontrados. Retornado None.".format(p['parlamentar_id'])) logger.error(
"Votos do parlamentar (id={}) não encontrados. Retornado None.".format(p['parlamentar_id']))
response['presentes'][i]['voto'] = None response['presentes'][i]['voto'] = None
response.update({ response.update({
@ -605,22 +612,22 @@ def get_dados_painel(request, pk):
ordem_expediente = last_ordem_voto.ordem ordem_expediente = last_ordem_voto.ordem
ultimo_timestamp = last_ordem_voto.data_hora ultimo_timestamp = last_ordem_voto.data_hora
if (last_expediente_voto and ultimo_timestamp and last_expediente_voto.data_hora > ultimo_timestamp) or \ if (last_expediente_voto and ultimo_timestamp and last_expediente_voto.data_hora > ultimo_timestamp) or \
(not ultimo_timestamp and last_expediente_voto): (not ultimo_timestamp and last_expediente_voto):
ordem_expediente = last_expediente_voto.expediente ordem_expediente = last_expediente_voto.expediente
ultimo_timestamp = last_expediente_voto.data_hora ultimo_timestamp = last_expediente_voto.data_hora
if (last_ordem_leitura and ultimo_timestamp and last_ordem_leitura.data_hora > ultimo_timestamp) or \ if (last_ordem_leitura and ultimo_timestamp and last_ordem_leitura.data_hora > ultimo_timestamp) or \
(not ultimo_timestamp and last_ordem_leitura): (not ultimo_timestamp and last_ordem_leitura):
ordem_expediente = last_ordem_leitura.ordem ordem_expediente = last_ordem_leitura.ordem
ultimo_timestamp = last_ordem_leitura.data_hora ultimo_timestamp = last_ordem_leitura.data_hora
if (last_expediente_leitura and ultimo_timestamp and last_expediente_leitura.data_hora > ultimo_timestamp) or \ if (last_expediente_leitura and ultimo_timestamp and last_expediente_leitura.data_hora > ultimo_timestamp) or \
(not ultimo_timestamp and last_expediente_leitura): (not ultimo_timestamp and last_expediente_leitura):
ordem_expediente = last_expediente_leitura.expediente ordem_expediente = last_expediente_leitura.expediente
ultimo_timestamp = last_expediente_leitura.data_hora ultimo_timestamp = last_expediente_leitura.data_hora
if ordem_expediente: if ordem_expediente:
return JsonResponse(get_votos( return JsonResponse(get_votos(
get_presentes(pk, response, ordem_expediente), get_presentes(pk, response, ordem_expediente),
ordem_expediente, app_config.mostrar_voto)) ordem_expediente, app_config.mostrar_voto))
# Retorna que não há nenhuma matéria já votada ou aberta # Retorna que não há nenhuma matéria já votada ou aberta
return response_nenhuma_materia(get_presentes(pk, response, None)) return response_nenhuma_materia(get_presentes(pk, response, None))

23
sapl/parlamentares/migrations/0045_auto_20251201_1531.py

@ -0,0 +1,23 @@
# Generated by Django 2.2.28 on 2025-12-01 18:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('parlamentares', '0044_adiciona_cargos_mesa'),
]
operations = [
migrations.AlterField(
model_name='parlamentar',
name='nome_completo',
field=models.CharField(max_length=80, verbose_name='Nome Completo'),
),
migrations.AlterField(
model_name='parlamentar',
name='nome_parlamentar',
field=models.CharField(max_length=80, verbose_name='Nome Parlamentar'),
),
]

18
sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py

@ -0,0 +1,18 @@
# Generated by Django 2.2.28 on 2026-06-26 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('parlamentares', '0045_auto_20251201_1531'),
]
operations = [
migrations.AddField(
model_name='parlamentar',
name='data_ultima_atualizacao',
field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'),
),
]

9
sapl/parlamentares/models.py

@ -4,7 +4,6 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from image_cropping.fields import ImageCropField, ImageRatioField from image_cropping.fields import ImageCropField, ImageRatioField
from model_utils import Choices from model_utils import Choices
from prompt_toolkit.key_binding.bindings.named_commands import self_insert
from sapl.base.models import Autor from sapl.base.models import Autor
from sapl.decorators import vigencia_atual from sapl.decorators import vigencia_atual
@ -194,6 +193,10 @@ class Parlamentar(models.Model):
SEXO_CHOICE = ((FEMININO, _('Feminino')), SEXO_CHOICE = ((FEMININO, _('Feminino')),
(MASCULINO, _('Masculino'))) (MASCULINO, _('Masculino')))
data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True,
verbose_name=_('Data da Última Atualização'))
nivel_instrucao = models.ForeignKey( nivel_instrucao = models.ForeignKey(
NivelInstrucao, NivelInstrucao,
blank=True, blank=True,
@ -207,9 +210,9 @@ class Parlamentar(models.Model):
on_delete=models.PROTECT, on_delete=models.PROTECT,
verbose_name=_('Situação Militar')) verbose_name=_('Situação Militar'))
nome_completo = models.CharField( nome_completo = models.CharField(
max_length=50, verbose_name=_('Nome Completo')) max_length=80, verbose_name=_('Nome Completo'))
nome_parlamentar = models.CharField( nome_parlamentar = models.CharField(
max_length=50, max_length=80,
verbose_name=_('Nome Parlamentar')) verbose_name=_('Nome Parlamentar'))
sexo = models.CharField( sexo = models.CharField(
max_length=1, verbose_name=_('Sexo'), choices=SEXO_CHOICE) max_length=1, verbose_name=_('Sexo'), choices=SEXO_CHOICE)

19
sapl/parlamentares/views.py

@ -33,7 +33,7 @@ from sapl.materia.models import Autoria, Proposicao, Relatoria
from sapl.norma.models import AutoriaNorma, NormaJuridica from sapl.norma.models import AutoriaNorma, NormaJuridica
from sapl.parlamentares.apps import AppConfig from sapl.parlamentares.apps import AppConfig
from sapl.rules import SAPL_GROUP_VOTANTE from sapl.rules import SAPL_GROUP_VOTANTE
from sapl.utils import (parlamentares_ativos, show_results_filter_set) from sapl.utils import (parlamentares_ativos, show_results_filter_set, ratelimit_ip)
from .forms import (ColigacaoFilterSet, FiliacaoForm, FrenteForm, LegislaturaForm, MandatoForm, from .forms import (ColigacaoFilterSet, FiliacaoForm, FrenteForm, LegislaturaForm, MandatoForm,
ParlamentarCreateForm, ParlamentarForm, VotanteForm, ParlamentarCreateForm, ParlamentarForm, VotanteForm,
@ -45,6 +45,11 @@ from .models import (CargoMesa, Coligacao, ComposicaoColigacao, ComposicaoMesa,
SituacaoMilitar, TipoAfastamento, TipoDependente, Votante, SituacaoMilitar, TipoAfastamento, TipoDependente, Votante,
Bloco, FrenteCargo, FrenteParlamentar, BlocoCargo, BlocoMembro, MesaDiretora) Bloco, FrenteCargo, FrenteParlamentar, BlocoCargo, BlocoMembro, MesaDiretora)
from ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
from ..settings import RATE_LIMITER_RATE
FrenteCargoCrud = CrudAux.build(FrenteCargo, 'frente_cargo') FrenteCargoCrud = CrudAux.build(FrenteCargo, 'frente_cargo')
BlocoCargoCrud = CrudAux.build(BlocoCargo, 'bloco_cargo') BlocoCargoCrud = CrudAux.build(BlocoCargo, 'bloco_cargo')
CargoMesaCrud = CrudAux.build(CargoMesa, 'cargo_mesa') CargoMesaCrud = CrudAux.build(CargoMesa, 'cargo_mesa')
@ -183,6 +188,10 @@ class ProposicaoParlamentarCrud(CrudBaseForListAndDetailExternalAppView):
_('Texto Eletrônico')) _('Texto Eletrônico'))
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class PesquisarParlamentarView(FilterView): class PesquisarParlamentarView(FilterView):
model = Parlamentar model = Parlamentar
filterset_class = ParlamentarFilterSet filterset_class = ParlamentarFilterSet
@ -245,6 +254,10 @@ class PesquisarParlamentarView(FilterView):
return self.render_to_response(context) return self.render_to_response(context)
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class PesquisarColigacaoView(FilterView): class PesquisarColigacaoView(FilterView):
model = Coligacao model = Coligacao
filterset_class = ColigacaoFilterSet filterset_class = ColigacaoFilterSet
@ -301,6 +314,10 @@ class PesquisarColigacaoView(FilterView):
return self.render_to_response(context) return self.render_to_response(context)
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class PesquisarPartidoView(FilterView): class PesquisarPartidoView(FilterView):
model = Partido model = Partido
filterset_class = PartidoFilterSet filterset_class = PartidoFilterSet

4
sapl/protocoloadm/forms.py

@ -1118,7 +1118,7 @@ class DocumentoAdministrativoForm(FileFieldCheckMixin, ModelForm):
numero_protocolo = self.data['numero_protocolo'] numero_protocolo = self.data['numero_protocolo']
ano_protocolo = self.data['ano_protocolo'] ano_protocolo = self.data['ano_protocolo']
complemento = re.sub('\s+', '', self.data['complemento']).upper() complemento = re.sub(r'\s+', '', self.data['complemento']).upper()
numero_documento = int(self.cleaned_data['numero']) numero_documento = int(self.cleaned_data['numero'])
tipo_documento = int(self.data['tipo']) tipo_documento = int(self.data['tipo'])
ano_documento = int(self.data['ano']) ano_documento = int(self.data['ano'])
@ -1566,7 +1566,7 @@ class TramitacaoEmLoteAdmForm(ModelForm):
('texto', 12) ('texto', 12)
]) ])
documentos_checkbox_HTML = ''' documentos_checkbox_HTML = r'''
<br\><br\><br\> <br\><br\><br\>
<fieldset> <fieldset>
<legend style="font-size: 24px;">Selecione os documentos para tramitação:</legend> <legend style="font-size: 24px;">Selecione os documentos para tramitação:</legend>

28
sapl/protocoloadm/migrations/0046_auto_20260626_1220.py

@ -0,0 +1,28 @@
# Generated by Django 2.2.28 on 2026-06-26 15:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('protocoloadm', '0045_auto_20240711_1405'),
]
operations = [
migrations.AddField(
model_name='documentoadministrativo',
name='data_ultima_atualizacao',
field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'),
),
migrations.AlterField(
model_name='documentoadministrativo',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'),
),
migrations.AlterField(
model_name='protocolo',
name='ano',
field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano do Protocolo'),
),
]

3
sapl/protocoloadm/models.py

@ -158,6 +158,9 @@ class DocumentoAdministrativo(models.Model):
tipo = models.ForeignKey( tipo = models.ForeignKey(
TipoDocumentoAdministrativo, on_delete=models.PROTECT, TipoDocumentoAdministrativo, on_delete=models.PROTECT,
verbose_name=_('Tipo Documento')) verbose_name=_('Tipo Documento'))
data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True,
verbose_name=_('Data da Última Atualização'))
numero = models.PositiveIntegerField(verbose_name=_('Número')) numero = models.PositiveIntegerField(verbose_name=_('Número'))
complemento = models.CharField(max_length=10, blank=True, complemento = models.CharField(max_length=10, blank=True,

39
sapl/protocoloadm/views.py

@ -47,7 +47,7 @@ from sapl.relatorios.views import relatorio_doc_administrativos
from sapl.utils import (create_barcode, get_base_url, get_client_ip, from sapl.utils import (create_barcode, get_base_url, get_client_ip,
get_mime_type_from_file_extension, lista_anexados, get_mime_type_from_file_extension, lista_anexados,
show_results_filter_set, mail_service_configured, from_date_to_datetime_utc, show_results_filter_set, mail_service_configured, from_date_to_datetime_utc,
google_recaptcha_configured, get_tempfile_dir, MultiFormatOutputMixin) google_recaptcha_configured, get_tempfile_dir, MultiFormatOutputMixin, ratelimit_ip)
from .forms import (AcompanhamentoDocumentoForm, AnexadoEmLoteFilterSet, AnexadoForm, from .forms import (AcompanhamentoDocumentoForm, AnexadoEmLoteFilterSet, AnexadoForm,
AnularProtocoloAdmForm, compara_tramitacoes_doc, AnularProtocoloAdmForm, compara_tramitacoes_doc,
@ -62,7 +62,10 @@ from .forms import (AcompanhamentoDocumentoForm, AnexadoEmLoteFilterSet, Anexado
from .models import (Anexado, AcompanhamentoDocumento, DocumentoAcessorioAdministrativo, from .models import (Anexado, AcompanhamentoDocumento, DocumentoAcessorioAdministrativo,
DocumentoAdministrativo, StatusTramitacaoAdministrativo, DocumentoAdministrativo, StatusTramitacaoAdministrativo,
TipoDocumentoAdministrativo, TramitacaoAdministrativo) TipoDocumentoAdministrativo, TramitacaoAdministrativo)
from ..settings import MEDIA_ROOT from ..settings import MEDIA_ROOT, RATE_LIMITER_RATE
from ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
TipoDocumentoAdministrativoCrud = CrudAux.build( TipoDocumentoAdministrativoCrud = CrudAux.build(
TipoDocumentoAdministrativo, '') TipoDocumentoAdministrativo, '')
@ -453,7 +456,7 @@ class DocumentoAdministrativoCrud(Crud):
def form_valid(self, form): def form_valid(self, form):
form.instance.complemento = re.sub( form.instance.complemento = re.sub(
'\s+', '', form.instance.complemento).upper() r'\s+', '', form.instance.complemento).upper()
return super().form_valid(form) return super().form_valid(form)
class UpdateView(Crud.UpdateView): class UpdateView(Crud.UpdateView):
@ -486,7 +489,7 @@ class DocumentoAdministrativoCrud(Crud):
break break
form.instance.complemento = re.sub( form.instance.complemento = re.sub(
'\s+', '', form.instance.complemento).upper() r'\s+', '', form.instance.complemento).upper()
return super().form_valid(form) return super().form_valid(form)
@ -540,6 +543,10 @@ class StatusTramitacaoAdministrativoCrud(CrudAux):
ordering = 'sigla' ordering = 'sigla'
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class ProtocoloPesquisaView(PermissionRequiredMixin, FilterView): class ProtocoloPesquisaView(PermissionRequiredMixin, FilterView):
model = Protocolo model = Protocolo
filterset_class = ProtocoloFilterSet filterset_class = ProtocoloFilterSet
@ -997,7 +1004,6 @@ class ProtocoloMateriaView(PermissionRequiredMixin, CreateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(CreateView, self).get_context_data(**kwargs) context = super(CreateView, self).get_context_data(**kwargs)
autores_ativos = self.autores_ativos() autores_ativos = self.autores_ativos()
autores = [] autores = []
autores.append(['0', '------']) autores.append(['0', '------'])
for a in autores_ativos: for a in autores_ativos:
@ -1038,6 +1044,10 @@ class ProtocoloMateriaTemplateView(PermissionRequiredMixin, TemplateView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class PesquisarDocumentoAdministrativoView(DocumentoAdministrativoMixin, class PesquisarDocumentoAdministrativoView(DocumentoAdministrativoMixin,
MultiFormatOutputMixin, MultiFormatOutputMixin,
PermissionRequiredMixin, PermissionRequiredMixin,
@ -1047,14 +1057,9 @@ class PesquisarDocumentoAdministrativoView(DocumentoAdministrativoMixin,
paginate_by = 10 paginate_by = 10
permission_required = ('protocoloadm.list_documentoadministrativo', ) permission_required = ('protocoloadm.list_documentoadministrativo', )
fields_base_report = [ export_fields = [
'id', 'ano', 'numero', 'tipo__sigla', 'tipo__descricao', 'assunto' 'id', 'ano', 'numero', 'tipo__sigla', 'tipo__descricao', 'assunto'
] ]
fields_report = {
'csv': fields_base_report,
'xlsx': fields_base_report,
'json': fields_base_report,
}
def get_filterset_kwargs(self, filterset_class): def get_filterset_kwargs(self, filterset_class):
super(PesquisarDocumentoAdministrativoView, super(PesquisarDocumentoAdministrativoView,
@ -1176,6 +1181,10 @@ class AnexadoCrud(MasterDetailCrud):
return 'AnexadoDetail' return 'AnexadoDetail'
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class DocumentoAnexadoEmLoteView(PermissionRequiredMixin, FilterView): class DocumentoAnexadoEmLoteView(PermissionRequiredMixin, FilterView):
filterset_class = AnexadoEmLoteFilterSet filterset_class = AnexadoEmLoteFilterSet
template_name = 'protocoloadm/em_lote/anexado.html' template_name = 'protocoloadm/em_lote/anexado.html'
@ -1689,6 +1698,10 @@ class FichaSelecionaAdmView(PermissionRequiredMixin, FormView):
'materia/impressos/ficha_adm_pdf.html') 'materia/impressos/ficha_adm_pdf.html')
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class PrimeiraTramitacaoEmLoteAdmView(PermissionRequiredMixin, FilterView): class PrimeiraTramitacaoEmLoteAdmView(PermissionRequiredMixin, FilterView):
filterset_class = PrimeiraTramitacaoEmLoteAdmFilterSet filterset_class = PrimeiraTramitacaoEmLoteAdmFilterSet
template_name = 'protocoloadm/em_lote/tramitacaoadm.html' template_name = 'protocoloadm/em_lote/tramitacaoadm.html'
@ -1925,6 +1938,10 @@ class VinculoDocAdminMateriaCrud(MasterDetailCrud):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class VinculoDocAdminMateriaEmLoteView(PermissionRequiredMixin, FilterView): class VinculoDocAdminMateriaEmLoteView(PermissionRequiredMixin, FilterView):
filterset_class = VinculoDocAdminMateriaEmLoteFilterSet filterset_class = VinculoDocAdminMateriaEmLoteFilterSet
template_name = 'protocoloadm/em_lote/vinculodocadminmateria.html' template_name = 'protocoloadm/em_lote/vinculodocadminmateria.html'

79
sapl/relatorios/forms.py

@ -3,17 +3,19 @@ from crispy_forms.bootstrap import (FormActions)
from crispy_forms.layout import (HTML, Button, Fieldset, from crispy_forms.layout import (HTML, Button, Fieldset,
Layout, Submit) Layout, Submit)
from django import forms from django import forms
from django.forms import ModelChoiceField
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.db.models import Q
from sapl.audiencia.models import AudienciaPublica from sapl.audiencia.models import AudienciaPublica
from sapl.base.models import Autor from sapl.base.models import Autor
from sapl.comissoes.models import Reuniao from sapl.comissoes.models import Reuniao
from sapl.crispy_layout_mixin import SaplFormHelper, to_row, form_actions from sapl.crispy_layout_mixin import SaplFormHelper, to_row, form_actions
from sapl.materia.models import DocumentoAcessorio, MateriaLegislativa, MateriaEmTramitacao, UnidadeTramitacao, \ from sapl.materia.models import DocumentoAcessorio, MateriaLegislativa, MateriaEmTramitacao, UnidadeTramitacao, \
StatusTramitacao StatusTramitacao, TipoMateriaLegislativa
from sapl.norma.models import NormaJuridica from sapl.norma.models import NormaJuridica
from sapl.protocoloadm.models import DocumentoAdministrativo from sapl.protocoloadm.models import DocumentoAdministrativo
from sapl.sessao.models import SessaoPlenaria from sapl.sessao.models import SessaoPlenaria, VotoParlamentar, RegistroVotacao
from sapl.utils import FilterOverridesMetaMixin, choice_anos_com_normas, qs_override_django_filter, \ from sapl.utils import FilterOverridesMetaMixin, choice_anos_com_normas, qs_override_django_filter, \
choice_anos_com_materias, choice_tipos_normas, autor_label, autor_modal choice_anos_com_materias, choice_tipos_normas, autor_label, autor_modal
@ -68,6 +70,70 @@ class RelatorioDocumentosAcessoriosFilterSet(django_filters.FilterSet):
) )
class RelatorioVotacoesNominaisFilterSet(django_filters.FilterSet):
def ordem_or_expediente(self, queryset, name, value):
if value is None:
return queryset
value = getattr(value, "pk", value)
ordem_q = f"ordem__materia__{name}"
expediente_q = f"expediente__materia__{name}"
return queryset.filter(Q(**{ordem_q: value}) | Q(**{expediente_q: value}))
tipo_id = django_filters.ModelChoiceFilter(
queryset=TipoMateriaLegislativa.objects.all(),
method='ordem_or_expediente',
label='Tipo de Matéria',
empty_label="---------"
)
numero = django_filters.NumberFilter(
widget=forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
method='ordem_or_expediente',
label='Número'
)
ano = django_filters.ChoiceFilter(
choices=list(choice_anos_com_materias()),
widget=forms.Select(attrs={'class': 'form-control'}),
method='ordem_or_expediente',
label='Ano da Matéria'
)
class Meta(FilterOverridesMetaMixin):
model = RegistroVotacao
fields = ['data_hora']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.filters['data_hora'].label = 'Período (Data Inicial - Data Final)'
row0 = to_row([('tipo_id', 6), ('numero', 3), ('ano', 3)])
row1 = to_row([('data_hora', 12)])
buttons = FormActions(
*[
HTML("""
<div class="form-check">
<input name="relatorio" type="checkbox" class="form-check-input" id="relatorio">
<label class="form-check-label" for="relatorio">Gerar relatório PDF</label>
</div>
""")
],
Submit('pesquisar', _('Pesquisar'), css_class='float-right',
onclick='return true;'),
css_class='form-group row justify-content-between',
)
self.form.helper = SaplFormHelper()
self.form.helper.form_method = 'GET'
self.form.helper.layout = Layout(
Fieldset(_('Pesquisa'),
row0, row1,
buttons)
)
class RelatorioAtasFilterSet(django_filters.FilterSet): class RelatorioAtasFilterSet(django_filters.FilterSet):
class Meta(FilterOverridesMetaMixin): class Meta(FilterOverridesMetaMixin):
model = SessaoPlenaria model = SessaoPlenaria
@ -313,7 +379,8 @@ class RelatorioDataFimPrazoTramitacaoFilterSet(django_filters.FilterSet):
@property @property
def qs(self): def qs(self):
parent = super(RelatorioDataFimPrazoTramitacaoFilterSet, self).qs parent = super(RelatorioDataFimPrazoTramitacaoFilterSet, self).qs
return parent.distinct().prefetch_related('materia__tipo').order_by('tramitacao__data_fim_prazo', 'materia__tipo', 'materia__numero') return parent.distinct().prefetch_related('materia__tipo').order_by('tramitacao__data_fim_prazo',
'materia__tipo', 'materia__numero')
class Meta(FilterOverridesMetaMixin): class Meta(FilterOverridesMetaMixin):
model = MateriaEmTramitacao model = MateriaEmTramitacao
@ -566,6 +633,8 @@ class RelatorioMateriasPorAutorFilterSet(django_filters.FilterSet):
@property @property
def qs(self): def qs(self):
parent = super().qs parent = super().qs
if not self.data.get('autoria__autor'):
return parent.none()
return parent.distinct().order_by('-ano', '-numero', 'tipo', 'autoria__autor', '-autoria__primeiro_autor') return parent.distinct().order_by('-ano', '-numero', 'tipo', 'autoria__autor', '-autoria__primeiro_autor')
class Meta(FilterOverridesMetaMixin): class Meta(FilterOverridesMetaMixin):
@ -677,7 +746,9 @@ class RelatorioNormasPorAutorFilterSet(django_filters.FilterSet):
@property @property
def qs(self): def qs(self):
parent = super().qs parent = super().qs
return parent.distinct().filter(autorianorma__primeiro_autor=True) \ if not self.data.get('autorianorma__autor'):
return parent.none()
return parent.distinct() \
.order_by('autorianorma__autor', '-autorianorma__primeiro_autor', 'tipo', '-ano', '-numero') .order_by('autorianorma__autor', '-autorianorma__primeiro_autor', 'tipo', '-ano', '-numero')
class Meta(FilterOverridesMetaMixin): class Meta(FilterOverridesMetaMixin):

53
sapl/relatorios/tests.py

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

6
sapl/relatorios/urls.py

@ -11,7 +11,7 @@ from .views import (relatorio_capa_processo,
RelatorioMateriasTramitacaoView, RelatorioMateriaAnoAssuntoView, RelatorioHistoricoTramitacaoView, RelatorioMateriasTramitacaoView, RelatorioMateriaAnoAssuntoView, RelatorioHistoricoTramitacaoView,
RelatorioDataFimPrazoTramitacaoView, RelatorioPresencaSessaoView, RelatorioAtasView, RelatorioDataFimPrazoTramitacaoView, RelatorioPresencaSessaoView, RelatorioAtasView,
RelatorioReuniaoView, RelatorioAudienciaView, RelatorioHistoricoTramitacaoAdmView, RelatorioReuniaoView, RelatorioAudienciaView, RelatorioHistoricoTramitacaoAdmView,
RelatorioDocumentosAcessoriosView, RelatorioNormasPorAutorView) RelatorioDocumentosAcessoriosView, RelatorioNormasPorAutorView, RelatorioVotacoesNominaisView)
from ..base.views import EstatisticasAcessoNormas from ..base.views import EstatisticasAcessoNormas
app_name = AppConfig.name app_name = AppConfig.name
@ -95,6 +95,10 @@ urlpatterns = [
url(r'^sistema/relatorios/documentos_acessorios$', url(r'^sistema/relatorios/documentos_acessorios$',
RelatorioDocumentosAcessoriosView.as_view(), RelatorioDocumentosAcessoriosView.as_view(),
name='relatorio_documentos_acessorios'), name='relatorio_documentos_acessorios'),
url(r'^sistema/relatorios/votacoes_nominais$',
RelatorioVotacoesNominaisView.as_view(),
name='relatorio_votacoes_nominais'),
url(r'^sistema/relatorios/normas-por-autor$', url(r'^sistema/relatorios/normas-por-autor$',
RelatorioNormasPorAutorView.as_view(), name='normas_por_autor'), RelatorioNormasPorAutorView.as_view(), name='normas_por_autor'),
] ]

197
sapl/relatorios/views.py

@ -31,7 +31,8 @@ from sapl.relatorios.forms import RelatorioNormasPorAutorFilterSet, RelatorioHis
RelatorioNormasVigenciaFilterSet, RelatorioNormasMesFilterSet, RelatorioMateriasPorAutorFilterSet, \ RelatorioNormasVigenciaFilterSet, RelatorioNormasMesFilterSet, RelatorioMateriasPorAutorFilterSet, \
RelatorioMateriasPorAnoAutorTipoFilterSet, RelatorioMateriasTramitacaoFilterSet, RelatorioAudienciaFilterSet, \ RelatorioMateriasPorAnoAutorTipoFilterSet, RelatorioMateriasTramitacaoFilterSet, RelatorioAudienciaFilterSet, \
RelatorioReuniaoFilterSet, RelatorioDataFimPrazoTramitacaoFilterSet, RelatorioHistoricoTramitacaoFilterSet, \ RelatorioReuniaoFilterSet, RelatorioDataFimPrazoTramitacaoFilterSet, RelatorioHistoricoTramitacaoFilterSet, \
RelatorioPresencaSessaoFilterSet, RelatorioAtasFilterSet, RelatorioDocumentosAcessoriosFilterSet RelatorioPresencaSessaoFilterSet, RelatorioAtasFilterSet, RelatorioDocumentosAcessoriosFilterSet, \
RelatorioVotacoesNominaisFilterSet
from sapl.sessao.models import (ExpedienteMateria, ExpedienteSessao, from sapl.sessao.models import (ExpedienteMateria, ExpedienteSessao,
IntegranteMesa, JustificativaAusencia, IntegranteMesa, JustificativaAusencia,
Orador, OradorExpediente, Orador, OradorExpediente,
@ -47,10 +48,10 @@ from sapl.sessao.views import (get_identificacao_basica, get_mesa_diretora,
get_oradores_explicacoes_pessoais, get_consideracoes_finais, get_oradores_explicacoes_pessoais, get_consideracoes_finais,
get_ocorrencias_da_sessao, get_assinaturas, get_ocorrencias_da_sessao, get_assinaturas,
get_correspondencias) get_correspondencias)
from sapl.settings import MEDIA_URL from sapl.settings import MEDIA_URL, RATE_LIMITER_RATE
from sapl.settings import STATIC_ROOT from sapl.settings import STATIC_ROOT
from sapl.utils import LISTA_DE_UFS, TrocaTag, filiacao_data, create_barcode, show_results_filter_set, \ from sapl.utils import LISTA_DE_UFS, TrocaTag, filiacao_data, create_barcode, show_results_filter_set, \
num_materias_por_tipo, parlamentares_ativos num_materias_por_tipo, parlamentares_ativos, MultiFormatOutputMixin, ratelimit_ip
from .templates import (pdf_capa_processo_gerar, from .templates import (pdf_capa_processo_gerar,
pdf_documento_administrativo_gerar, pdf_espelho_gerar, pdf_documento_administrativo_gerar, pdf_espelho_gerar,
pdf_etiqueta_protocolo_gerar, pdf_materia_gerar, pdf_etiqueta_protocolo_gerar, pdf_materia_gerar,
@ -58,6 +59,9 @@ from .templates import (pdf_capa_processo_gerar,
pdf_protocolo_gerar, pdf_sessao_plenaria_gerar) pdf_protocolo_gerar, pdf_sessao_plenaria_gerar)
from sapl.crud.base import make_pagination from sapl.crud.base import make_pagination
from ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
def get_kwargs_params(request, fields): def get_kwargs_params(request, fields):
kwargs = {} kwargs = {}
@ -605,20 +609,22 @@ def get_sessao_plenaria(sessao, casa, user):
# Exibe os Expedientes # Exibe os Expedientes
lst_expedientes = [] lst_expedientes = []
# A ordenação deve ser a mesma de sapl.sessao.views.get_expedientes, para
# que o PDF confira com o Resumo exibido em tela. OSTicket #125461
expedientes = ExpedienteSessao.objects.filter( expedientes = ExpedienteSessao.objects.filter(
sessao_plenaria=sessao).order_by('tipo__nome') sessao_plenaria=sessao).order_by('tipo__ordenacao', 'tipo__nome')
for e in expedientes: for e in expedientes:
conteudo = e.conteudo conteudo = e.conteudo
if not is_empty(conteudo): if not is_empty(conteudo):
# unescape HTML codes # unescape HTML codes
# https://github.com/interlegis/sapl/issues/1046 # https://github.com/interlegis/sapl/issues/1046
conteudo = re.sub('style=".*?"', '', conteudo) conteudo = re.sub(r'style=".*?"', '', conteudo)
conteudo = re.sub('class=".*?"', '', conteudo) conteudo = re.sub(r'class=".*?"', '', conteudo)
# OSTicket Ticket #796450 # OSTicket Ticket #796450
conteudo = re.sub('align=".*?"', '', conteudo) conteudo = re.sub(r'align=".*?"', '', conteudo)
conteudo = re.sub('<p\s+>', '<p>', conteudo) conteudo = re.sub(r'<p\s+>', '<p>', conteudo)
# OSTicket Ticket #796450 # OSTicket Ticket #796450
conteudo = re.sub('<br\s+/>', '<br/>', conteudo) conteudo = re.sub(r'<br\s+/>', '<br/>', conteudo)
conteudo = html.unescape(conteudo) conteudo = html.unescape(conteudo)
# escape special character '&' # escape special character '&'
@ -705,7 +711,7 @@ def get_sessao_plenaria(sessao, casa, user):
lst_expediente_materia_vot_nom = [] lst_expediente_materia_vot_nom = []
materias_expediente_votacao_nominal = ExpedienteMateria.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \ materias_expediente_votacao_nominal = ExpedienteMateria.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \
.order_by('-materia') .order_by('numero_ordem')
for mevn in materias_expediente_votacao_nominal: for mevn in materias_expediente_votacao_nominal:
votos_materia = [] votos_materia = []
@ -818,7 +824,7 @@ def get_sessao_plenaria(sessao, casa, user):
lst_votacao_vot_nom = [] lst_votacao_vot_nom = []
materias_ordem_dia_votacao_nominal = OrdemDia.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \ materias_ordem_dia_votacao_nominal = OrdemDia.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \
.order_by('-materia') .order_by('numero_ordem')
for modvn in materias_ordem_dia_votacao_nominal: for modvn in materias_ordem_dia_votacao_nominal:
votos_materia_od = [] votos_materia_od = []
@ -874,7 +880,7 @@ def get_sessao_plenaria(sessao, casa, user):
# unescape HTML codes # unescape HTML codes
# https://github.com/interlegis/sapl/issues/1046 # https://github.com/interlegis/sapl/issues/1046
conteudo = re.sub('style=".*?"', '', conteudo) conteudo = re.sub(r'style=".*?"', '', conteudo)
conteudo = html.unescape(conteudo) conteudo = html.unescape(conteudo)
# escape special character '&' # escape special character '&'
@ -894,7 +900,7 @@ def get_sessao_plenaria(sessao, casa, user):
# unescape HTML codes # unescape HTML codes
# https://github.com/interlegis/sapl/issues/1046 # https://github.com/interlegis/sapl/issues/1046
conteudo = re.sub('style=".*?"', '', conteudo) conteudo = re.sub(r'style=".*?"', '', conteudo)
conteudo = html.unescape(conteudo) conteudo = html.unescape(conteudo)
# escape special character '&' # escape special character '&'
@ -1321,13 +1327,13 @@ def get_pauta_sessao(sessao, casa):
if not is_empty(conteudo): if not is_empty(conteudo):
# unescape HTML codes # unescape HTML codes
# https://github.com/interlegis/sapl/issues/1046 # https://github.com/interlegis/sapl/issues/1046
conteudo = re.sub('style=".*?"', '', conteudo) conteudo = re.sub(r'style=".*?"', '', conteudo)
conteudo = re.sub('class=".*?"', '', conteudo) conteudo = re.sub(r'class=".*?"', '', conteudo)
# OSTicket Ticket #796450 # OSTicket Ticket #796450
conteudo = re.sub('align=".*?"', '', conteudo) conteudo = re.sub(r'align=".*?"', '', conteudo)
conteudo = re.sub('<p\s+>', '<p>', conteudo) conteudo = re.sub(r'<p\s+>', '<p>', conteudo)
# OSTicket Ticket #796450 # OSTicket Ticket #796450
conteudo = re.sub('<br\s+/>', '<br/>', conteudo) conteudo = re.sub(r'<br\s+/>', '<br/>', conteudo)
conteudo = html.unescape(conteudo) conteudo = html.unescape(conteudo)
# escape special character '&' # escape special character '&'
@ -1560,6 +1566,10 @@ def relatorio_documento_acessorio(obj, request, context):
return cria_relatorio(request, context, 'relatorios/relatorio_documento_acessorio.html') return cria_relatorio(request, context, 'relatorios/relatorio_documento_acessorio.html')
def relatorio_votacao_nominal(obj, request, context):
return cria_relatorio(request, context, 'relatorios/relatorio_votacao_nominal.html')
def relatorio_normas_por_autor(obj, request, context): def relatorio_normas_por_autor(obj, request, context):
return cria_relatorio(request, context, 'relatorios/relatorio_normas_por_autor.html') return cria_relatorio(request, context, 'relatorios/relatorio_normas_por_autor.html')
@ -1782,7 +1792,7 @@ def relatorio_materia_tramitacao(request, pk):
'materia': materia_legislativa, 'materia': materia_legislativa,
'ano': materia_legislativa.ano, 'ano': materia_legislativa.ano,
'numero': materia_legislativa.numero, 'numero': materia_legislativa.numero,
'autor': materia_legislativa.autores.first(), 'autores': materia_legislativa.autores.all(),
'tipo': materia_legislativa.tipo.descricao, 'tipo': materia_legislativa.tipo.descricao,
'rodape': rodape, 'rodape': rodape,
'data': dt.today().strftime('%d/%m/%Y'), 'data': dt.today().strftime('%d/%m/%Y'),
@ -1836,6 +1846,10 @@ class RelatorioMixin:
return self.render_to_response(context) return self.render_to_response(context)
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioDocumentosAcessoriosView(RelatorioMixin, FilterView): class RelatorioDocumentosAcessoriosView(RelatorioMixin, FilterView):
model = DocumentoAcessorio model = DocumentoAcessorio
filterset_class = RelatorioDocumentosAcessoriosFilterSet filterset_class = RelatorioDocumentosAcessoriosFilterSet
@ -1880,6 +1894,83 @@ class RelatorioDocumentosAcessoriosView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioVotacoesNominaisView(RelatorioMixin, MultiFormatOutputMixin, FilterView):
model = VotoParlamentar
filterset_class = RelatorioVotacoesNominaisFilterSet
template_name = 'relatorios/RelatorioVotacoesNominais_filter.html'
relatorio = relatorio_votacao_nominal
paginate_by = 20
export_fields = [
'votacao_id', 'votacao', 'parlamentar__nome_parlamentar', 'voto'
]
def get_queryset(self):
query_params = Q(ordem__tipo_votacao=2)|Q(expediente__tipo_votacao=2)
if 'format' in self.request.GET:
order_fields = ['-votacao_id', 'parlamentar']
qs = VotoParlamentar.objects.filter(query_params).order_by(*order_fields)
else:
order_fields = ['-id']
qs = RegistroVotacao.objects.filter(query_params).order_by(*order_fields)
return qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = _('Votações Nominais')
if not self.filterset.form.is_valid():
return context
query_dict = self.request.GET.copy()
if 'page' in query_dict:
del query_dict['page']
context['filter_url'] = f"&{query_dict.urlencode()}" if query_dict else ''
context['show_results'] = show_results_filter_set(query_dict)
data_inicial = self.request.GET.get('data_hora_0', '')
data_final = self.request.GET.get('data_hora_1', '')
if not data_inicial:
data_inicial = "Data Inicial não definida"
if not data_final:
data_final = "Data Final não definida"
context['periodo'] = f"{data_inicial} - {data_final}"
tipo_id = self.request.GET.get('tipo_id')
numero = self.request.GET.get('numero')
ano = self.request.GET.get('ano')
if tipo_id:
context['tipo_materia'] = TipoMateriaLegislativa.objects.get(id=tipo_id)
if numero:
context['numero'] = int(numero)
if ano:
context['ano'] = ano
if 'relatorio' not in self.request.GET:
paginator = context['paginator']
page_obj = context['page_obj']
context['page_range'] = make_pagination(
page_obj.number, paginator.num_pages)
context['qtde_votacoes'] = paginator.count
else:
self.paginate_by = None
context['qtde_votacoes'] = len(context['object_list'])
return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioAtasView(RelatorioMixin, FilterView): class RelatorioAtasView(RelatorioMixin, FilterView):
model = SessaoPlenaria model = SessaoPlenaria
filterset_class = RelatorioAtasFilterSet filterset_class = RelatorioAtasFilterSet
@ -1905,6 +1996,10 @@ class RelatorioAtasView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioPresencaSessaoView(RelatorioMixin, FilterView): class RelatorioPresencaSessaoView(RelatorioMixin, FilterView):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
model = SessaoPlenaria model = SessaoPlenaria
@ -1985,17 +2080,22 @@ class RelatorioPresencaSessaoView(RelatorioMixin, FilterView):
parlamentares_id = parlamentares_qs.values_list('id', flat=True) parlamentares_id = parlamentares_qs.values_list('id', flat=True)
# Presenças de cada Parlamentar em Sessões # Presenças de cada Parlamentar em Sessões
# Conta sessões distintas, e não linhas de presença: bases com
# presenças repetidas para o mesmo parlamentar na mesma sessão
# produziam percentuais acima de 100%.
presenca_sessao = SessaoPlenariaPresenca.objects.filter( presenca_sessao = SessaoPlenariaPresenca.objects.filter(
**param0).values_list('parlamentar_id').annotate(sessao_count=Count('id')) **param0).values_list('parlamentar_id').annotate(
sessao_count=Count('sessao_plenaria_id', distinct=True))
# Presenças de cada Ordem do Dia # Presenças de cada Ordem do Dia
presenca_ordem = PresencaOrdemDia.objects.filter( presenca_ordem = PresencaOrdemDia.objects.filter(
**param0).values_list('parlamentar_id').annotate(sessao_count=Count('id')) **param0).values_list('parlamentar_id').annotate(
sessao_count=Count('sessao_plenaria_id', distinct=True))
# Ausencias justificadas # Ausencias justificadas
ausencia_justificadas = JustificativaAusencia.objects.filter( ausencia_justificadas = JustificativaAusencia.objects.filter(
**param0, ausencia=2).values_list('parlamentar_id')\ **param0, ausencia=2).values_list('parlamentar_id')\
.annotate(sessao_count=Count('id')) .annotate(sessao_count=Count('sessao_plenaria_id', distinct=True))
total_ordemdia = PresencaOrdemDia.objects.filter( total_ordemdia = PresencaOrdemDia.objects.filter(
**param0).distinct('sessao_plenaria__id').order_by('sessao_plenaria__id').count() **param0).distinct('sessao_plenaria__id').order_by('sessao_plenaria__id').count()
@ -2139,6 +2239,10 @@ class RelatorioPresencaSessaoView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioHistoricoTramitacaoView(RelatorioMixin, FilterView): class RelatorioHistoricoTramitacaoView(RelatorioMixin, FilterView):
model = MateriaLegislativa model = MateriaLegislativa
filterset_class = RelatorioHistoricoTramitacaoFilterSet filterset_class = RelatorioHistoricoTramitacaoFilterSet
@ -2196,6 +2300,10 @@ class RelatorioHistoricoTramitacaoView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioDataFimPrazoTramitacaoView(RelatorioMixin, FilterView): class RelatorioDataFimPrazoTramitacaoView(RelatorioMixin, FilterView):
model = MateriaEmTramitacao model = MateriaEmTramitacao
filterset_class = RelatorioDataFimPrazoTramitacaoFilterSet filterset_class = RelatorioDataFimPrazoTramitacaoFilterSet
@ -2259,6 +2367,10 @@ class RelatorioDataFimPrazoTramitacaoView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioReuniaoView(RelatorioMixin, FilterView): class RelatorioReuniaoView(RelatorioMixin, FilterView):
model = Reuniao model = Reuniao
filterset_class = RelatorioReuniaoFilterSet filterset_class = RelatorioReuniaoFilterSet
@ -2293,6 +2405,10 @@ class RelatorioReuniaoView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioAudienciaView(RelatorioMixin, FilterView): class RelatorioAudienciaView(RelatorioMixin, FilterView):
model = AudienciaPublica model = AudienciaPublica
filterset_class = RelatorioAudienciaFilterSet filterset_class = RelatorioAudienciaFilterSet
@ -2327,6 +2443,10 @@ class RelatorioAudienciaView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioMateriasTramitacaoView(RelatorioMixin, FilterView): class RelatorioMateriasTramitacaoView(RelatorioMixin, FilterView):
model = MateriaEmTramitacao model = MateriaEmTramitacao
filterset_class = RelatorioMateriasTramitacaoFilterSet filterset_class = RelatorioMateriasTramitacaoFilterSet
@ -2441,6 +2561,10 @@ class RelatorioMateriasTramitacaoView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioMateriasPorAnoAutorTipoView(RelatorioMixin, FilterView): class RelatorioMateriasPorAnoAutorTipoView(RelatorioMixin, FilterView):
model = MateriaLegislativa model = MateriaLegislativa
filterset_class = RelatorioMateriasPorAnoAutorTipoFilterSet filterset_class = RelatorioMateriasPorAnoAutorTipoFilterSet
@ -2520,6 +2644,10 @@ class RelatorioMateriasPorAnoAutorTipoView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioMateriasPorAutorView(RelatorioMixin, FilterView): class RelatorioMateriasPorAutorView(RelatorioMixin, FilterView):
model = MateriaLegislativa model = MateriaLegislativa
filterset_class = RelatorioMateriasPorAutorFilterSet filterset_class = RelatorioMateriasPorAutorFilterSet
@ -2539,7 +2667,6 @@ class RelatorioMateriasPorAutorView(RelatorioMixin, FilterView):
return context return context
qs = context['object_list'] qs = context['object_list']
context['materias_resultado'] = list(collections.OrderedDict.fromkeys(qs))
context['qtdes'] = num_materias_por_tipo(qs) context['qtdes'] = num_materias_por_tipo(qs)
qr = self.request.GET.copy() qr = self.request.GET.copy()
@ -2554,9 +2681,9 @@ class RelatorioMateriasPorAutorView(RelatorioMixin, FilterView):
context['tipo'] = '' context['tipo'] = ''
if self.request.GET['autoria__autor']: if self.request.GET['autoria__autor']:
autor = int(self.request.GET['autoria__autor']) autor = int(self.request.GET['autoria__autor'])
context['autor'] = (str(Autor.objects.get(id=autor))) context['autor'] = Autor.objects.get(id=autor)
else: else:
context['autor'] = '' context['autor'] = None
context['periodo'] = ( context['periodo'] = (
self.request.GET['data_apresentacao_0'] + self.request.GET['data_apresentacao_0'] +
' - ' + self.request.GET['data_apresentacao_1']) ' - ' + self.request.GET['data_apresentacao_1'])
@ -2591,6 +2718,10 @@ class RelatorioMateriaAnoAssuntoView(ListView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioNormasPublicadasMesView(RelatorioMixin, FilterView): class RelatorioNormasPublicadasMesView(RelatorioMixin, FilterView):
model = NormaJuridica model = NormaJuridica
filterset_class = RelatorioNormasMesFilterSet filterset_class = RelatorioNormasMesFilterSet
@ -2631,6 +2762,10 @@ class RelatorioNormasPublicadasMesView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioNormasVigenciaView(RelatorioMixin, FilterView): class RelatorioNormasVigenciaView(RelatorioMixin, FilterView):
model = NormaJuridica model = NormaJuridica
filterset_class = RelatorioNormasVigenciaFilterSet filterset_class = RelatorioNormasVigenciaFilterSet
@ -2695,6 +2830,10 @@ class RelatorioNormasVigenciaView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioHistoricoTramitacaoAdmView(RelatorioMixin, FilterView): class RelatorioHistoricoTramitacaoAdmView(RelatorioMixin, FilterView):
model = DocumentoAdministrativo model = DocumentoAdministrativo
filterset_class = RelatorioHistoricoTramitacaoAdmFilterSet filterset_class = RelatorioHistoricoTramitacaoAdmFilterSet
@ -2745,6 +2884,10 @@ class RelatorioHistoricoTramitacaoAdmView(RelatorioMixin, FilterView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class RelatorioNormasPorAutorView(RelatorioMixin, FilterView): class RelatorioNormasPorAutorView(RelatorioMixin, FilterView):
model = NormaJuridica model = NormaJuridica
filterset_class = RelatorioNormasPorAutorFilterSet filterset_class = RelatorioNormasPorAutorFilterSet
@ -2784,9 +2927,9 @@ class RelatorioNormasPorAutorView(RelatorioMixin, FilterView):
if self.request.GET['autorianorma__autor']: if self.request.GET['autorianorma__autor']:
autor = int(self.request.GET['autorianorma__autor']) autor = int(self.request.GET['autorianorma__autor'])
context['autor'] = (str(Autor.objects.get(id=autor))) context['autor'] = Autor.objects.get(id=autor)
else: else:
context['autor'] = '' context['autor'] = None
context['periodo'] = ( context['periodo'] = (
self.request.GET['data_0'] + self.request.GET['data_0'] +
' - ' + self.request.GET['data_1']) ' - ' + self.request.GET['data_1'])

19
sapl/sessao/forms.py

@ -22,7 +22,8 @@ from sapl.materia.models import (MateriaLegislativa, StatusTramitacao,
from sapl.parlamentares.models import Mandato, Parlamentar from sapl.parlamentares.models import Mandato, Parlamentar
from sapl.protocoloadm.models import TipoDocumentoAdministrativo,\ from sapl.protocoloadm.models import TipoDocumentoAdministrativo,\
DocumentoAdministrativo DocumentoAdministrativo
from sapl.sessao.models import Correspondencia from sapl.sessao.models import Correspondencia, AbstractOrdemDia
from sapl.utils import (autor_label, autor_modal, from sapl.utils import (autor_label, autor_modal,
choice_anos_com_sessaoplenaria, choice_anos_com_sessaoplenaria,
FileFieldCheckMixin, FileFieldCheckMixin,
@ -563,6 +564,22 @@ class SessaoPlenariaFilterSet(django_filters.FilterSet):
) )
class AdicionarVariasMateriasForm(forms.Form):
check_all = forms.BooleanField(
label='Marcar/Desmarcar Todos',
required=False,
widget=forms.CheckboxInput(
attrs={'onchange':'checkAll(this)'}),
)
tipo_votacao = forms.ChoiceField(required=False,
choices= AbstractOrdemDia.TIPO_VOTACAO_CHOICES,
initial=False,
widget=forms.RadioSelect(
attrs={'onchange':'marcaTipoVotacao()'}),
)
class AdicionarVariasMateriasFilterSet(MateriaLegislativaFilterSet): class AdicionarVariasMateriasFilterSet(MateriaLegislativaFilterSet):
o = MateriaPesquisaOrderingFilter() o = MateriaPesquisaOrderingFilter()

40
sapl/sessao/migrations/0070_presenca_unica_por_sessao.py

@ -0,0 +1,40 @@
# Generated by Django 2.2.28 on 2026-08-05 16:00
from django.db import migrations
# Mantém uma única presença por (sessão, parlamentar). Presença é um sim/não,
# então as linhas repetidas não carregam informação adicional: preserva-se a
# de menor id e descartam-se as demais.
REMOVE_DUPLICADAS_SQL = """
DELETE FROM {tabela} a
USING {tabela} b
WHERE a.sessao_plenaria_id = b.sessao_plenaria_id
AND a.parlamentar_id = b.parlamentar_id
AND a.id > b.id;
"""
class Migration(migrations.Migration):
dependencies = [
('sessao', '0069_auto_20220919_1705'),
]
operations = [
migrations.RunSQL(
REMOVE_DUPLICADAS_SQL.format(
tabela='sessao_sessaoplenariapresenca'),
migrations.RunSQL.noop),
migrations.RunSQL(
REMOVE_DUPLICADAS_SQL.format(tabela='sessao_presencaordemdia'),
migrations.RunSQL.noop),
migrations.AlterUniqueTogether(
name='sessaoplenariapresenca',
unique_together={('sessao_plenaria', 'parlamentar')},
),
migrations.AlterUniqueTogether(
name='presencaordemdia',
unique_together={('sessao_plenaria', 'parlamentar')},
),
]

44
sapl/sessao/models.py

@ -153,6 +153,31 @@ def anexo_upload_path(instance, filename):
# return get_sessao_media_path(instance, 'anexo', filename) # return get_sessao_media_path(instance, 'anexo', filename)
def restringe_sessoes_visiveis(qs, user):
"""Restringe o queryset às sessões visíveis na consulta pública.
Usuário autenticado enxerga todas. Para o público, ficam ocultas as
sessões cadastradas com antecedência, ou seja, as de data futura
marcadas como não iniciadas e sem pauta publicada.
A data é o que distingue o cadastro antecipado: sessões de hoje ou de
datas passadas ficam sempre visíveis, mesmo com `iniciada` em Não, porque
há Casas que não marcam o campo nas sessões já realizadas.
`iniciada` nulo conta como visível: o default do campo só surgiu na
migração 0027, que não preencheu as linhas já existentes, então bases
antigas têm sessões realizadas com o campo em NULL.
"""
if user.is_authenticated:
return qs
return qs.filter(
Q(publicar_pauta=True) |
Q(iniciada=True) |
Q(iniciada__isnull=True) |
Q(data_inicio__lte=timezone.localdate()))
class SessaoPlenaria(models.Model): class SessaoPlenaria(models.Model):
# TODO trash??? Seems to have been a FK in the past. Would be: # TODO trash??? Seems to have been a FK in the past. Would be:
# andamento_sessao = models.ForeignKey( # andamento_sessao = models.ForeignKey(
@ -574,6 +599,9 @@ class PresencaOrdemDia(models.Model): # OrdemDiaPresenca
verbose_name = _('Presença da Ordem do Dia') verbose_name = _('Presença da Ordem do Dia')
verbose_name_plural = _('Presenças da Ordem do Dia') verbose_name_plural = _('Presenças da Ordem do Dia')
ordering = ['parlamentar__nome_parlamentar'] ordering = ['parlamentar__nome_parlamentar']
# Presença é um sim/não: mais de uma linha para o mesmo parlamentar
# na mesma sessão infla a contagem dos relatórios.
unique_together = ('sessao_plenaria', 'parlamentar')
def __str__(self): def __str__(self):
# FIXME ambigous # FIXME ambigous
@ -641,11 +669,14 @@ class RegistroVotacao(models.Model):
ordering = ('id',) ordering = ('id',)
def __str__(self): def __str__(self):
return _('Ordem: %(ordem)s - Votação: %(votacao)s - ' if self.ordem:
'Matéria: %(materia)s') % { return _('Ordem: %(ordem)s - Votação: %(votacao)s') % {
'ordem': self.ordem, 'ordem': self.ordem,
'votacao': self.tipo_resultado_votacao, 'votacao': self.tipo_resultado_votacao}
'materia': self.materia} else:
return _('Expediente: %(expediente)s - Votação: %(votacao)s') % {
'expediente': self.expediente,
'votacao': self.tipo_resultado_votacao}
def clean(self): def clean(self):
"""Exatamente um dos campos ordem ou expediente deve estar preenchido. """Exatamente um dos campos ordem ou expediente deve estar preenchido.
@ -713,6 +744,9 @@ class SessaoPlenariaPresenca(models.Model):
verbose_name = _('Presença em Sessão Plenária') verbose_name = _('Presença em Sessão Plenária')
verbose_name_plural = _('Presenças em Sessões Plenárias') verbose_name_plural = _('Presenças em Sessões Plenárias')
ordering = ['parlamentar__nome_parlamentar'] ordering = ['parlamentar__nome_parlamentar']
# Presença é um sim/não: mais de uma linha para o mesmo parlamentar
# na mesma sessão infla a contagem dos relatórios.
unique_together = ('sessao_plenaria', 'parlamentar')
ORDENACAO_RESUMO = [ ORDENACAO_RESUMO = [

220
sapl/sessao/tests/test_sessao_view.py

@ -1,14 +1,21 @@
import pytest import pytest
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.db import IntegrityError, transaction
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from model_bakery import baker from model_bakery import baker
from sapl.parlamentares.models import Legislatura, SessaoLegislativa from sapl.parlamentares.models import Legislatura, SessaoLegislativa
from sapl.sessao.models import (SessaoPlenaria, TipoSessaoPlenaria, from sapl.sessao.models import (SessaoPlenaria, TipoSessaoPlenaria,
IntegranteMesa, SessaoPlenariaPresenca, IntegranteMesa, SessaoPlenariaPresenca,
PresencaOrdemDia,
JustificativaAusencia, ExpedienteSessao, JustificativaAusencia, ExpedienteSessao,
TipoExpediente, ExpedienteMateria, TipoExpediente, ExpedienteMateria,
Orador, OcorrenciaSessao) Orador, OcorrenciaSessao,
restringe_sessoes_visiveis)
from sapl.parlamentares.models import Parlamentar, CargoMesa, Filiacao from sapl.parlamentares.models import Parlamentar, CargoMesa, Filiacao
@ -147,3 +154,214 @@ class TestResumoView():
resultado_get_ocorrencia = get_ocorrencias_da_sessao(self.sessao_plenaria) resultado_get_ocorrencia = get_ocorrencias_da_sessao(self.sessao_plenaria)
assert resultado_get_ocorrencia['ocorrencias_da_sessao'][0] == ocorrencia assert resultado_get_ocorrencia['ocorrencias_da_sessao'][0] == ocorrencia
# Sessão cadastrada com antecedência: data futura, sem pauta e não iniciada.
def data_futura():
return timezone.localdate() + timedelta(days=30)
@pytest.mark.django_db(transaction=False)
def test_visiveis_para_oculta_do_anonimo_apenas_a_sessao_previa():
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=data_futura())
com_pauta = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=True,
data_inicio=data_futura())
iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False,
data_inicio=data_futura())
# Sessões anteriores à migração 0027 ficaram com `iniciada` em NULL.
legada = baker.make(SessaoPlenaria, iniciada=None, publicar_pauta=False,
data_inicio=data_futura())
visiveis = restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), AnonymousUser())
assert previa not in visiveis
assert com_pauta in visiveis
assert iniciada in visiveis
assert legada in visiveis
@pytest.mark.django_db(transaction=False)
def test_visiveis_para_mostra_ao_anonimo_sessao_realizada_nao_marcada():
# Casas que não marcam "Sessão iniciada?" nas sessões já realizadas.
passada = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=timezone.localdate() - timedelta(days=1))
de_hoje = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=timezone.localdate())
visiveis = restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), AnonymousUser())
assert passada in visiveis
assert de_hoje in visiveis
@pytest.mark.django_db(transaction=False)
def test_visiveis_para_nao_oculta_nada_de_usuario_autenticado():
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=data_futura())
operador = baker.make(get_user_model())
assert previa in restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), operador)
@pytest.mark.django_db(transaction=False)
def test_pesquisar_sessao_nao_lista_sessao_previa_para_anonimo(client):
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=data_futura())
iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False,
data_inicio=data_futura())
response = client.get(reverse('sapl.sessao:pesquisar_sessao'),
{'data_inicio__year': data_futura().year})
assert response.status_code == 200
assert previa not in response.context['object_list']
assert iniciada in response.context['object_list']
@pytest.mark.django_db(transaction=False)
def test_pesquisar_sessao_lista_sessao_previa_para_autenticado(admin_client):
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=data_futura())
response = admin_client.get(reverse('sapl.sessao:pesquisar_sessao'),
{'data_inicio__year': data_futura().year})
assert response.status_code == 200
assert previa in response.context['object_list']
@pytest.mark.django_db(transaction=False)
def test_detail_sessao_previa_indisponivel_para_anonimo(client):
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=data_futura())
response = client.get(reverse('sapl.sessao:sessaoplenaria_detail',
kwargs={'pk': previa.pk}))
assert response.status_code == 404
@pytest.mark.django_db(transaction=False)
def test_resumo_de_sessao_previa_indisponivel_para_anonimo(client):
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
data_inicio=data_futura())
response = client.get(reverse('sapl.sessao:resumo',
kwargs={'pk': previa.pk}))
assert response.status_code == 404
@pytest.mark.django_db(transaction=False)
def test_pauta_nao_publicada_indisponivel_para_anonimo(client):
sem_pauta = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False)
response = client.get(reverse('sapl.sessao:pauta_sessao_detail',
kwargs={'pk': sem_pauta.pk}))
assert response.status_code == 404
def cria_sessao_com_parlamentares():
legislatura = baker.make(Legislatura)
sessao_legislativa = baker.make(SessaoLegislativa)
tipo = baker.make(TipoSessaoPlenaria)
sessao = baker.make(SessaoPlenaria,
legislatura=legislatura,
sessao_legislativa=sessao_legislativa,
tipo=tipo)
parlamentares = [baker.make(Parlamentar, ativo=True) for _i in range(3)]
return sessao, parlamentares
@pytest.mark.django_db(transaction=False)
def test_presenca_sessao_preserva_registros_ao_salvar_novamente(admin_client):
# Cada salvamento apagava todas as presenças da sessão e as recriava,
# trocando os ids. Além do desperdício, é essa janela entre apagar e
# recriar que permite a duplicação quando o formulário é submetido duas
# vezes em paralelo. Salvar de novo deve ser inócuo.
sessao, parlamentares = cria_sessao_com_parlamentares()
url = reverse('sapl.sessao:presenca', kwargs={'pk': sessao.pk})
dados = {'presenca_ativos': [str(p.pk) for p in parlamentares]}
admin_client.post(url, dados, follow=True)
presencas = SessaoPlenariaPresenca.objects.filter(sessao_plenaria=sessao)
ids_iniciais = sorted(presencas.values_list('id', flat=True))
admin_client.post(url, dados, follow=True)
admin_client.post(url, dados, follow=True)
assert presencas.count() == len(parlamentares)
assert sorted(presencas.values_list('parlamentar_id', flat=True)) == \
sorted(p.pk for p in parlamentares)
assert sorted(presencas.values_list('id', flat=True)) == ids_iniciais
@pytest.mark.django_db(transaction=False)
def test_presenca_sessao_ignora_registro_ja_existente(admin_client):
# Reproduz o efeito da submissão concorrente: a linha já existe quando a
# view vai gravar. Deve terminar com uma única presença, sem erro.
sessao, parlamentares = cria_sessao_com_parlamentares()
parlamentar = parlamentares[0]
SessaoPlenariaPresenca.objects.create(sessao_plenaria=sessao,
parlamentar=parlamentar)
url = reverse('sapl.sessao:presenca', kwargs={'pk': sessao.pk})
response = admin_client.post(
url, {'presenca_ativos': [str(parlamentar.pk)]}, follow=True)
assert response.status_code == 200
assert SessaoPlenariaPresenca.objects.filter(
sessao_plenaria=sessao, parlamentar=parlamentar).count() == 1
@pytest.mark.django_db(transaction=False)
def test_presenca_sessao_remove_desmarcados(admin_client):
sessao, parlamentares = cria_sessao_com_parlamentares()
url = reverse('sapl.sessao:presenca', kwargs={'pk': sessao.pk})
admin_client.post(url, {'presenca_ativos': [str(p.pk)
for p in parlamentares]},
follow=True)
admin_client.post(url, {'presenca_ativos': [str(parlamentares[0].pk)]},
follow=True)
presencas = SessaoPlenariaPresenca.objects.filter(sessao_plenaria=sessao)
assert list(presencas.values_list('parlamentar_id', flat=True)) == \
[parlamentares[0].pk]
@pytest.mark.django_db(transaction=False)
def test_presenca_ordem_dia_preserva_registros_ao_resalvar(admin_client):
sessao, parlamentares = cria_sessao_com_parlamentares()
url = reverse('sapl.sessao:presencaordemdia', kwargs={'pk': sessao.pk})
dados = {'presenca_ativos': [str(p.pk) for p in parlamentares]}
admin_client.post(url, dados, follow=True)
presencas = PresencaOrdemDia.objects.filter(sessao_plenaria=sessao)
ids_iniciais = sorted(presencas.values_list('id', flat=True))
admin_client.post(url, dados, follow=True)
assert presencas.count() == len(parlamentares)
assert sorted(presencas.values_list('id', flat=True)) == ids_iniciais
@pytest.mark.django_db(transaction=False)
def test_presenca_unica_por_sessao_e_parlamentar():
# A restrição de unicidade é a proteção contra submissões concorrentes,
# que a lógica da view sozinha não cobre.
sessao, parlamentares = cria_sessao_com_parlamentares()
parlamentar = parlamentares[0]
for model in (SessaoPlenariaPresenca, PresencaOrdemDia):
model.objects.create(sessao_plenaria=sessao, parlamentar=parlamentar)
with pytest.raises(IntegrityError):
with transaction.atomic():
model.objects.create(sessao_plenaria=sessao,
parlamentar=parlamentar)

153
sapl/sessao/views.py

@ -28,6 +28,9 @@ from django.views.generic.edit import FormMixin
from django_filters.views import FilterView from django_filters.views import FilterView
import pytz import pytz
from ratelimit.decorators import ratelimit
from django.utils.decorators import method_decorator
from sapl.base.models import AppConfig as AppsAppConfig from sapl.base.models import AppConfig as AppsAppConfig
from sapl.crud.base import (RP_DETAIL, RP_LIST, Crud, CrudAux, from sapl.crud.base import (RP_DETAIL, RP_LIST, Crud, CrudAux,
MasterDetailCrud, MasterDetailCrud,
@ -44,11 +47,11 @@ from sapl.sessao.apps import AppConfig
from sapl.sessao.forms import ExpedienteMateriaForm, OrdemDiaForm, OrdemExpedienteLeituraForm, \ from sapl.sessao.forms import ExpedienteMateriaForm, OrdemDiaForm, OrdemExpedienteLeituraForm, \
CorrespondenciaForm, CorrespondenciaEmLoteFilterSet CorrespondenciaForm, CorrespondenciaEmLoteFilterSet
from sapl.sessao.models import Correspondencia from sapl.sessao.models import Correspondencia
from sapl.settings import TIME_ZONE from sapl.settings import TIME_ZONE, RATE_LIMITER_RATE
from sapl.utils import show_results_filter_set, remover_acentos, get_client_ip,\ from sapl.utils import show_results_filter_set, remover_acentos, get_client_ip, \
MultiFormatOutputMixin, PautaMultiFormatOutputMixin MultiFormatOutputMixin, PautaMultiFormatOutputMixin, ratelimit_ip
from .forms import (AdicionarVariasMateriasFilterSet, BancadaForm, from .forms import (AdicionarVariasMateriasFilterSet, AdicionarVariasMateriasForm, BancadaForm,
ExpedienteForm, JustificativaAusenciaForm, OcorrenciaSessaoForm, ListMateriaForm, ExpedienteForm, JustificativaAusenciaForm, OcorrenciaSessaoForm, ListMateriaForm,
MesaForm, OradorExpedienteForm, OradorForm, PautaSessaoFilterSet, MesaForm, OradorExpedienteForm, OradorForm, PautaSessaoFilterSet,
PresencaForm, ResumoOrdenacaoForm, SessaoPlenariaFilterSet, PresencaForm, ResumoOrdenacaoForm, SessaoPlenariaFilterSet,
@ -61,7 +64,7 @@ from .models import (Bancada, CargoBancada, CargoMesa,
SessaoPlenaria, SessaoPlenariaPresenca, TipoExpediente, SessaoPlenaria, SessaoPlenariaPresenca, TipoExpediente,
TipoResultadoVotacao, TipoSessaoPlenaria, VotoParlamentar, TipoRetiradaPauta, TipoResultadoVotacao, TipoSessaoPlenaria, VotoParlamentar, TipoRetiradaPauta,
RetiradaPauta, TipoJustificativa, JustificativaAusencia, OradorOrdemDia, RetiradaPauta, TipoJustificativa, JustificativaAusencia, OradorOrdemDia,
ORDENACAO_RESUMO, RegistroLeitura) ORDENACAO_RESUMO, RegistroLeitura, restringe_sessoes_visiveis)
TipoSessaoCrud = CrudAux.build(TipoSessaoPlenaria, 'tipo_sessao_plenaria') TipoSessaoCrud = CrudAux.build(TipoSessaoPlenaria, 'tipo_sessao_plenaria')
TipoJustificativaCrud = CrudAux.build(TipoJustificativa, 'tipo_justificativa') TipoJustificativaCrud = CrudAux.build(TipoJustificativa, 'tipo_justificativa')
@ -1345,6 +1348,13 @@ class SessaoCrud(Crud):
class DetailView(Crud.DetailView): class DetailView(Crud.DetailView):
def get(self, request, *args, **kwargs):
if not restringe_sessoes_visiveis(
SessaoPlenaria.objects.filter(pk=kwargs.get('pk')),
request.user).exists():
raise Http404()
return super().get(request, *args, **kwargs)
@property @property
def layout_key(self): def layout_key(self):
sessao = self.object sessao = self.object
@ -1410,26 +1420,31 @@ class PresencaView(FormMixin, PresencaMixin, DetailView):
if form.is_valid(): if form.is_valid():
# Pegar os presentes salvos no banco # Pegar os presentes salvos no banco
presentes_banco = SessaoPlenariaPresenca.objects.filter( presentes_banco = set(SessaoPlenariaPresenca.objects.filter(
sessao_plenaria_id=self.object.id).values_list( sessao_plenaria_id=self.object.id).values_list(
'parlamentar_id', flat=True).distinct() 'parlamentar_id', flat=True))
# Id dos parlamentares presentes # Id dos parlamentares presentes
marcados = request.POST.getlist('presenca_ativos') \ marcados = set(int(p) for p in
+ request.POST.getlist('presenca_inativos') request.POST.getlist('presenca_ativos')
+ request.POST.getlist('presenca_inativos'))
# Deletar os que foram desmarcados # Deletar os que foram desmarcados
deletar = set(presentes_banco) - set(marcados)
SessaoPlenariaPresenca.objects.filter( SessaoPlenariaPresenca.objects.filter(
parlamentar_id__in=deletar, parlamentar_id__in=presentes_banco - marcados,
sessao_plenaria_id=self.object.id).delete() sessao_plenaria_id=self.object.id).delete()
for p in marcados: # Criar apenas quem ainda não tem presença registrada. O
sessao = SessaoPlenariaPresenca() # ignore_conflicts descarta a inserção duplicada quando o
sessao.sessao_plenaria = self.object # formulário é submetido duas vezes em paralelo, em vez de
sessao.parlamentar = Parlamentar.objects.get(id=p) # gravar uma segunda linha para o mesmo parlamentar.
sessao.save() username = request.user.username
username = request.user.username novos = marcados - presentes_banco
SessaoPlenariaPresenca.objects.bulk_create(
[SessaoPlenariaPresenca(sessao_plenaria=self.object,
parlamentar_id=p) for p in novos],
ignore_conflicts=True)
for p in novos:
self.logger.info( self.logger.info(
"user=" + username + ". SessaoPlenariaPresenca salva com sucesso (parlamentar_id={})!".format(p)) "user=" + username + ". SessaoPlenariaPresenca salva com sucesso (parlamentar_id={})!".format(p))
msg = _('Presença em Sessão salva com sucesso!') msg = _('Presença em Sessão salva com sucesso!')
@ -1525,26 +1540,29 @@ class PresencaOrdemDiaView(FormMixin, PresencaMixin, DetailView):
if form.is_valid(): if form.is_valid():
# Pegar os presentes salvos no banco # Pegar os presentes salvos no banco
presentes_banco = PresencaOrdemDia.objects.filter( presentes_banco = set(PresencaOrdemDia.objects.filter(
sessao_plenaria_id=self.object.id).values_list( sessao_plenaria_id=self.object.id).values_list(
'parlamentar_id', flat=True).distinct() 'parlamentar_id', flat=True))
# Id dos parlamentares presentes # Id dos parlamentares presentes
marcados = request.POST.getlist('presenca_ativos') \ marcados = set(int(p) for p in
+ request.POST.getlist('presenca_inativos') request.POST.getlist('presenca_ativos')
+ request.POST.getlist('presenca_inativos'))
# Deletar os que foram desmarcados # Deletar os que foram desmarcados
deletar = set(presentes_banco) - set(marcados)
PresencaOrdemDia.objects.filter( PresencaOrdemDia.objects.filter(
parlamentar_id__in=deletar, parlamentar_id__in=presentes_banco - marcados,
sessao_plenaria_id=self.object.id).delete() sessao_plenaria_id=self.object.id).delete()
for p in marcados: # Criar apenas quem ainda não tem presença registrada. Ver
ordem = PresencaOrdemDia() # comentário equivalente em PresencaView.post.
ordem.sessao_plenaria = self.object username = request.user.username
ordem.parlamentar = Parlamentar.objects.get(id=p) novos = marcados - presentes_banco
ordem.save() PresencaOrdemDia.objects.bulk_create(
username = request.user.username [PresencaOrdemDia(sessao_plenaria=self.object,
parlamentar_id=p) for p in novos],
ignore_conflicts=True)
for p in novos:
self.logger.info( self.logger.info(
'user=' + username + '. PresencaOrdemDia (parlamentar com id={}) salva com sucesso!'.format(p)) 'user=' + username + '. PresencaOrdemDia (parlamentar com id={}) salva com sucesso!'.format(p))
@ -2324,6 +2342,10 @@ class ResumoView(DetailView):
model = SessaoPlenaria model = SessaoPlenaria
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_queryset(self):
return restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), self.request.user)
def get_context(self, *args, **kwargs): def get_context(self, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
context = self.get_context_data(object=self.object) context = self.get_context_data(object=self.object)
@ -2331,7 +2353,7 @@ class ResumoView(DetailView):
# Votos de Votação Nominal de Matérias Expediente # Votos de Votação Nominal de Matérias Expediente
votacoes = [] votacoes = []
for mevn in ExpedienteMateria.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2) \ for mevn in ExpedienteMateria.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2) \
.order_by('-materia'): .order_by('numero_ordem'):
votos_materia = [] votos_materia = []
titulo_materia = mevn.materia titulo_materia = mevn.materia
registro = RegistroVotacao.objects.filter(expediente=mevn) registro = RegistroVotacao.objects.filter(expediente=mevn)
@ -2380,7 +2402,7 @@ class ResumoView(DetailView):
# Matérias Ordem do Dia # Matérias Ordem do Dia
# Votos de Votação Nominal de Matérias Ordem do Dia # Votos de Votação Nominal de Matérias Ordem do Dia
votacoes_od = [] votacoes_od = []
for modvn in OrdemDia.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2).order_by('-materia'): for modvn in OrdemDia.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2).order_by('numero_ordem'):
votos_materia_od = [] votos_materia_od = []
t_materia = modvn.materia t_materia = modvn.materia
registro_od = RegistroVotacao.objects.filter(ordem=modvn) registro_od = RegistroVotacao.objects.filter(ordem=modvn)
@ -3794,6 +3816,10 @@ class SessaoListView(ListView):
return context return context
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class PautaSessaoView(TemplateView): class PautaSessaoView(TemplateView):
model = SessaoPlenaria model = SessaoPlenaria
template_name = "sessao/pauta_inexistente.html" template_name = "sessao/pauta_inexistente.html"
@ -3809,26 +3835,37 @@ class PautaSessaoView(TemplateView):
reverse('sapl.sessao:pauta_sessao_detail', kwargs={'pk': sessao.pk})) reverse('sapl.sessao:pauta_sessao_detail', kwargs={'pk': sessao.pk}))
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView): class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView):
template_name = "sessao/pauta_sessao_detail.html" template_name = "sessao/pauta_sessao_detail.html"
model = SessaoPlenaria model = SessaoPlenaria
queryset_values_for_formats = False export_fields = (
('id', 'ID'),
('periodo', 'Período'),
('titulo', 'Matéria'),
('autor', 'Autor'),
('ementa', 'Ementa'),
('situacao', 'Situação')
)
fields_base_report = [ def get_queryset(self):
[('id', 'ID'), ('titulo', 'Matéria'), ('autor', 'Autor'), ('ementa', 'Ementa'), ('situacao', 'Situação')], qs = SessaoPlenaria.objects.all()
[('id', 'ID'), ('titulo', 'Matéria'), ('autor', 'Autor'), ('ementa', 'Ementa'), ('situacao', 'Situação')] if not self.request.user.is_authenticated:
] qs = qs.filter(publicar_pauta=True)
fields_report = { return qs
'csv': fields_base_report,
'xlsx': fields_base_report, def hook_autor(self, obj):
'json': fields_base_report, return ','.join(obj['autor'])
}
item_context = [ def hook_titulo(self, obj):
('materia_expediente', 'Matérias do Expediente'), return str(obj['titulo'])
('materias_ordem', 'Matérias da Ordem do Dia')
] def hook_situacao(self, obj):
return str(obj['situacao'])
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
from sapl.relatorios.views import relatorio_pauta_sessao_weasy # Evitar import ciclico from sapl.relatorios.views import relatorio_pauta_sessao_weasy # Evitar import ciclico
@ -3888,7 +3925,8 @@ class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView):
'situacao': ultima_tramitacao.status if ultima_tramitacao else _("Não informada"), 'situacao': ultima_tramitacao.status if ultima_tramitacao else _("Não informada"),
'processo': f'{str(numeracao.numero_materia)}/{str(numeracao.ano_materia)}' if numeracao else '-', 'processo': f'{str(numeracao.numero_materia)}/{str(numeracao.ano_materia)}' if numeracao else '-',
'autor': [str(x.autor) for x in m.materia.autoria_set.select_related('autor').all()], 'autor': [str(x.autor) for x in m.materia.autoria_set.select_related('autor').all()],
'turno': get_turno(ultima_tramitacao.turno) if ultima_tramitacao else '' 'turno': get_turno(ultima_tramitacao.turno) if ultima_tramitacao else '',
'periodo': 'expediente',
}) })
context.update({'materia_expediente': materias_expediente}) context.update({'materia_expediente': materias_expediente})
@ -3972,7 +4010,8 @@ class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView):
'situacao': ultima_tramitacao.status if ultima_tramitacao else _("Não informada"), 'situacao': ultima_tramitacao.status if ultima_tramitacao else _("Não informada"),
'processo': f'{str(numeracao.numero_materia)}/{str(numeracao.ano_materia)}' if numeracao else '-', 'processo': f'{str(numeracao.numero_materia)}/{str(numeracao.ano_materia)}' if numeracao else '-',
'autor': [str(x.autor) for x in Autoria.objects.select_related("autor").filter(materia_id=o.materia_id)], 'autor': [str(x.autor) for x in Autoria.objects.select_related("autor").filter(materia_id=o.materia_id)],
'turno': get_turno(ultima_tramitacao.turno) if ultima_tramitacao else '' 'turno': get_turno(ultima_tramitacao.turno) if ultima_tramitacao else '',
'periodo': 'ordem dia',
}) })
context.update({ context.update({
@ -3987,6 +4026,10 @@ class PautaSessaoDetailView(PautaMultiFormatOutputMixin, DetailView):
return self.render_to_response(context) return self.render_to_response(context)
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView): class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView):
model = SessaoPlenaria model = SessaoPlenaria
filterset_class = SessaoPlenariaFilterSet filterset_class = SessaoPlenariaFilterSet
@ -3998,14 +4041,9 @@ class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView):
queryset_values_for_formats = False queryset_values_for_formats = False
fields_base_report = [ export_fields = [
'id', 'data_inicio', 'hora_inicio', 'data_fim', 'hora_fim', '', 'id', 'data_inicio', 'hora_inicio', 'data_fim', 'hora_fim', '',
] ]
fields_report = {
'csv': fields_base_report,
'xlsx': fields_base_report,
'json': fields_base_report,
}
def get_filterset_kwargs(self, filterset_class): def get_filterset_kwargs(self, filterset_class):
super().get_filterset_kwargs(filterset_class) super().get_filterset_kwargs(filterset_class)
@ -4015,6 +4053,8 @@ class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView):
qs = self.get_queryset().select_related( qs = self.get_queryset().select_related(
'tipo', 'sessao_legislativa', 'legislatura') 'tipo', 'sessao_legislativa', 'legislatura')
qs = restringe_sessoes_visiveis(qs, self.request.user)
qs = qs.distinct().order_by( qs = qs.distinct().order_by(
'-legislatura__numero', '-data_inicio', '-hora_inicio') '-legislatura__numero', '-data_inicio', '-hora_inicio')
@ -4079,6 +4119,7 @@ class PesquisarSessaoPlenariaView(MultiFormatOutputMixin, FilterView):
return r return r
class PesquisarPautaSessaoView(PesquisarSessaoPlenariaView): class PesquisarPautaSessaoView(PesquisarSessaoPlenariaView):
filterset_class = PautaSessaoFilterSet filterset_class = PautaSessaoFilterSet
template_name = 'sessao/pauta_sessao_filter.html' template_name = 'sessao/pauta_sessao_filter.html'
@ -4176,6 +4217,8 @@ class AdicionarVariasMateriasExpediente(PermissionRequiredForAppCrudMixin,
qr = self.request.GET.copy() qr = self.request.GET.copy()
form = AdicionarVariasMateriasForm
context['form'] = form
context['filter_url'] = ('&' + qr.urlencode()) if len(qr) > 0 else '' context['filter_url'] = ('&' + qr.urlencode()) if len(qr) > 0 else ''
context['pk_sessao'] = self.kwargs['pk'] context['pk_sessao'] = self.kwargs['pk']
@ -5377,6 +5420,10 @@ class CorrespondenciaCrud(MasterDetailCrud):
return obj return obj
@method_decorator(ratelimit(key=ratelimit_ip,
rate=RATE_LIMITER_RATE,
block=True),
name='dispatch')
class CorrespondenciaEmLoteView(PermissionRequiredMixin, FilterView): class CorrespondenciaEmLoteView(PermissionRequiredMixin, FilterView):
filterset_class = CorrespondenciaEmLoteFilterSet filterset_class = CorrespondenciaEmLoteFilterSet
template_name = 'sessao/em_lote/correspondencia.html' template_name = 'sessao/em_lote/correspondencia.html'

147
sapl/settings.py

@ -24,6 +24,8 @@ from unipath import Path
logging.captureWarnings(True) logging.captureWarnings(True)
logger = logging.getLogger(__name__)
host = socket.gethostbyname_ex(socket.gethostname())[0] host = socket.gethostbyname_ex(socket.gethostname())[0]
BASE_DIR = Path(__file__).ancestor(1) BASE_DIR = Path(__file__).ancestor(1)
@ -41,7 +43,7 @@ ALLOWED_HOSTS = ['*']
LOGIN_REDIRECT_URL = '/' LOGIN_REDIRECT_URL = '/'
LOGIN_URL = '/login/?next=' LOGIN_URL = '/login/?next='
SAPL_VERSION = '3.1.164-RC0' SAPL_VERSION = '3.1.165-RC3'
if DEBUG: if DEBUG:
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
@ -68,37 +70,37 @@ SAPL_APPS = (
) )
INSTALLED_APPS = ( INSTALLED_APPS = (
'django_admin_bootstrapped', # must come before django.contrib.admin 'django_admin_bootstrapped', # must come before django.contrib.admin
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.forms', 'django.forms',
'django_extensions', 'django_extensions',
'crispy_forms', 'crispy_forms',
'waffle', 'waffle',
'drf_spectacular', 'drf_spectacular',
'rest_framework', 'rest_framework',
'rest_framework.authtoken', 'rest_framework.authtoken',
'django_filters', 'django_filters',
'easy_thumbnails', 'easy_thumbnails',
'image_cropping', 'image_cropping',
'haystack', 'haystack',
'django.contrib.postgres', 'django.contrib.postgres',
'webpack_loader', 'webpack_loader',
'django_prometheus', 'django_prometheus',
) + SAPL_APPS ) + SAPL_APPS
# FTS = Full Text Search # FTS = Full Text Search
# Desabilita a indexação textual até encontramos uma solução para a issue # Desabilita a indexação textual até encontramos uma solução para a issue
@ -112,6 +114,10 @@ USE_SOLR = config('USE_SOLR', cast=bool, default=False)
SOLR_URL = config('SOLR_URL', cast=str, default='http://localhost:8983') SOLR_URL = config('SOLR_URL', cast=str, default='http://localhost:8983')
SOLR_COLLECTION = config('SOLR_COLLECTION', cast=str, default='sapl') SOLR_COLLECTION = config('SOLR_COLLECTION', cast=str, default='sapl')
# FOR HAYSTACK 3.3.1 (Django >= 3)
# SOLR_USER = config('SOLR_USER', cast=str)
# SOLR_PASSWORD = config('SOLR_PASSWORD', cast=str)
if USE_SOLR: if USE_SOLR:
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' # enable auto-index HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' # enable auto-index
SEARCH_BACKEND = 'haystack.backends.solr_backend.SolrEngine' SEARCH_BACKEND = 'haystack.backends.solr_backend.SolrEngine'
@ -124,24 +130,29 @@ HAYSTACK_CONNECTIONS = {
SEARCH_URL[0]: SEARCH_URL[1], SEARCH_URL[0]: SEARCH_URL[1],
'BATCH_SIZE': 1000, 'BATCH_SIZE': 1000,
'TIMEOUT': 20, 'TIMEOUT': 20,
# 'KWARGS': {
# 'timeout': 60,
# 'auth': (SOLR_USER, SOLR_PASSWORD), # Basic Auth
# },
}, },
} }
MIDDLEWARE = [ MIDDLEWARE = [
'sapl.middleware.request_id.RequestIdMiddleware',
'django_prometheus.middleware.PrometheusBeforeMiddleware', 'django_prometheus.middleware.PrometheusBeforeMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware', 'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'sapl.endpoint_restriction_middleware.EndpointRestrictionMiddleware', 'sapl.middleware.endpoint_restriction.EndpointRestrictionMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware',
'waffle.middleware.WaffleMiddleware', 'waffle.middleware.WaffleMiddleware',
'sapl.middleware.CheckWeakPasswordMiddleware', 'sapl.middleware.check_password.CheckWeakPasswordMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware',
] ]
if DEBUG: if DEBUG:
INSTALLED_APPS += ('debug_toolbar',) INSTALLED_APPS += ('debug_toolbar',)
@ -194,7 +205,7 @@ CACHES = {
'default': { 'default': {
'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache', 'BACKEND': 'django.core.cache.backends.filebased.FileBasedCache',
'LOCATION': '/var/tmp/django_cache', 'LOCATION': '/var/tmp/django_cache',
'OPTIONS': {"MAX_ENTRIES": 1000}, 'OPTIONS': {"MAX_ENTRIES": 10000},
} }
} }
@ -230,17 +241,47 @@ WSGI_APPLICATION = 'sapl.wsgi.application'
# Database # Database
# https://docs.djangoproject.com/en/1.8/ref/settings/#databases # https://docs.djangoproject.com/en/1.8/ref/settings/#databases
# Parse DATABASE_URL
# dj-database-url==0.5.0 is the latest compatible with Django 2.2, later versions required Django >= 4
# but it doesn't support OPTIONS tag, so we need setup_db_tz
# This should be removed once we are able to upgrade to Django >= 4
DATABASES = { DATABASES = {
'default': config( "default": config("DATABASE_URL", cast=db_url)
'DATABASE_URL', default='sqlite://:memory:',
cast=db_url,
)
} }
def setup_db_tz():
db = DATABASES["default"]
# Normalize legacy engine alias returned by old dj-database-url
if db.get("ENGINE") == "django.db.backends.postgresql_psycopg2":
db["ENGINE"] = "django.db.backends.postgresql"
# Force UTC per connection for Postgres (fixes Django’s utc_tzinfo_factory assertion)
if db.get("ENGINE") == "django.db.backends.postgresql":
opts = db.setdefault("OPTIONS", {})
existing = (opts.get("options") or "").strip()
force_utc = "-c timezone=UTC"
opts["options"] = f"{existing} {force_utc}".strip() if existing else force_utc
# ensure default TCP port if you use HOST; leave sockets alone if HOST is empty
if db.get("HOST") and not db.get("PORT"):
db["PORT"] = "5432"
# Add connection lifetime
# in recent dj-database-url versions, replace by config("DATABASE_URL", conn_max_age=300)
db["CONN_MAX_AGE"] = 300 # keep connections for 5 minutes
# Log if DEBUG mode
if config("DEBUG", default=False, cast=bool):
logger.debug("DB config: %r", db)
setup_db_tz()
IMAGE_CROPPING_JQUERY_URL = None IMAGE_CROPPING_JQUERY_URL = None
THUMBNAIL_PROCESSORS = ( THUMBNAIL_PROCESSORS = (
'image_cropping.thumbnail_processors.crop_corners', 'image_cropping.thumbnail_processors.crop_corners',
) + thumbnail_settings.THUMBNAIL_PROCESSORS ) + thumbnail_settings.THUMBNAIL_PROCESSORS
THUMBNAIL_SOURCE_GENERATORS = ( THUMBNAIL_SOURCE_GENERATORS = (
'sapl.utils.pil_image', 'sapl.utils.pil_image',
@ -271,11 +312,12 @@ WAFFLE_CREATE_MISSING_SWITCHES = True
WAFFLE_LOG_MISSING_SWITCHES = True WAFFLE_LOG_MISSING_SWITCHES = True
WAFFLE_ENABLE_ADMIN_PAGES = True WAFFLE_ENABLE_ADMIN_PAGES = True
MAX_DOC_UPLOAD_SIZE = 150 * 1024 * 1024 # 150MB MAX_DOC_UPLOAD_SIZE = 150 * 1024 * 1024 # 150MB
MAX_IMAGE_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB MAX_IMAGE_UPLOAD_SIZE = 2 * 1024 * 1024 # 2MB
DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 # 10MB
RATE_LIMITER_RATE = config('RATE_LIMITER_RATE', default='35/m')
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/1.8/topics/i18n/ # https://docs.djangoproject.com/en/1.8/topics/i18n/
LANGUAGE_CODE = 'pt-br' LANGUAGE_CODE = 'pt-br'
@ -291,6 +333,30 @@ if not TIME_ZONE:
USE_I18N = True USE_I18N = True
USE_L10N = True USE_L10N = True
USE_TZ = True USE_TZ = True
##
## Monkey patch of the Django 2.2 because latest version of psycopg2 returns DB time zone as UTC,
## but Django 2.2 requires an int! This should be removed once we are able to upgrade to Django >= 4
##
import importlib
from django.utils.timezone import utc
pg_utils = importlib.import_module("django.db.backends.postgresql.utils")
def _compat_utc_tzinfo_factory(offset):
try:
minutes = int(offset.total_seconds() // 60) if hasattr(offset, "total_seconds") else int(offset)
except Exception:
raise AssertionError("database connection isn't set to UTC")
if minutes != 0:
raise AssertionError("database connection isn't set to UTC")
return utc
pg_utils.utc_tzinfo_factory = _compat_utc_tzinfo_factory
# DATE_FORMAT = 'N j, Y' # DATE_FORMAT = 'N j, Y'
DATE_FORMAT = 'd/m/Y' DATE_FORMAT = 'd/m/Y'
SHORT_DATE_FORMAT = 'd/m/Y' SHORT_DATE_FORMAT = 'd/m/Y'
@ -356,32 +422,37 @@ LOGGING = {
'require_debug_true': { 'require_debug_true': {
'()': 'django.utils.log.RequireDebugTrue', '()': 'django.utils.log.RequireDebugTrue',
}, },
'request_id': {
"()": 'sapl.logging.filters.RequestIdFilter',
},
}, },
'formatters': { 'formatters': {
'verbose': { 'verbose': {
'format': '%(levelname)s %(asctime)s ' + host + ' %(pathname)s %(name)s:%(funcName)s:%(lineno)d %(message)s' 'format': '%(levelname)s %(asctime)s [%(request_id)s] ' + host + '%(pathname)s %(name)s:%(funcName)s:%('
'lineno)d %(message)s '
}, },
'simple': { 'simple': {
'format': '%(levelname)s %(asctime)s - %(message)s' 'format': '%(levelname)s %(asctime)s [%(request_id)s] - %(message)s'
}, },
}, },
'handlers': { 'handlers': {
'console': { 'console': {
'level': 'INFO', 'level': 'INFO',
'class': 'logging.StreamHandler', 'class': 'logging.StreamHandler',
'filters': ['require_debug_true'], 'filters': ['request_id', 'require_debug_true'],
'formatter': 'simple', 'formatter': 'simple',
}, },
'console_verbose': { 'console_verbose': {
'level': 'DEBUG', 'level': 'DEBUG',
'class': 'logging.StreamHandler', 'class': 'logging.StreamHandler',
'filters': ['require_debug_true'], 'filters': ['request_id', 'require_debug_true'],
'formatter': 'verbose', 'formatter': 'verbose',
}, },
'applogfile': { 'applogfile': {
'level': 'INFO', 'level': 'INFO',
'class': 'logging.handlers.RotatingFileHandler', 'class': 'logging.handlers.RotatingFileHandler',
'filename': 'sapl.log', 'filename': 'sapl.log',
'filters': ['request_id'],
'maxBytes': 1024 * 1024 * 15, # 15MB 'maxBytes': 1024 * 1024 * 15, # 15MB
'backupCount': 10, 'backupCount': 10,
'formatter': 'verbose', 'formatter': 'verbose',

5
sapl/static/.well-known/traffic-advice

@ -0,0 +1,5 @@
{
"interval": 300,
"requests-per-second": 5,
"burst": 10
}

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

Loading…
Cancel
Save