mirror of https://github.com/interlegis/sapl.git
committed by
GitHub
129 changed files with 5085 additions and 1278 deletions
@ -0,0 +1,19 @@ |
|||||
|
import pytest |
||||
|
from model_bakery import baker |
||||
|
|
||||
|
from sapl.api.serializers import SessaoPlenariaECidadaniaSerializer |
||||
|
from sapl.base.models import CasaLegislativa |
||||
|
from sapl.sessao.models import SessaoPlenaria |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_sessao_ecidadania_serializa_dados_da_casa(): |
||||
|
casa = baker.make(CasaLegislativa, nome='Câmara Municipal', sigla='CM', |
||||
|
endereco='Praça Central') |
||||
|
sessao = baker.make(SessaoPlenaria) |
||||
|
|
||||
|
data = SessaoPlenariaECidadaniaSerializer(sessao).data |
||||
|
|
||||
|
assert data['txtNomeOrgao'] == casa.nome |
||||
|
assert data['txtSiglaOrgao'] == casa.sigla |
||||
|
assert data['txtLocal'] == casa.endereco |
||||
@ -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'), |
||||
|
), |
||||
|
] |
||||
@ -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'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,124 @@ |
|||||
|
import mimetypes |
||||
|
import os |
||||
|
import re |
||||
|
from urllib.parse import quote |
||||
|
|
||||
|
from django.conf import settings |
||||
|
from django.http import FileResponse, Http404, HttpResponse |
||||
|
|
||||
|
from sapl.materia.models import Proposicao, pode_ver_proposicao |
||||
|
from sapl.protocoloadm.models import (DocumentoAcessorioAdministrativo, |
||||
|
DocumentoAdministrativo, |
||||
|
pode_ver_documento_adm) |
||||
|
from sapl.sessao.models import (JustificativaAusencia, Orador, |
||||
|
OradorExpediente, OradorOrdemDia, |
||||
|
SessaoPlenaria, restringe_sessoes_visiveis) |
||||
|
from sapl.utils import EXTENSOES_ATIVAS, tipo_ativo |
||||
|
|
||||
|
X_ACCEL_PREFIX = '/media-interna/' |
||||
|
|
||||
|
# O nginx do container só envia para cá os caminhos de PROTEGIDO_RE e de |
||||
|
# MODELOS_DE_SESSAO; o resto de /media/ sai direto do disco. Ao incluir um |
||||
|
# modelo aqui, incluí-lo também em docker/config/nginx/sapl.conf. |
||||
|
|
||||
|
# Arquivos que dependem de autorização, em private/ ou em qualquer outra |
||||
|
# pasta (public/ guarda os enviados antes da criação de private/). |
||||
|
PROTEGIDO_RE = re.compile( |
||||
|
r'^sapl/[^/]+/(?:documentoadministrativo|' |
||||
|
r'documentoacessorioadministrativo|proposicao)/') |
||||
|
|
||||
|
RESOLVEDORES = { |
||||
|
'documentoadministrativo': ( |
||||
|
DocumentoAdministrativo, 'texto_integral', pode_ver_documento_adm), |
||||
|
'documentoacessorioadministrativo': ( |
||||
|
DocumentoAcessorioAdministrativo, 'arquivo', |
||||
|
lambda user, obj: pode_ver_documento_adm(user, obj.documento)), |
||||
|
'proposicao': ( |
||||
|
Proposicao, 'texto_original', pode_ver_proposicao), |
||||
|
} |
||||
|
|
||||
|
|
||||
|
# Arquivos gravados em sapl/public/<model>/<pk>/..., que seguem a |
||||
|
# visibilidade da sessão plenária a que pertencem. |
||||
|
MODELOS_DE_SESSAO = {m._meta.model_name: m for m in ( |
||||
|
SessaoPlenaria, Orador, OradorExpediente, OradorOrdemDia, |
||||
|
JustificativaAusencia)} |
||||
|
|
||||
|
|
||||
|
def _de_sessao_oculta(request, partes): |
||||
|
model = MODELOS_DE_SESSAO.get(partes[2]) |
||||
|
if request.user.is_authenticated or not model or len(partes) < 5 or \ |
||||
|
partes[1] != 'public' or not re.fullmatch(r'[0-9]+', partes[3]): |
||||
|
return False |
||||
|
if model is SessaoPlenaria: |
||||
|
sessoes = SessaoPlenaria.objects.filter(pk=partes[3]) |
||||
|
else: |
||||
|
sessoes = SessaoPlenaria.objects.filter( |
||||
|
pk__in=model.objects.filter(pk=partes[3]).values( |
||||
|
'sessao_plenaria_id')) |
||||
|
return sessoes.exists() and not restringe_sessoes_visiveis( |
||||
|
sessoes, request.user).exists() |
||||
|
|
||||
|
|
||||
|
def _nome_protegido(request, path): |
||||
|
"""Devolve o nome do arquivo gravado no objeto dono, se o usuário puder |
||||
|
vê-lo. O caminho servido vem do banco, nunca da URL.""" |
||||
|
resolvedor = RESOLVEDORES.get(path.split('/')[2]) |
||||
|
if not resolvedor: |
||||
|
raise Http404 |
||||
|
model, campo, pode_ver = resolvedor |
||||
|
obj = model.objects.filter( |
||||
|
**{campo + '__in': [path, './' + path]}).first() |
||||
|
if not obj: |
||||
|
raise Http404 |
||||
|
arquivo = getattr(obj, campo) |
||||
|
if not arquivo or not pode_ver(request.user, obj): |
||||
|
raise Http404 |
||||
|
return arquivo.name[2:] if arquivo.name.startswith('./') else arquivo.name |
||||
|
|
||||
|
|
||||
|
def _caminho_em_media(nome): |
||||
|
raiz = os.path.realpath(settings.MEDIA_ROOT) |
||||
|
caminho = os.path.realpath(os.path.join(raiz, nome)) |
||||
|
if not caminho.startswith(raiz + os.sep): |
||||
|
raise Http404 |
||||
|
return caminho |
||||
|
|
||||
|
|
||||
|
def media(request, path): |
||||
|
partes = path.split('/') |
||||
|
if any(p in ('', '.', '..') for p in partes) or '\x00' in path: |
||||
|
raise Http404 |
||||
|
|
||||
|
protegido = path.startswith('sapl/private/') or bool( |
||||
|
PROTEGIDO_RE.match(path)) |
||||
|
nome = _nome_protegido(request, path) if protegido else path |
||||
|
|
||||
|
if len(partes) > 2 and _de_sessao_oculta(request, partes): |
||||
|
raise Http404 |
||||
|
nome_arquivo = os.path.basename(nome) |
||||
|
|
||||
|
content_type = (mimetypes.guess_type(nome_arquivo)[0] or |
||||
|
'application/octet-stream') |
||||
|
disposicao = 'inline' |
||||
|
if os.path.splitext(nome_arquivo)[1].lower() in EXTENSOES_ATIVAS or \ |
||||
|
tipo_ativo(content_type): |
||||
|
content_type, disposicao = 'application/octet-stream', 'attachment' |
||||
|
|
||||
|
caminho = _caminho_em_media(nome) |
||||
|
if settings.DEBUG: |
||||
|
try: |
||||
|
response = FileResponse(open(caminho, 'rb'), |
||||
|
content_type=content_type) |
||||
|
except (FileNotFoundError, IsADirectoryError): |
||||
|
raise Http404 |
||||
|
else: |
||||
|
response = HttpResponse(content_type=content_type) |
||||
|
response['X-Accel-Redirect'] = X_ACCEL_PREFIX + quote(nome) |
||||
|
|
||||
|
response['Content-Disposition'] = "%s; filename*=UTF-8''%s" % ( |
||||
|
disposicao, quote(nome_arquivo)) |
||||
|
response['X-Content-Type-Options'] = 'nosniff' |
||||
|
if protegido or (len(partes) > 2 and partes[2] in MODELOS_DE_SESSAO): |
||||
|
response['Cache-Control'] = 'private' |
||||
|
return response |
||||
@ -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'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,384 @@ |
|||||
|
import os |
||||
|
import re |
||||
|
|
||||
|
from django.conf import settings |
||||
|
from django.contrib.auth import get_user_model |
||||
|
from django.contrib.auth.models import AnonymousUser |
||||
|
from django.contrib.auth.models import Permission |
||||
|
from django.contrib.contenttypes.models import ContentType |
||||
|
from django.core.cache import cache |
||||
|
from django.http import Http404 |
||||
|
from django.utils import timezone |
||||
|
from model_bakery import baker |
||||
|
import pytest |
||||
|
|
||||
|
from sapl.base.media import MODELOS_DE_SESSAO, RESOLVEDORES, media |
||||
|
from sapl.base.models import AppConfig, Autor |
||||
|
from sapl.materia.models import Proposicao |
||||
|
from sapl.protocoloadm.models import (DocumentoAcessorioAdministrativo, |
||||
|
DocumentoAdministrativo) |
||||
|
from sapl.utils import EXTENSOES_ATIVAS |
||||
|
|
||||
|
DOC_PATH = 'sapl/private/documentoadministrativo/2026/1/texto.pdf' |
||||
|
ACESSORIO_PATH = 'sapl/public/documentoacessorioadministrativo/2026/1/a.pdf' |
||||
|
PROPOSICAO_PATH = 'sapl/private/proposicao/2026/1/proposicao.pdf' |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(autouse=True) |
||||
|
def limpa_cache_appconfig(): |
||||
|
# o cache em arquivo sobrevive ao rollback do teste |
||||
|
cache.clear() |
||||
|
yield |
||||
|
cache.clear() |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(autouse=True) |
||||
|
def sem_debug(settings): |
||||
|
settings.DEBUG = False |
||||
|
|
||||
|
|
||||
|
def _config(comportamento): |
||||
|
cache.clear() |
||||
|
AppConfig.objects.all().delete() |
||||
|
baker.make(AppConfig, documentos_administrativos=comportamento) |
||||
|
|
||||
|
|
||||
|
def _login(client, username, *perms): |
||||
|
user = baker.make(get_user_model(), username=username) |
||||
|
for perm in perms: |
||||
|
app_label, codename = perm.split('.') |
||||
|
perm_obj = Permission.objects.filter( |
||||
|
content_type__app_label=app_label, codename=codename).first() |
||||
|
if not perm_obj: |
||||
|
perm_obj = Permission.objects.create( |
||||
|
codename=codename, name=codename, |
||||
|
content_type=ContentType.objects.get_for_model( |
||||
|
DocumentoAdministrativo)) |
||||
|
user.user_permissions.add(perm_obj) |
||||
|
client.force_login(user) |
||||
|
return user |
||||
|
|
||||
|
|
||||
|
def _get(client, path): |
||||
|
return client.get('/media/' + path) |
||||
|
|
||||
|
|
||||
|
def _assert_servido(response, path): |
||||
|
assert response.status_code == 200 |
||||
|
assert response['X-Accel-Redirect'] == '/media-interna/' + path |
||||
|
assert response['X-Content-Type-Options'] == 'nosniff' |
||||
|
assert response['Cache-Control'] == 'private' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
@pytest.mark.parametrize('comportamento,restrito,perfil,pode_ver', [ |
||||
|
('O', False, None, True), |
||||
|
('O', True, None, False), |
||||
|
('O', True, 'logado', True), |
||||
|
('R', False, None, False), |
||||
|
('R', False, 'logado', False), |
||||
|
('R', True, None, False), |
||||
|
('R', True, 'logado', False), |
||||
|
('R', True, 'com_permissao', True), |
||||
|
]) |
||||
|
def test_documento_administrativo(client, comportamento, restrito, perfil, |
||||
|
pode_ver): |
||||
|
_config(comportamento) |
||||
|
baker.make(DocumentoAdministrativo, restrito=restrito, |
||||
|
texto_integral=DOC_PATH) |
||||
|
if perfil == 'logado': |
||||
|
_login(client, 'logado') |
||||
|
elif perfil == 'com_permissao': |
||||
|
_login(client, 'perm', |
||||
|
'protocoloadm.detail_documentoadministrativo') |
||||
|
|
||||
|
response = _get(client, DOC_PATH) |
||||
|
|
||||
|
if pode_ver: |
||||
|
_assert_servido(response, DOC_PATH) |
||||
|
assert response['Content-Type'] == 'application/pdf' |
||||
|
else: |
||||
|
assert response.status_code == 404 |
||||
|
assert not response.has_header('X-Accel-Redirect') |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
@pytest.mark.parametrize('restrito,pode_ver', [(False, True), (True, False)]) |
||||
|
def test_documento_acessorio_segue_documento(client, restrito, pode_ver): |
||||
|
_config('O') |
||||
|
documento = baker.make(DocumentoAdministrativo, restrito=restrito) |
||||
|
baker.make(DocumentoAcessorioAdministrativo, documento=documento, |
||||
|
arquivo=ACESSORIO_PATH) |
||||
|
|
||||
|
response = _get(client, ACESSORIO_PATH) |
||||
|
|
||||
|
assert response.status_code == (200 if pode_ver else 404) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_caminho_legado_e_prefixo_ponto_barra(client): |
||||
|
_config('O') |
||||
|
legado = 'sapl/public/documentoadministrativo/2010/7/antigo.pdf' |
||||
|
baker.make(DocumentoAdministrativo, texto_integral=legado) |
||||
|
baker.make(DocumentoAdministrativo, texto_integral='./' + DOC_PATH) |
||||
|
|
||||
|
_assert_servido(_get(client, legado), legado) |
||||
|
_assert_servido(_get(client, DOC_PATH), DOC_PATH) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
@pytest.mark.parametrize('path', [ |
||||
|
'sapl/private/documentoadministrativo/2026/1/inexistente.pdf', |
||||
|
'sapl/private/outromodelo/2026/1/texto.pdf', |
||||
|
'sapl/private/documentoadministrativo/../../public/x.pdf', |
||||
|
'sapl/private/documentoadministrativo/%2e%2e/x.pdf', |
||||
|
]) |
||||
|
def test_caminho_invalido(client, path): |
||||
|
_config('O') |
||||
|
baker.make(DocumentoAdministrativo, texto_integral=DOC_PATH) |
||||
|
|
||||
|
assert _get(client, path).status_code == 404 |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_extensao_ativa_e_baixada(client): |
||||
|
_config('O') |
||||
|
path = 'sapl/private/documentoadministrativo/2026/1/x.html' |
||||
|
baker.make(DocumentoAdministrativo, texto_integral=path) |
||||
|
|
||||
|
response = _get(client, path) |
||||
|
|
||||
|
_assert_servido(response, path) |
||||
|
assert response['Content-Type'] == 'application/octet-stream' |
||||
|
assert response['Content-Disposition'].startswith('attachment;') |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_nome_nao_ascii_codificado(client): |
||||
|
_config('O') |
||||
|
path = 'sapl/private/documentoadministrativo/2026/1/ofício_nº1.pdf' |
||||
|
baker.make(DocumentoAdministrativo, texto_integral=path) |
||||
|
|
||||
|
response = _get(client, path) |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
assert response['X-Accel-Redirect'] == ( |
||||
|
'/media-interna/sapl/private/documentoadministrativo/2026/1/' |
||||
|
'of%C3%ADcio_n%C2%BA1.pdf') |
||||
|
|
||||
|
|
||||
|
ESTADOS_PROPOSICAO = ('rascunho', 'enviada', 'devolvida', 'incorporada') |
||||
|
PERMS_DOCADM = ['protocoloadm.add_documentoadministrativo', |
||||
|
'protocoloadm.change_documentoadministrativo', |
||||
|
'protocoloadm.detail_documentoadministrativo'] |
||||
|
|
||||
|
|
||||
|
def _proposicao(estado): |
||||
|
autor = baker.make(Autor) |
||||
|
operador = baker.make(get_user_model(), username='operador') |
||||
|
autor.operadores.add(operador) |
||||
|
agora = timezone.now() |
||||
|
# mesmos campos gravados pelo envio, devolução e incorporação |
||||
|
baker.make(Proposicao, autor=autor, texto_original=PROPOSICAO_PATH, |
||||
|
data_envio=agora if estado in ('enviada', 'incorporada') |
||||
|
else None, |
||||
|
data_recebimento=agora if estado == 'incorporada' else None, |
||||
|
data_devolucao=agora if estado == 'devolvida' else None) |
||||
|
return operador |
||||
|
|
||||
|
|
||||
|
def _login_proposicao(client, perfil, operador): |
||||
|
if perfil == 'operador': |
||||
|
client.force_login(operador) |
||||
|
elif perfil == 'docadm': |
||||
|
user = _login(client, 'docadm', *PERMS_DOCADM) |
||||
|
assert user.has_module_perms('protocoloadm') |
||||
|
elif perfil == 'outro': |
||||
|
_login(client, 'outro') |
||||
|
elif perfil: |
||||
|
_login(client, perfil, 'materia.detail_proposicao_' + perfil) |
||||
|
|
||||
|
|
||||
|
def _pode_ver_proposicao(estado, perfil): |
||||
|
return perfil == 'operador' or perfil == estado != 'rascunho' |
||||
|
|
||||
|
|
||||
|
GRADE_PROPOSICAO = [ |
||||
|
(estado, perfil, _pode_ver_proposicao(estado, perfil)) |
||||
|
for estado in ESTADOS_PROPOSICAO |
||||
|
for perfil in (None, 'operador', 'outro', 'docadm', 'enviada', |
||||
|
'devolvida', 'incorporada')] |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
@pytest.mark.parametrize('estado,perfil,pode_ver', GRADE_PROPOSICAO) |
||||
|
def test_proposicao(client, estado, perfil, pode_ver): |
||||
|
_login_proposicao(client, perfil, _proposicao(estado)) |
||||
|
|
||||
|
response = _get(client, PROPOSICAO_PATH) |
||||
|
|
||||
|
if pode_ver: |
||||
|
_assert_servido(response, PROPOSICAO_PATH) |
||||
|
else: |
||||
|
assert response.status_code == 404 |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_arquivo_publico_passa_por_x_accel(client): |
||||
|
path = 'sapl/public/materialegislativa/2026/1/materia.pdf' |
||||
|
|
||||
|
response = _get(client, path) |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
assert response['X-Accel-Redirect'] == '/media-interna/' + path |
||||
|
assert response['Content-Type'] == 'application/pdf' |
||||
|
assert response['X-Content-Type-Options'] == 'nosniff' |
||||
|
assert not response.has_header('Cache-Control') |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
@pytest.mark.parametrize('nome', ['x.svg', 'x.html', 'x.XML', 'x.js', |
||||
|
'x.xht', 'x.rdf', 'x.xslt', 'x.kml']) |
||||
|
def test_arquivo_publico_ativo_e_baixado(client, nome): |
||||
|
response = _get(client, 'sapl/public/normajuridica/2026/1/' + nome) |
||||
|
|
||||
|
assert response['Content-Type'] == 'application/octet-stream' |
||||
|
assert response['Content-Disposition'].startswith('attachment;') |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
@pytest.mark.parametrize('path', [ |
||||
|
'sapl/public/../private/documentoadministrativo/2026/1/texto.pdf', |
||||
|
'sapl//private/documentoadministrativo/2026/1/texto.pdf', |
||||
|
'sapl/./private/documentoadministrativo/2026/1/texto.pdf', |
||||
|
'sapl/public/normajuridica/', |
||||
|
]) |
||||
|
def test_caminho_nao_normalizado(client, path): |
||||
|
assert _get(client, path).status_code == 404 |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_debug_serve_arquivo(rf, settings, tmp_path): |
||||
|
settings.DEBUG = True |
||||
|
settings.MEDIA_ROOT = str(tmp_path) |
||||
|
_config('R') |
||||
|
publico = tmp_path / 'sapl/public/normajuridica/1/n.pdf' |
||||
|
publico.parent.mkdir(parents=True) |
||||
|
publico.write_bytes(b'%PDF-1.4 norma') |
||||
|
baker.make(DocumentoAdministrativo, texto_integral=DOC_PATH) |
||||
|
|
||||
|
def chama(path): |
||||
|
request = rf.get('/media/' + path) |
||||
|
request.user = AnonymousUser() |
||||
|
return media(request, path) |
||||
|
|
||||
|
response = chama('sapl/public/normajuridica/1/n.pdf') |
||||
|
assert b''.join(response.streaming_content) == b'%PDF-1.4 norma' |
||||
|
assert not response.has_header('X-Accel-Redirect') |
||||
|
|
||||
|
for path in (DOC_PATH, 'sapl/public/normajuridica/1/nao.pdf'): |
||||
|
with pytest.raises(Http404): |
||||
|
chama(path) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
@pytest.mark.parametrize('debug', [False, True]) |
||||
|
def test_link_simbolico_para_fora_de_media(rf, settings, tmp_path, debug): |
||||
|
settings.DEBUG = debug |
||||
|
media_root = tmp_path / 'media' |
||||
|
fora = tmp_path / 'fora' |
||||
|
(media_root / 'sapl/public').mkdir(parents=True) |
||||
|
fora.mkdir() |
||||
|
(fora / 'segredo.txt').write_bytes(b'segredo') |
||||
|
(media_root / 'sapl/public/link').symlink_to(fora) |
||||
|
settings.MEDIA_ROOT = str(media_root) |
||||
|
request = rf.get('/media/sapl/public/link/segredo.txt') |
||||
|
request.user = AnonymousUser() |
||||
|
|
||||
|
with pytest.raises(Http404): |
||||
|
media(request, 'sapl/public/link/segredo.txt') |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
@pytest.mark.parametrize('path', ['/etc/passwd', 'sapl/public//etc/passwd']) |
||||
|
def test_caminho_absoluto(rf, path): |
||||
|
request = rf.get('/media/' + path) |
||||
|
request.user = AnonymousUser() |
||||
|
|
||||
|
with pytest.raises(Http404): |
||||
|
media(request, path) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_modelo_protegido_fora_de_private_e_public(client): |
||||
|
_config('O') |
||||
|
path = 'sapl/outra/documentoadministrativo/2026/1/texto.pdf' |
||||
|
baker.make(DocumentoAdministrativo, restrito=True, texto_integral=path) |
||||
|
|
||||
|
assert _get(client, path).status_code == 404 |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_senha_fraca_nao_redireciona_media(client): |
||||
|
_login(client, 'fraca') |
||||
|
session = client.session |
||||
|
session['weak_password'] = True |
||||
|
session.save() |
||||
|
|
||||
|
response = _get(client, 'sapl/public/casalegislativa/logotipo.png') |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
assert response.has_header('X-Accel-Redirect') |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
@pytest.mark.parametrize('estado,perfil,pode_ver', GRADE_PROPOSICAO) |
||||
|
def test_proposicao_texto(client, settings, tmp_path, estado, perfil, |
||||
|
pode_ver): |
||||
|
settings.MEDIA_ROOT = str(tmp_path) |
||||
|
arquivo = tmp_path / PROPOSICAO_PATH |
||||
|
arquivo.parent.mkdir(parents=True) |
||||
|
arquivo.write_bytes(b'%PDF-1.4 proposicao') |
||||
|
_login_proposicao(client, perfil, _proposicao(estado)) |
||||
|
pk = Proposicao.objects.get(texto_original=PROPOSICAO_PATH).pk |
||||
|
|
||||
|
response = client.get('/proposicao/texto/%s' % pk) |
||||
|
|
||||
|
if pode_ver: |
||||
|
assert response.status_code == 200 |
||||
|
assert response.content == b'%PDF-1.4 proposicao' |
||||
|
else: |
||||
|
assert response.status_code == 302 |
||||
|
assert response['Location'] == '/proposicao/%s' % pk |
||||
|
|
||||
|
|
||||
|
def test_nginx_envia_ao_django_todos_os_caminhos_protegidos(): |
||||
|
caminho = os.path.join(settings.PROJECT_DIR, 'docker', 'config', |
||||
|
'nginx', 'sapl.conf') |
||||
|
with open(caminho) as f: |
||||
|
conf = f.read() |
||||
|
regex = re.search(r'location ~\*? \^/media/sapl/\(private/\|' |
||||
|
r'\[\^/\]\+/\(([a-z|]+)\)/\)', conf) |
||||
|
|
||||
|
assert regex, 'location de arquivos protegidos não encontrada' |
||||
|
no_nginx = set(regex.group(1).split('|')) |
||||
|
assert set(RESOLVEDORES) | set(MODELOS_DE_SESSAO) <= no_nginx |
||||
|
assert conf.index(regex.group(0)) < conf.index('location /media/ {') |
||||
|
|
||||
|
|
||||
|
def test_nginx_forca_download_das_extensoes_ativas(): |
||||
|
caminho = os.path.join(settings.PROJECT_DIR, 'docker', 'config', |
||||
|
'nginx', 'sapl.conf') |
||||
|
with open(caminho) as f: |
||||
|
conf = f.read() |
||||
|
regex = re.search(r'location ~\*? \^/media/\.\+\\\.\(([a-z?|]+)\)\$', |
||||
|
conf) |
||||
|
|
||||
|
assert regex, 'location de extensões ativas não encontrada' |
||||
|
no_nginx = set() |
||||
|
for ext in regex.group(1).split('|'): |
||||
|
if ext.endswith('?'): |
||||
|
no_nginx |= {ext[:-2], ext[:-1]} |
||||
|
else: |
||||
|
no_nginx.add(ext) |
||||
|
assert {'.' + e for e in no_nginx} == EXTENSOES_ATIVAS |
||||
@ -0,0 +1,228 @@ |
|||||
|
import pytest |
||||
|
from django.db.models.signals import pre_save |
||||
|
from django.template import Context, Template |
||||
|
from model_bakery import baker |
||||
|
|
||||
|
from sapl.compilacao.forms import TipoDispositivoForm |
||||
|
from sapl.compilacao.models import TipoDispositivo, TipoTextoArticulado |
||||
|
from sapl.crispy_layout_mixin import get_field_display |
||||
|
from sapl.lexml.models import LexmlProvedor |
||||
|
from sapl.parlamentares.models import Parlamentar |
||||
|
from sapl.protocoloadm.models import TramitacaoAdministrativo |
||||
|
from sapl.sanitize import (html_fragment_is_balanced, sanitize_field, |
||||
|
sanitize_html, sanitize_scope) |
||||
|
from sapl.sessao.models import ExpedienteSessao |
||||
|
|
||||
|
|
||||
|
def test_plain_remove_marcacao_e_preserva_texto(): |
||||
|
assert sanitize_html('<script>alert(1)</script>Ciente') == 'Ciente' |
||||
|
assert sanitize_html('Encaminhado <b>ao</b> setor') == \ |
||||
|
'Encaminhado ao setor' |
||||
|
assert sanitize_html('<img src=x onerror=alert(1)>') == '' |
||||
|
assert sanitize_html('<div>a</div><div>b</div>') == 'ab' |
||||
|
|
||||
|
|
||||
|
def test_plain_guarda_texto_puro_sem_escape(): |
||||
|
"""O escape é da renderização; gravado escapado, apareceria & na tela.""" |
||||
|
assert sanitize_html('Valor < 10 & prazo > 5') == 'Valor < 10 & prazo > 5' |
||||
|
assert sanitize_html('ALFA & BETA') == 'ALFA & BETA' |
||||
|
|
||||
|
|
||||
|
def test_plain_preserva_quebras_de_linha(): |
||||
|
# get_field_display converte \n em <br/> depois de sanitizar |
||||
|
assert sanitize_html('linha1\nlinha2') == 'linha1\nlinha2' |
||||
|
|
||||
|
|
||||
|
def test_valores_vazios_atravessam(): |
||||
|
assert sanitize_html('') == '' |
||||
|
assert sanitize_html(None) is None |
||||
|
assert sanitize_html('', rich=True) == '' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.parametrize('valor', [ |
||||
|
'<script>alert(1)</script>Ciente', |
||||
|
'Valor < 10 & prazo > 5', |
||||
|
'Encaminhado <b>ao</b> <a href="https://x">setor</a>', |
||||
|
'texto & cia', |
||||
|
]) |
||||
|
def test_plain_e_idempotente(valor): |
||||
|
"""Salvar de novo um registro já sanitizado não altera o texto.""" |
||||
|
uma_vez = sanitize_html(valor) |
||||
|
assert sanitize_html(uma_vez) == uma_vez |
||||
|
|
||||
|
|
||||
|
@pytest.mark.parametrize('valor', [ |
||||
|
'<a href="https://camara.gov.br" target="_blank">Portal</a>', |
||||
|
'<p style="text-align: center;">centro</p>', |
||||
|
'<table><tr><td colspan="2">c</td></tr></table>', |
||||
|
'<script>alert(1)</script><b>ok</b>', |
||||
|
]) |
||||
|
def test_rich_e_idempotente(valor): |
||||
|
uma_vez = sanitize_html(valor, rich=True) |
||||
|
assert sanitize_html(uma_vez, rich=True) == uma_vez |
||||
|
|
||||
|
|
||||
|
def test_rich_preserva_links(): |
||||
|
saida = sanitize_html( |
||||
|
'<a href="https://camara.gov.br" target="_blank">Portal</a>', |
||||
|
rich=True) |
||||
|
assert 'href="https://camara.gov.br"' in saida |
||||
|
assert 'target="_blank"' in saida |
||||
|
assert 'rel="noopener noreferrer"' in saida |
||||
|
assert '>Portal</a>' in saida |
||||
|
|
||||
|
assert 'href="/materia/123"' in sanitize_html( |
||||
|
'<a href="/materia/123">Matéria</a>', rich=True) |
||||
|
assert 'href="mailto:a@b.c"' in sanitize_html( |
||||
|
'<a href="mailto:a@b.c">mail</a>', rich=True) |
||||
|
|
||||
|
|
||||
|
def test_rich_remove_href_perigosa_mas_mantem_o_texto(): |
||||
|
saida = sanitize_html( |
||||
|
'<a href="javascript:alert(1)">clique</a>', rich=True) |
||||
|
assert 'javascript' not in saida |
||||
|
assert 'clique' in saida |
||||
|
|
||||
|
|
||||
|
def test_rich_remove_script_e_manipuladores_de_evento(): |
||||
|
saida = sanitize_html('<script>alert(1)</script><b>ok</b>', rich=True) |
||||
|
assert saida == '<b>ok</b>' |
||||
|
|
||||
|
assert 'onclick' not in sanitize_html( |
||||
|
'<a href="#" onclick="steal()">x</a>', rich=True) |
||||
|
assert 'onerror' not in sanitize_html( |
||||
|
'<img src="x" onerror="alert(1)">', rich=True) |
||||
|
|
||||
|
|
||||
|
def test_rich_preserva_formatacao_do_tinymce(): |
||||
|
"""Protege contra regressão visual no conteúdo já cadastrado.""" |
||||
|
assert 'style="text-align: center;"' in sanitize_html( |
||||
|
'<p style="text-align: center;">centro</p>', rich=True) |
||||
|
|
||||
|
saida = sanitize_html( |
||||
|
'<table><tr><td colspan="2">c</td></tr></table>', rich=True) |
||||
|
assert '<table>' in saida and 'colspan="2"' in saida |
||||
|
|
||||
|
assert sanitize_html('<ul><li>a</li><li>b</li></ul>', rich=True) == \ |
||||
|
'<ul><li>a</li><li>b</li></ul>' |
||||
|
|
||||
|
|
||||
|
def test_sanitize_scope(): |
||||
|
assert sanitize_scope(TramitacaoAdministrativo, 'texto') == 'plain' |
||||
|
assert sanitize_scope(ExpedienteSessao, 'conteudo') == 'rich' |
||||
|
assert sanitize_scope(LexmlProvedor, 'xml') == 'exempt' |
||||
|
assert sanitize_scope(TipoTextoArticulado, 'rodape_global') == 'exempt' |
||||
|
assert sanitize_scope(Parlamentar, 'biografia') == 'rich' |
||||
|
|
||||
|
|
||||
|
def test_sanitize_field_respeita_isencao(): |
||||
|
xml = '<xml><a href="javascript:x">y</a></xml>' |
||||
|
assert sanitize_field(LexmlProvedor, 'xml', xml) == xml |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db |
||||
|
def test_pre_save_sanitiza_campo_simples(): |
||||
|
t = baker.make(TramitacaoAdministrativo, |
||||
|
texto='<script>alert(1)</script>Ciente <b>ok</b>') |
||||
|
t.refresh_from_db() |
||||
|
assert t.texto == 'Ciente ok' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db |
||||
|
def test_pre_save_sanitiza_campo_rico_preservando_html(): |
||||
|
e = baker.make(ExpedienteSessao, |
||||
|
conteudo='<b>x</b><script>alert(1)</script>' |
||||
|
'<a href="https://a.b" target="_blank">l</a>') |
||||
|
e.refresh_from_db() |
||||
|
assert '<script>' not in e.conteudo |
||||
|
assert '<b>x</b>' in e.conteudo |
||||
|
assert 'href="https://a.b"' in e.conteudo |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db |
||||
|
def test_pre_save_nao_toca_modelo_isento(): |
||||
|
xml = '<xml>a & b <script>x</script></xml>' |
||||
|
p = baker.make(LexmlProvedor, xml=xml) |
||||
|
p.refresh_from_db() |
||||
|
assert p.xml == xml |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db |
||||
|
def test_get_field_display_nao_devolve_script(): |
||||
|
t = TramitacaoAdministrativo(texto='<script>alert(1)</script>Ciente') |
||||
|
__, display = get_field_display(t, 'texto') |
||||
|
assert '<script>' not in display |
||||
|
assert 'Ciente' in display |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db |
||||
|
def test_get_field_display_protege_linha_legada(): |
||||
|
"""Linhas gravadas antes do pre_save não passam pela camada de entrada.""" |
||||
|
t = baker.make(TramitacaoAdministrativo, texto='ok') |
||||
|
TramitacaoAdministrativo.objects.filter(pk=t.pk).update( |
||||
|
texto='<script>alert(1)</script>legado') |
||||
|
t.refresh_from_db() |
||||
|
assert t.texto == '<script>alert(1)</script>legado' |
||||
|
|
||||
|
__, display = get_field_display(t, 'texto') |
||||
|
assert '<script>' not in display |
||||
|
|
||||
|
|
||||
|
def test_pre_save_ignora_raw(): |
||||
|
"""loaddata (inclusive em migrations) grava o objeto literalmente.""" |
||||
|
t = TramitacaoAdministrativo(texto='<b>fixture</b>') |
||||
|
pre_save.send(sender=TramitacaoAdministrativo, instance=t, raw=True) |
||||
|
assert t.texto == '<b>fixture</b>' |
||||
|
|
||||
|
|
||||
|
def test_get_field_display_escapa_texto_puro_uma_unica_vez(): |
||||
|
t = TramitacaoAdministrativo(texto='ALFA & BETA < 30') |
||||
|
__, display = get_field_display(t, 'texto') |
||||
|
assert 'ALFA & BETA < 30' in display |
||||
|
assert '&amp;' not in display |
||||
|
|
||||
|
|
||||
|
def test_get_field_display_escapa_campo_isento(): |
||||
|
p = LexmlProvedor(xml='<xml><script>x</script></xml>') |
||||
|
__, display = get_field_display(p, 'xml') |
||||
|
assert '<script>' not in display |
||||
|
assert '<xml>' in display |
||||
|
|
||||
|
|
||||
|
def test_striptags_apos_sanitize_nao_escapa_duas_vezes(): |
||||
|
"""Blocos da ata: texto puro, sem entidades escapadas de novo.""" |
||||
|
t = Template('{% load common_tags %}{{ v|sanitize|striptags }}') |
||||
|
saida = t.render(Context({ |
||||
|
'v': '<p>Ofício 12 lido & arquivado</p>' |
||||
|
'<script>alert(1)</script><img src=x onerror=alert(1)>'})) |
||||
|
assert saida == 'Ofício 12 lido & arquivado' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.parametrize('valor, esperado', [ |
||||
|
('<br>', True), |
||||
|
('<br/>', True), |
||||
|
('<div class="titulo">Justificativa</div>', True), |
||||
|
('Art. ', True), |
||||
|
('<span class="x">', False), |
||||
|
('</span>', False), |
||||
|
('<b><i>x</b></i>', False), |
||||
|
]) |
||||
|
def test_html_fragment_is_balanced(valor, esperado): |
||||
|
assert html_fragment_is_balanced(valor) is esperado |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db |
||||
|
def test_tipo_dispositivo_form_rejeita_fragmento_desbalanceado(): |
||||
|
td = baker.make(TipoDispositivo) |
||||
|
dados = {f: getattr(td, f) or '' for f in TipoDispositivoForm.Meta.fields} |
||||
|
dados['rotulo_prefixo_html'] = '<span class="rotulo">' |
||||
|
dados['rotulo_sufixo_html'] = '</span>' |
||||
|
form = TipoDispositivoForm(data=dados, instance=td) |
||||
|
assert not form.is_valid() |
||||
|
assert 'rotulo_prefixo_html' in form.errors |
||||
|
assert 'rotulo_sufixo_html' in form.errors |
||||
|
|
||||
|
dados['rotulo_prefixo_html'] = '<br/>' |
||||
|
dados['rotulo_sufixo_html'] = '' |
||||
|
form = TipoDispositivoForm(data=dados, instance=td) |
||||
|
assert 'rotulo_prefixo_html' not in form.errors |
||||
@ -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'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,90 @@ |
|||||
|
from django.db import migrations, models |
||||
|
|
||||
|
_OLD_VIEW = """ |
||||
|
create or replace view materia_materiaemtramitacao as |
||||
|
select m.id as id, |
||||
|
m.id as materia_id, |
||||
|
t.id as tramitacao_id, |
||||
|
t.unidade_tramitacao_destino_id as unidade_tramitacao_atual_id |
||||
|
from materia_materialegislativa m |
||||
|
inner join materia_tramitacao t on (m.id = t.materia_id) |
||||
|
where t.id = (select max(id) from materia_tramitacao where materia_id = m.id) |
||||
|
order by m.id DESC |
||||
|
""" |
||||
|
|
||||
|
_NEW_VIEW = """ |
||||
|
CREATE OR REPLACE VIEW materia_materiaemtramitacao AS |
||||
|
SELECT |
||||
|
m.id, |
||||
|
m.id AS materia_id, |
||||
|
t.id AS tramitacao_id, |
||||
|
t.unidade_tramitacao_destino_id AS unidade_tramitacao_atual_id |
||||
|
FROM materia_materialegislativa m |
||||
|
JOIN LATERAL ( |
||||
|
SELECT |
||||
|
t.id, |
||||
|
t.unidade_tramitacao_destino_id |
||||
|
FROM materia_tramitacao t |
||||
|
WHERE t.materia_id = m.id |
||||
|
ORDER BY t.id DESC |
||||
|
LIMIT 1 |
||||
|
) t ON true; |
||||
|
""" |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
# CREATE INDEX CONCURRENTLY cannot run inside a transaction. |
||||
|
atomic = False |
||||
|
|
||||
|
dependencies = [ |
||||
|
('materia', '0087_update_viewdb_materiaemtramitacao'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.RunSQL(sql=_NEW_VIEW, reverse_sql=_OLD_VIEW), |
||||
|
migrations.SeparateDatabaseAndState( |
||||
|
database_operations=[ |
||||
|
# Um CREATE INDEX CONCURRENTLY interrompido deixa o índice |
||||
|
# criado porém inválido, e o IF NOT EXISTS abaixo o pularia |
||||
|
# na próxima execução. DROP INDEX sem CONCURRENTLY porque |
||||
|
# este não roda dentro de um bloco DO. |
||||
|
migrations.RunSQL( |
||||
|
sql=""" |
||||
|
DO $$ |
||||
|
BEGIN |
||||
|
IF EXISTS ( |
||||
|
SELECT 1 |
||||
|
FROM pg_index i |
||||
|
JOIN pg_class c ON c.oid = i.indexrelid |
||||
|
WHERE c.relname = 'tram_materia_id_desc' |
||||
|
AND NOT i.indisvalid |
||||
|
) THEN |
||||
|
DROP INDEX tram_materia_id_desc; |
||||
|
END IF; |
||||
|
END $$; |
||||
|
""", |
||||
|
reverse_sql=migrations.RunSQL.noop, |
||||
|
), |
||||
|
migrations.RunSQL( |
||||
|
sql=""" |
||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS |
||||
|
tram_materia_id_desc |
||||
|
ON materia_tramitacao (materia_id, id DESC) |
||||
|
""", |
||||
|
reverse_sql=""" |
||||
|
DROP INDEX CONCURRENTLY IF EXISTS |
||||
|
tram_materia_id_desc |
||||
|
""", |
||||
|
), |
||||
|
], |
||||
|
state_operations=[ |
||||
|
migrations.AddIndex( |
||||
|
model_name='tramitacao', |
||||
|
index=models.Index( |
||||
|
fields=['materia', '-id'], |
||||
|
name='tram_materia_id_desc', |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,64 @@ |
|||||
|
import os |
||||
|
|
||||
|
from django.core.files.uploadedfile import SimpleUploadedFile |
||||
|
from django.urls import reverse |
||||
|
from model_bakery import baker |
||||
|
import pytest |
||||
|
|
||||
|
from sapl.materia.models import (DocumentoAcessorio, MateriaLegislativa, |
||||
|
TipoDocumento) |
||||
|
|
||||
|
|
||||
|
@pytest.fixture |
||||
|
def dirs(settings, tmp_path, monkeypatch): |
||||
|
media = tmp_path / 'media' |
||||
|
temp = tmp_path / 'temp' |
||||
|
media.mkdir() |
||||
|
temp.mkdir() |
||||
|
settings.MEDIA_ROOT = str(media) |
||||
|
monkeypatch.setattr('sapl.materia.views.MEDIA_ROOT', str(media)) |
||||
|
monkeypatch.setattr('sapl.materia.views.get_tempfile_dir', |
||||
|
lambda: str(temp)) |
||||
|
return media, temp |
||||
|
|
||||
|
|
||||
|
def _post(client, nome, conteudo): |
||||
|
materias = baker.make(MateriaLegislativa, _quantity=2) |
||||
|
tipo = baker.make(TipoDocumento, descricao='Parecer') |
||||
|
client.post(reverse('sapl.materia:acessorio_em_lote'), { |
||||
|
'materia_id': [m.pk for m in materias], |
||||
|
'tipo': tipo.descricao, |
||||
|
'nome': 'Parecer', |
||||
|
'autor': 'Comissão', |
||||
|
'data': '12/10/2026', |
||||
|
'ementa': 'Ementa', |
||||
|
'arquivo': SimpleUploadedFile(nome, conteudo), |
||||
|
}) |
||||
|
return materias |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_arquivo_invalido_nao_grava_nada(admin_client, dirs): |
||||
|
media, temp = dirs |
||||
|
|
||||
|
_post(admin_client, 'x.html', b'%PDF-1.4\n<html><script></script>') |
||||
|
|
||||
|
assert not DocumentoAcessorio.objects.exists() |
||||
|
assert os.listdir(media) == [] |
||||
|
assert os.listdir(temp) == [] |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_arquivo_valido_copiado_para_cada_materia(admin_client, dirs): |
||||
|
media, temp = dirs |
||||
|
|
||||
|
materias = _post(admin_client, 'parecer.pdf', b'%PDF-1.4\n%%EOF') |
||||
|
|
||||
|
docs = DocumentoAcessorio.objects.filter(materia__in=materias) |
||||
|
assert docs.count() == 2 |
||||
|
for doc in docs: |
||||
|
assert doc.arquivo.name == ( |
||||
|
'sapl/public/documentoacessorio/2026/%s/parecer.pdf' % doc.pk) |
||||
|
assert (media / doc.arquivo.name).read_bytes() == b'%PDF-1.4\n%%EOF' |
||||
|
assert os.listdir(media) == ['sapl'] |
||||
|
assert os.listdir(temp) == [] |
||||
@ -0,0 +1,98 @@ |
|||||
|
from django.contrib.auth import get_user_model |
||||
|
from django.contrib.auth.models import Permission |
||||
|
from django.contrib.contenttypes.models import ContentType |
||||
|
from django.urls import reverse |
||||
|
from django.utils import timezone |
||||
|
from model_bakery import baker |
||||
|
import pytest |
||||
|
|
||||
|
from sapl.base.models import AppConfig |
||||
|
from sapl.materia.models import (Proposicao, TipoMateriaLegislativa, |
||||
|
TipoProposicao) |
||||
|
from sapl.utils import SEPARADOR_HASH_PROPOSICAO, gerar_hash_arquivo |
||||
|
|
||||
|
PROPOSICAO_PATH = 'sapl/private/proposicao/2026/1/proposicao.pdf' |
||||
|
PERMS_DOCADM = ['add_documentoadministrativo', |
||||
|
'change_documentoadministrativo', |
||||
|
'detail_documentoadministrativo'] |
||||
|
|
||||
|
|
||||
|
def _login(client, app_label, *codenames): |
||||
|
user = baker.make(get_user_model(), username=app_label) |
||||
|
user.user_permissions.add(*Permission.objects.filter( |
||||
|
content_type__app_label=app_label, codename__in=codenames)) |
||||
|
client.force_login(user) |
||||
|
return user |
||||
|
|
||||
|
|
||||
|
def _login_docadm(client): |
||||
|
user = _login(client, 'protocoloadm', *PERMS_DOCADM) |
||||
|
assert user.has_module_perms('protocoloadm') |
||||
|
|
||||
|
|
||||
|
def _login_protocolo(client): |
||||
|
_login(client, 'materia', 'detail_proposicao_enviada') |
||||
|
|
||||
|
|
||||
|
def _proposicao_enviada(settings, tmp_path): |
||||
|
settings.MEDIA_ROOT = str(tmp_path) |
||||
|
baker.make(AppConfig) |
||||
|
arquivo = tmp_path / PROPOSICAO_PATH |
||||
|
arquivo.parent.mkdir(parents=True) |
||||
|
arquivo.write_bytes(b'%PDF-1.4 proposicao') |
||||
|
tipo = baker.make( |
||||
|
TipoProposicao, |
||||
|
content_type=ContentType.objects.get_for_model(TipoMateriaLegislativa), |
||||
|
object_id=baker.make(TipoMateriaLegislativa).pk) |
||||
|
proposicao = baker.make(Proposicao, tipo=tipo, |
||||
|
texto_original=PROPOSICAO_PATH, |
||||
|
data_envio=timezone.now()) |
||||
|
hasher = gerar_hash_arquivo(str(arquivo), str(proposicao.pk)) |
||||
|
return proposicao, hasher[1:].split(SEPARADOR_HASH_PROPOSICAO)[0] |
||||
|
|
||||
|
|
||||
|
def _url_confirmar(proposicao, hash_): |
||||
|
return reverse('sapl.materia:proposicao-confirmar', |
||||
|
kwargs={'hash': hash_, 'pk': proposicao.pk}) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_receber_exige_permissao_de_proposicao(client): |
||||
|
_login_docadm(client) |
||||
|
url = reverse('sapl.materia:receber-proposicao') |
||||
|
|
||||
|
assert client.get(url).status_code == 403 |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_receber_operador_de_protocolo(client): |
||||
|
_login_protocolo(client) |
||||
|
url = reverse('sapl.materia:receber-proposicao') |
||||
|
|
||||
|
assert client.get(url).status_code == 200 |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_confirmar_exige_permissao_de_proposicao(client, settings, tmp_path): |
||||
|
proposicao, hash_ = _proposicao_enviada(settings, tmp_path) |
||||
|
_login_docadm(client) |
||||
|
|
||||
|
response = client.get(_url_confirmar(proposicao, hash_)) |
||||
|
|
||||
|
assert response.status_code == 403 |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_confirmar_operador_de_protocolo_ve_texto(client, settings, |
||||
|
tmp_path): |
||||
|
settings.DEBUG = False |
||||
|
proposicao, hash_ = _proposicao_enviada(settings, tmp_path) |
||||
|
_login_protocolo(client) |
||||
|
|
||||
|
response = client.get(_url_confirmar(proposicao, hash_)) |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
assert proposicao.texto_original.url.encode() in response.content |
||||
|
texto = client.get(proposicao.texto_original.url) |
||||
|
assert texto.status_code == 200 |
||||
|
assert texto['X-Accel-Redirect'] == '/media-interna/' + PROPOSICAO_PATH |
||||
@ -1,3 +1,364 @@ |
|||||
# from django.test import TestCase |
import pytest |
||||
|
from django.contrib.auth import get_user_model |
||||
|
from django.contrib.auth.models import Permission |
||||
|
from django.test import Client |
||||
|
from django.urls import reverse |
||||
|
from model_bakery import baker |
||||
|
|
||||
# Create your tests here. |
from sapl.base.models import AppConfig as ConfiguracoesAplicacao |
||||
|
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa |
||||
|
from sapl.parlamentares.models import (Legislatura, Mandato, Parlamentar, |
||||
|
SessaoLegislativa, Votante) |
||||
|
from sapl.sessao.models import (ExpedienteMateria, OrdemDia, |
||||
|
PresencaOrdemDia, SessaoPlenaria, |
||||
|
TipoResultadoVotacao, TipoSessaoPlenaria, |
||||
|
VotoParlamentar) |
||||
|
|
||||
|
NOMINAL = 2 |
||||
|
LEITURA = 4 |
||||
|
|
||||
|
|
||||
|
def _sessao_plenaria(): |
||||
|
legislatura = baker.make(Legislatura) |
||||
|
sessao_legislativa = baker.make(SessaoLegislativa) |
||||
|
tipo = baker.make(TipoSessaoPlenaria) |
||||
|
return baker.make(SessaoPlenaria, legislatura=legislatura, |
||||
|
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1) |
||||
|
|
||||
|
|
||||
|
def _materia(): |
||||
|
tipo_materia = baker.make(TipoMateriaLegislativa) |
||||
|
return baker.make(MateriaLegislativa, tipo=tipo_materia) |
||||
|
|
||||
|
|
||||
|
def _ordem_nominal_aberta(registro_aberto=False): |
||||
|
sessao = _sessao_plenaria() |
||||
|
materia = _materia() |
||||
|
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, |
||||
|
tipo_votacao=NOMINAL, votacao_aberta=True, |
||||
|
registro_aberto=registro_aberto) |
||||
|
return sessao, ordem |
||||
|
|
||||
|
|
||||
|
def _ordem_leitura_aberta(): |
||||
|
sessao = _sessao_plenaria() |
||||
|
materia = _materia() |
||||
|
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, |
||||
|
tipo_votacao=LEITURA, votacao_aberta=True) |
||||
|
return sessao, ordem |
||||
|
|
||||
|
|
||||
|
def _votante(sessao, admin_user): |
||||
|
parlamentar = baker.make(Parlamentar, ativo=True) |
||||
|
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) |
||||
|
baker.make(Votante, parlamentar=parlamentar, user=admin_user) |
||||
|
return parlamentar |
||||
|
|
||||
|
|
||||
|
def _votante_com_client(sessao): |
||||
|
""" |
||||
|
Como _votante(), mas cria seu próprio usuário (não-superuser, só com a |
||||
|
permissão parlamentares.can_vote) e devolve um Client logado separado — |
||||
|
necessário para testar a corrida entre o operador e o vereador, que |
||||
|
precisam ser duas sessões/usuários distintos. |
||||
|
""" |
||||
|
parlamentar = baker.make(Parlamentar, ativo=True) |
||||
|
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) |
||||
|
user = get_user_model().objects.create_user( |
||||
|
username='votante-{}'.format(parlamentar.pk), password='x') |
||||
|
user.user_permissions.add(Permission.objects.get(codename='can_vote')) |
||||
|
baker.make(Votante, parlamentar=parlamentar, user=user) |
||||
|
client = Client() |
||||
|
client.force_login(user) |
||||
|
return parlamentar, client |
||||
|
|
||||
|
|
||||
|
def _registrar_url(sessao, ordem): |
||||
|
return reverse('sapl.sessao:votacaonominal', |
||||
|
kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id}) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_votante_view_envia_headers_never_cache(admin_client, admin_user): |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
_votante(sessao, admin_user) |
||||
|
|
||||
|
response = admin_client.get(reverse('sapl.painel:voto_individual')) |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
assert 'no-store' in response['Cache-Control'] |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_votante_view_mostra_materia_quando_registro_fechado(admin_client, admin_user): |
||||
|
sessao, ordem = _ordem_nominal_aberta(registro_aberto=False) |
||||
|
_votante(sessao, admin_user) |
||||
|
|
||||
|
response = admin_client.get(reverse('sapl.painel:voto_individual')) |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
assert 'error_message' not in response.context |
||||
|
assert response.context['materia'] == ordem.materia |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_votante_view_mostra_erro_explicito_quando_registro_bloqueado(admin_client, admin_user): |
||||
|
""" |
||||
|
Regressão da causa raiz #1: quando a Mesa bloqueia novos votos |
||||
|
(registro_aberto=True), o vereador que ainda não votou precisa ver uma |
||||
|
mensagem explícita — não uma tela em branco sem explicação. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta(registro_aberto=True) |
||||
|
_votante(sessao, admin_user) |
||||
|
|
||||
|
response = admin_client.get(reverse('sapl.painel:voto_individual')) |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
assert 'Mesa encerrou o recebimento de novos votos' in response.context['error_message'] |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_voto_do_vereador_prevalece_sobre_lote_do_operador(admin_client): |
||||
|
""" |
||||
|
Invariante: o voto do próprio vereador sempre prevalece sobre qualquer |
||||
|
valor provisório já existente para ele (ex.: de uma tentativa anterior |
||||
|
do operador, ou de qualquer outra origem). Complementa |
||||
|
test_salvar_votacao_nao_sobrescreve_voto_ja_registrado (que cobre a |
||||
|
ordem inversa: o operador não pode sobrescrever um voto real já |
||||
|
registrado) — aqui é o vereador votando por cima de um valor existente |
||||
|
através da view de verdade, não apenas o estado inicial simulado. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
vereador, votante_client = _votante_com_client(sessao) |
||||
|
|
||||
|
# 1) Já existe um valor provisório "Não Votou" para o vereador (ex.: o |
||||
|
# <select> nunca foi tocado pelo operador). Esse valor nunca deveria |
||||
|
# impedir o vereador de votar de verdade. |
||||
|
baker.make(VotoParlamentar, ordem=ordem, parlamentar=vereador, |
||||
|
voto='Não Votou') |
||||
|
|
||||
|
# 2) O vereador vota pelo tablet — seu voto real prevalece sobre o |
||||
|
# valor provisório. |
||||
|
response = votante_client.post( |
||||
|
reverse('sapl.painel:voto_individual'), {'voto': 'Sim'}) |
||||
|
assert response.status_code == 302 |
||||
|
|
||||
|
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=vereador) |
||||
|
assert voto.voto == 'Sim' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_post_de_voto_bloqueado_nao_persiste(admin_client, admin_user): |
||||
|
sessao, ordem = _ordem_nominal_aberta(registro_aberto=True) |
||||
|
parlamentar = _votante(sessao, admin_user) |
||||
|
|
||||
|
response = admin_client.post( |
||||
|
reverse('sapl.painel:voto_individual'), {'voto': 'Sim'}) |
||||
|
|
||||
|
assert response.status_code == 302 |
||||
|
assert not VotoParlamentar.objects.filter( |
||||
|
ordem=ordem, parlamentar=parlamentar).exists() |
||||
|
|
||||
|
# voto_individual.html é avulso (não herda base.html): o erro precisa |
||||
|
# aparecer na própria tela do tablet. |
||||
|
pagina = admin_client.get(response.url) |
||||
|
assert 'A votação não está disponível para novos votos.' in pagina.content.decode() |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_post_rejeita_valor_de_voto_invalido(admin_client, admin_user): |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
parlamentar = _votante(sessao, admin_user) |
||||
|
|
||||
|
response = admin_client.post( |
||||
|
reverse('sapl.painel:voto_individual'), {'voto': 'Outro'}) |
||||
|
|
||||
|
assert response.status_code == 302 |
||||
|
assert not VotoParlamentar.objects.filter( |
||||
|
ordem=ordem, parlamentar=parlamentar).exists() |
||||
|
|
||||
|
pagina = admin_client.get(response.url) |
||||
|
assert 'Voto inválido.' in pagina.content.decode() |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_propria_tela_nao_mostra_voto_de_outra_materia(admin_client): |
||||
|
""" |
||||
|
Regressão: votacao() buscava o voto do próprio vereador com |
||||
|
Q(ordem=ordem_dia) | Q(expediente=expediente) — quando a matéria atual |
||||
|
é uma OrdemDia, expediente é None, e Q(expediente=None) vira |
||||
|
"expediente_id IS NULL" no SQL, que é verdadeiro para QUALQUER voto de |
||||
|
ordem do dia daquele vereador, não só o desta matéria (idem para |
||||
|
ExpedienteMateria, cujas votações também têm ordem=None). Com .first() |
||||
|
sem ordenação, o vereador podia ver o voto de uma matéria antiga em vez |
||||
|
do voto (ou ausência de voto) da matéria atual — inclusive depois de |
||||
|
trocar o próprio voto, já que a query buscava a linha errada. |
||||
|
""" |
||||
|
sessao, ordem_antiga = _ordem_nominal_aberta() |
||||
|
vereador, votante_client = _votante_com_client(sessao) |
||||
|
baker.make(VotoParlamentar, ordem=ordem_antiga, parlamentar=vereador, voto='Sim') |
||||
|
|
||||
|
ordem_antiga.votacao_aberta = False |
||||
|
ordem_antiga.save() |
||||
|
baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(), |
||||
|
tipo_votacao=NOMINAL, votacao_aberta=True, registro_aberto=False) |
||||
|
|
||||
|
status_url = reverse('sapl.painel:voto_individual_status') |
||||
|
|
||||
|
# Ainda não votou na matéria atual — não pode herdar o 'Sim' da antiga. |
||||
|
assert votante_client.get(status_url).json()['voto_parlamentar'] is None |
||||
|
|
||||
|
votante_client.post(reverse('sapl.painel:voto_individual'), {'voto': 'Não'}) |
||||
|
|
||||
|
assert votante_client.get(status_url).json()['voto_parlamentar'] == 'Não' |
||||
|
assert VotoParlamentar.objects.get( |
||||
|
ordem=ordem_antiga, parlamentar=vereador).voto == 'Sim' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_get_dados_painel_nao_usa_etag_incompleto(admin_client): |
||||
|
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False) |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
url = reverse('sapl.painel:dados_painel', kwargs={'pk': sessao.pk}) |
||||
|
|
||||
|
primeira = admin_client.get(url) |
||||
|
assert primeira.status_code == 200 |
||||
|
assert 'ETag' not in primeira |
||||
|
|
||||
|
# Mesmo que um cliente antigo ainda envie o último valor conhecido, a |
||||
|
# resposta deve ser sempre recalculada para não ocultar mudanças em |
||||
|
# presenças, oradores ou configuração do painel. |
||||
|
segunda = admin_client.get(url, HTTP_IF_NONE_MATCH='"valor-antigo"') |
||||
|
assert segunda.status_code == 200 |
||||
|
assert 'ETag' not in segunda |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_painel_exibe_nao_votou_para_parlamentar_sem_voto(admin_client): |
||||
|
""" |
||||
|
Quem estava presente e não votou chega ao painel como 'Não Votou' depois |
||||
|
do encerramento. Para dado legado sem VotoParlamentar (voto null), o |
||||
|
JavaScript mostra "Não votou" só com a votação registrada — com ela |
||||
|
aberta, voto vazio significa apenas "ainda não votou". |
||||
|
""" |
||||
|
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, |
||||
|
mostrar_brasao_painel=False) |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', |
||||
|
natureza='A') |
||||
|
votou = baker.make(Parlamentar, ativo=True) |
||||
|
nao_votou = baker.make(Parlamentar, ativo=True) |
||||
|
for parlamentar in (votou, nao_votou): |
||||
|
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, |
||||
|
parlamentar=parlamentar) |
||||
|
baker.make(Mandato, parlamentar=parlamentar, |
||||
|
legislatura=sessao.legislatura) |
||||
|
|
||||
|
resposta_registro = admin_client.post(_registrar_url(sessao, ordem), { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': [ |
||||
|
'Sim:{}'.format(votou.pk), |
||||
|
'Não Votou:{}'.format(nao_votou.pk), |
||||
|
], |
||||
|
}) |
||||
|
assert resposta_registro.status_code == 302 |
||||
|
|
||||
|
dados = admin_client.get(reverse( |
||||
|
'sapl.painel:dados_painel', kwargs={'pk': sessao.pk})).json() |
||||
|
parlamentar_sem_voto = next( |
||||
|
p for p in dados['presentes'] if p['parlamentar_id'] == nao_votou.pk) |
||||
|
assert parlamentar_sem_voto['voto'] == 'Não Votou' |
||||
|
|
||||
|
painel = admin_client.get(reverse( |
||||
|
'sapl.painel:painel_principal', kwargs={'pk': sessao.pk})) |
||||
|
assert painel.status_code == 200 |
||||
|
assert b'if (!parlamentar.voto && registrada)' in painel.content |
||||
|
assert 'Não votou'.encode() in painel.content |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_dados_painel_leitura_nao_tem_voto_individual(admin_client, admin_user): |
||||
|
""" |
||||
|
Contrato consumido pelo JS do painel (sapl/templates/painel/index.html): |
||||
|
para uma matéria em Leitura, tipo_votacao chega como a string 'Leitura' |
||||
|
(não o código inteiro) e nenhum parlamentar presente recebe um voto — |
||||
|
é isso que permite ao JS distinguir "não há voto individual nesta |
||||
|
matéria" de "ainda não votou". |
||||
|
""" |
||||
|
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False) |
||||
|
sessao, ordem = _ordem_leitura_aberta() |
||||
|
_votante(sessao, admin_user) |
||||
|
|
||||
|
dados = admin_client.get(reverse( |
||||
|
'sapl.painel:dados_painel', kwargs={'pk': sessao.pk})).json() |
||||
|
|
||||
|
assert dados['tipo_votacao'] == 'Leitura' |
||||
|
assert all(p['voto'] == '' for p in dados['presentes']) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_dados_painel_sem_materia_nao_envia_tipo_votacao(admin_client, admin_user): |
||||
|
""" |
||||
|
Sem nenhuma matéria aberta ou já votada/lida (ex.: sessão solene), a |
||||
|
chave tipo_votacao não é enviada — o JS trata isso como "sem voto |
||||
|
individual" da mesma forma que trata 'Leitura'. |
||||
|
""" |
||||
|
baker.make(ConfiguracoesAplicacao, mostrar_voto=True, mostrar_brasao_painel=False) |
||||
|
sessao = _sessao_plenaria() |
||||
|
|
||||
|
dados = admin_client.get(reverse( |
||||
|
'sapl.painel:dados_painel', kwargs={'pk': sessao.pk})).json() |
||||
|
|
||||
|
assert 'tipo_votacao' not in dados |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_votante_status_reflete_estado_e_nao_exige_permissao_do_painel(): |
||||
|
""" |
||||
|
votante_status precisa ser alcançável por uma conta só-Votante (sem |
||||
|
nenhuma permissão do app painel) — é por isso que não reaproveita |
||||
|
get_dados_painel, que exige check_permission (permissão de módulo do |
||||
|
app painel). |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
vereador, votante_client = _votante_com_client(sessao) |
||||
|
|
||||
|
status_url = reverse('sapl.painel:voto_individual_status') |
||||
|
|
||||
|
resposta = votante_client.get(status_url) |
||||
|
assert resposta.status_code == 200 |
||||
|
data = resposta.json() |
||||
|
assert data['materia_id'] == ordem.materia_id |
||||
|
assert data['status_message'] == 'Aguardando seu voto.' |
||||
|
assert data['voto_parlamentar'] is None |
||||
|
|
||||
|
votante_client.post(reverse('sapl.painel:voto_individual'), {'voto': 'Não'}) |
||||
|
|
||||
|
resposta2 = votante_client.get(status_url) |
||||
|
data2 = resposta2.json() |
||||
|
assert data2['voto_parlamentar'] == 'Não' |
||||
|
assert 'encerramento da votação' in data2['status_message'] |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_votante_status_com_duas_votacoes_abertas_nao_acumula_mensagens(): |
||||
|
""" |
||||
|
Uma OrdemDia e uma ExpedienteMateria abertas ao mesmo tempo (as |
||||
|
constraints são por tabela): o poll do tablet devolve o erro em |
||||
|
error_message, em texto puro, sem empilhar mensagens na sessão. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
vereador, votante_client = _votante_com_client(sessao) |
||||
|
outra_sessao = _sessao_plenaria() |
||||
|
baker.make(ExpedienteMateria, sessao_plenaria=outra_sessao, |
||||
|
materia=_materia(), tipo_votacao=NOMINAL, votacao_aberta=True) |
||||
|
|
||||
|
status_url = reverse('sapl.painel:voto_individual_status') |
||||
|
for _ in range(2): |
||||
|
data = votante_client.get(status_url).json() |
||||
|
|
||||
|
assert 'mais de uma' in data['error_message'] |
||||
|
assert '<' not in data['error_message'] |
||||
|
assert '_messages' not in votante_client.session |
||||
|
|||||
@ -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'), |
||||
|
), |
||||
|
] |
||||
@ -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'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,163 @@ |
|||||
|
from io import BytesIO |
||||
|
|
||||
|
from PyPDF4 import PdfFileWriter |
||||
|
from django.contrib.auth import get_user_model |
||||
|
from django.contrib.auth.models import Permission |
||||
|
from django.contrib.contenttypes.models import ContentType |
||||
|
from django.core.cache import cache |
||||
|
from django.core.files.uploadedfile import SimpleUploadedFile |
||||
|
from django.urls import reverse |
||||
|
from model_bakery import baker |
||||
|
import pytest |
||||
|
|
||||
|
from sapl.base.models import AppConfig |
||||
|
from sapl.protocoloadm.models import (DocumentoAcessorioAdministrativo, |
||||
|
DocumentoAdministrativo) |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(autouse=True) |
||||
|
def limpa_cache_appconfig(): |
||||
|
# o cache em arquivo sobrevive ao rollback do teste |
||||
|
cache.clear() |
||||
|
yield |
||||
|
cache.clear() |
||||
|
|
||||
|
|
||||
|
@pytest.fixture |
||||
|
def media_tmp(settings, tmp_path): |
||||
|
settings.MEDIA_ROOT = str(tmp_path) |
||||
|
return tmp_path |
||||
|
|
||||
|
|
||||
|
def _config(comportamento): |
||||
|
cache.clear() |
||||
|
AppConfig.objects.all().delete() |
||||
|
baker.make(AppConfig, documentos_administrativos=comportamento) |
||||
|
|
||||
|
|
||||
|
def _documento(restrito): |
||||
|
documento = baker.make(DocumentoAdministrativo, restrito=restrito) |
||||
|
documento.texto_integral = SimpleUploadedFile( |
||||
|
'texto.pdf', b'%PDF-1.4 conteudo', content_type='application/pdf') |
||||
|
documento.save() |
||||
|
return documento |
||||
|
|
||||
|
|
||||
|
def _client(client, perfil): |
||||
|
if perfil == 'anonimo': |
||||
|
return client |
||||
|
user = baker.make(get_user_model(), username=perfil) |
||||
|
if perfil == 'com_permissao': |
||||
|
ct = ContentType.objects.get_for_model(DocumentoAdministrativo) |
||||
|
perm, _ = Permission.objects.get_or_create( |
||||
|
codename='detail_documentoadministrativo', content_type=ct, |
||||
|
defaults={'name': 'detail'}) |
||||
|
user.user_permissions.add(perm) |
||||
|
client.force_login(user) |
||||
|
return client |
||||
|
|
||||
|
|
||||
|
ACESSO = [ |
||||
|
# (comportamento, restrito, perfil, pode_ver) |
||||
|
('O', False, 'anonimo', True), |
||||
|
('O', False, 'sem_permissao', True), |
||||
|
('O', False, 'com_permissao', True), |
||||
|
('O', True, 'anonimo', False), |
||||
|
('O', True, 'sem_permissao', True), |
||||
|
('O', True, 'com_permissao', True), |
||||
|
('R', False, 'anonimo', False), |
||||
|
('R', False, 'sem_permissao', False), |
||||
|
('R', False, 'com_permissao', True), |
||||
|
('R', True, 'anonimo', False), |
||||
|
('R', True, 'sem_permissao', False), |
||||
|
('R', True, 'com_permissao', True), |
||||
|
] |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
@pytest.mark.parametrize('comportamento,restrito,perfil,pode_ver', ACESSO) |
||||
|
def test_doc_texto_integral_acesso(client, media_tmp, comportamento, |
||||
|
restrito, perfil, pode_ver): |
||||
|
_config(comportamento) |
||||
|
documento = _documento(restrito) |
||||
|
client = _client(client, perfil) |
||||
|
|
||||
|
response = client.get(reverse('sapl.protocoloadm:doc_texto_integral', |
||||
|
kwargs={'pk': documento.pk})) |
||||
|
|
||||
|
assert response.status_code == (200 if pode_ver else 404) |
||||
|
|
||||
|
|
||||
|
def _pdf(): |
||||
|
writer = PdfFileWriter() |
||||
|
writer.addBlankPage(72, 72) |
||||
|
data = BytesIO() |
||||
|
writer.write(data) |
||||
|
return data.getvalue() |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
@pytest.mark.parametrize('comportamento,restrito,perfil,pode_ver', ACESSO) |
||||
|
def test_pdf_docacessorios_acesso(client, media_tmp, monkeypatch, |
||||
|
comportamento, restrito, perfil, pode_ver): |
||||
|
# create_pdf_docacessorios usa o MEDIA_ROOT importado de sapl.settings |
||||
|
monkeypatch.setattr('sapl.protocoloadm.views.MEDIA_ROOT', str(media_tmp)) |
||||
|
_config(comportamento) |
||||
|
documento = baker.make(DocumentoAdministrativo, restrito=restrito) |
||||
|
acessorio = baker.make(DocumentoAcessorioAdministrativo, |
||||
|
documento=documento) |
||||
|
acessorio.arquivo = SimpleUploadedFile( |
||||
|
'acessorio.pdf', _pdf(), content_type='application/pdf') |
||||
|
acessorio.save() |
||||
|
client = _client(client, perfil) |
||||
|
|
||||
|
response = client.get(reverse('sapl.protocoloadm:merge_docacessorios', |
||||
|
kwargs={'pk': documento.pk})) |
||||
|
|
||||
|
if pode_ver: |
||||
|
assert response.status_code == 200 |
||||
|
assert response['Content-Type'] == 'application/pdf' |
||||
|
assert response.content.startswith(b'%PDF') |
||||
|
else: |
||||
|
assert response.status_code == 404 |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_pdf_docacessorios_sem_acessorios_volta_para_lista(client): |
||||
|
_config('O') |
||||
|
documento = baker.make(DocumentoAdministrativo, restrito=False) |
||||
|
|
||||
|
response = client.get(reverse('sapl.protocoloadm:merge_docacessorios', |
||||
|
kwargs={'pk': documento.pk})) |
||||
|
|
||||
|
assert response.status_code == 302 |
||||
|
assert response['Location'] == reverse( |
||||
|
'sapl.protocoloadm:documentoacessorioadministrativo_list', |
||||
|
kwargs={'pk': documento.pk}) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
@pytest.mark.parametrize('url_name', ['doc_texto_integral', |
||||
|
'merge_docacessorios']) |
||||
|
def test_docadm_pk_inexistente(client, url_name): |
||||
|
_config('O') |
||||
|
response = client.get(reverse('sapl.protocoloadm:' + url_name, |
||||
|
kwargs={'pk': 999999})) |
||||
|
assert response.status_code == 404 |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_detalhe_e_arquivo_usam_a_mesma_configuracao(client, media_tmp): |
||||
|
# com mais de uma linha em AppConfig, página e arquivo leem a mesma |
||||
|
_config('O') |
||||
|
baker.make(AppConfig, documentos_administrativos='R') |
||||
|
documento = _documento(False) |
||||
|
|
||||
|
detalhe = client.get(reverse( |
||||
|
'sapl.protocoloadm:documentoadministrativo_detail', |
||||
|
kwargs={'pk': documento.pk})) |
||||
|
texto = client.get(reverse('sapl.protocoloadm:doc_texto_integral', |
||||
|
kwargs={'pk': documento.pk})) |
||||
|
|
||||
|
assert detalhe.status_code == 302 |
||||
|
assert texto.status_code == 404 |
||||
@ -0,0 +1,182 @@ |
|||||
|
"""Sanitização de HTML/JavaScript nos campos de texto livre do SAPL. |
||||
|
|
||||
|
Módulo sem dependências internas do SAPL de propósito: é importado por |
||||
|
``sapl.crispy_layout_mixin``, ``sapl.base.receivers`` e pelos templatetags, |
||||
|
e ``sapl.utils`` já importa ``sapl.crispy_layout_mixin``. |
||||
|
|
||||
|
Duas políticas: |
||||
|
|
||||
|
* ``plain`` — remove toda a marcação e guarda texto puro, com as entidades |
||||
|
já decodificadas: o escape fica por conta da renderização. É o padrão para |
||||
|
qualquer ``TextField``. |
||||
|
* ``rich`` — allowlist para os campos editados via TinyMCE, que contêm HTML |
||||
|
legítimo (negrito, listas, tabelas e links). |
||||
|
|
||||
|
A ``rich`` é idempotente, o que permite aplicá-la tanto no ``pre_save`` quanto |
||||
|
na renderização. A ``plain`` só não é idempotente para texto que seja HTML |
||||
|
codificado em entidades (``<b>``): a primeira passada decodifica, a |
||||
|
segunda remove a tag. Na renderização, campos ``plain`` são escapados em vez |
||||
|
de re-sanitizados. |
||||
|
""" |
||||
|
|
||||
|
import html |
||||
|
from html.parser import HTMLParser |
||||
|
|
||||
|
import nh3 |
||||
|
|
||||
|
SANITIZE_RICH_TAGS = { |
||||
|
'p', 'br', 'hr', 'div', 'span', |
||||
|
'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'sub', 'sup', |
||||
|
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'blockquote', 'pre', 'code', |
||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', |
||||
|
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', |
||||
|
'col', 'colgroup', |
||||
|
'a', 'img', |
||||
|
} |
||||
|
|
||||
|
# 'style' mantém o alinhamento produzido pelos botões do TinyMCE; |
||||
|
# 'target' mantém o "abrir em nova aba" dos links já cadastrados. |
||||
|
# 'rel' não pode entrar aqui: o nh3 aborta se a tag 'a' declarar 'rel' |
||||
|
# ao mesmo tempo em que link_rel está definido — ele mesmo escreve o atributo. |
||||
|
SANITIZE_RICH_ATTRS = { |
||||
|
'*': {'style', 'class', 'align', 'title', 'dir', 'lang'}, |
||||
|
'a': {'href', 'target', 'name'}, |
||||
|
'img': {'src', 'alt', 'width', 'height'}, |
||||
|
'td': {'colspan', 'rowspan', 'headers'}, |
||||
|
'th': {'colspan', 'rowspan', 'scope', 'headers'}, |
||||
|
'col': {'span'}, |
||||
|
'colgroup': {'span'}, |
||||
|
'table': {'border', 'cellpadding', 'cellspacing', 'summary'}, |
||||
|
} |
||||
|
|
||||
|
SANITIZE_URL_SCHEMES = {'http', 'https', 'mailto', 'tel'} |
||||
|
|
||||
|
# O conteúdo destas tags é descartado junto com a tag. Sem isso o texto de |
||||
|
# dentro de um <script> sobreviveria como texto solto. |
||||
|
SANITIZE_CLEAN_CONTENT_TAGS = {'script', 'style'} |
||||
|
|
||||
|
# Campos que guardam HTML legítimo, indexados por '<app_label>.<Model>'. |
||||
|
# Os de sessao e compilacao.Dispositivo são editados no TinyMCE; os de |
||||
|
# compilacao.TipoDispositivo são fragmentos de template configurados por |
||||
|
# administradores. |
||||
|
RICH_TEXT_FIELDS = { |
||||
|
'base.CasaLegislativa': {'informacao_geral'}, |
||||
|
'norma.NormaRelacionada': {'resumo'}, |
||||
|
'parlamentares.Legislatura': {'observacao'}, |
||||
|
'parlamentares.Parlamentar': {'biografia'}, |
||||
|
'sessao.ExpedienteSessao': {'conteudo'}, |
||||
|
'sessao.OcorrenciaSessao': {'conteudo'}, |
||||
|
'sessao.ConsideracoesFinais': {'conteudo'}, |
||||
|
'compilacao.Dispositivo': {'texto', 'texto_atualizador'}, |
||||
|
'compilacao.TipoDispositivo': { |
||||
|
'rotulo_prefixo_html', 'rotulo_sufixo_html', |
||||
|
'texto_prefixo_html', 'texto_sufixo_html', |
||||
|
'nota_automatica_prefixo_html', 'nota_automatica_sufixo_html', |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
# Campos que não devem ser tocados em hipótese alguma. |
||||
|
SANITIZE_EXEMPT_FIELDS = { |
||||
|
# xml é XML fornecido pela equipe do LexML; já é escapado em pretty_xml |
||||
|
'lexml.LexmlProvedor': {'xml'}, |
||||
|
# rodape_global é interpolado dentro de um content: de CSS |
||||
|
'compilacao.TipoTextoArticulado': {'rodape_global'}, |
||||
|
} |
||||
|
|
||||
|
|
||||
|
def model_key(model): |
||||
|
return '{}.{}'.format(model._meta.app_label, model.__name__) |
||||
|
|
||||
|
|
||||
|
def sanitize_scope(model, fieldname): |
||||
|
"""Retorna 'exempt', 'rich' ou 'plain' para um campo de um modelo.""" |
||||
|
key = model_key(model) |
||||
|
if fieldname in SANITIZE_EXEMPT_FIELDS.get(key, ()): |
||||
|
return 'exempt' |
||||
|
if fieldname in RICH_TEXT_FIELDS.get(key, ()): |
||||
|
return 'rich' |
||||
|
return 'plain' |
||||
|
|
||||
|
|
||||
|
def sanitize_html(value, rich=False): |
||||
|
"""Remove HTML/JavaScript perigoso de ``value``. |
||||
|
|
||||
|
Com ``rich=False`` toda a marcação é removida e apenas o texto sobra, |
||||
|
sem escape: ``&`` continua ``&``. |
||||
|
Com ``rich=True`` aplica-se a allowlist: links são preservados, mas |
||||
|
esquemas de URL fora de SANITIZE_URL_SCHEMES (javascript:, data:) e |
||||
|
manipuladores de evento (onclick, onerror) são descartados. |
||||
|
""" |
||||
|
if not value: |
||||
|
return value |
||||
|
|
||||
|
if not isinstance(value, str): |
||||
|
value = str(value) |
||||
|
|
||||
|
if rich: |
||||
|
return nh3.clean( |
||||
|
value, |
||||
|
tags=SANITIZE_RICH_TAGS, |
||||
|
attributes=SANITIZE_RICH_ATTRS, |
||||
|
url_schemes=SANITIZE_URL_SCHEMES, |
||||
|
clean_content_tags=SANITIZE_CLEAN_CONTENT_TAGS, |
||||
|
link_rel='noopener noreferrer', |
||||
|
strip_comments=True) |
||||
|
|
||||
|
return html.unescape(nh3.clean( |
||||
|
value, |
||||
|
tags=set(), |
||||
|
attributes={}, |
||||
|
clean_content_tags=SANITIZE_CLEAN_CONTENT_TAGS, |
||||
|
link_rel=None, |
||||
|
strip_comments=True)) |
||||
|
|
||||
|
|
||||
|
def sanitize_field(model, fieldname, value): |
||||
|
"""Sanitiza ``value`` conforme a política do campo. |
||||
|
|
||||
|
Campos isentos atravessam sem modificação. |
||||
|
""" |
||||
|
scope = sanitize_scope(model, fieldname) |
||||
|
if scope == 'exempt': |
||||
|
return value |
||||
|
return sanitize_html(value, rich=(scope == 'rich')) |
||||
|
|
||||
|
|
||||
|
_VOID_TAGS = { |
||||
|
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', |
||||
|
'meta', 'param', 'source', 'track', 'wbr', |
||||
|
} |
||||
|
|
||||
|
|
||||
|
class _BalanceChecker(HTMLParser): |
||||
|
|
||||
|
def __init__(self): |
||||
|
super().__init__(convert_charrefs=True) |
||||
|
self.stack = [] |
||||
|
self.balanced = True |
||||
|
|
||||
|
def handle_starttag(self, tag, attrs): |
||||
|
if tag not in _VOID_TAGS: |
||||
|
self.stack.append(tag) |
||||
|
|
||||
|
def handle_startendtag(self, tag, attrs): |
||||
|
pass |
||||
|
|
||||
|
def handle_endtag(self, tag): |
||||
|
if tag in _VOID_TAGS: |
||||
|
return |
||||
|
if not self.stack or self.stack.pop() != tag: |
||||
|
self.balanced = False |
||||
|
|
||||
|
|
||||
|
def html_fragment_is_balanced(value): |
||||
|
"""Indica se toda tag aberta em ``value`` é fechada nele mesmo. |
||||
|
|
||||
|
Fragmentos desbalanceados (``<span>`` num campo, ``</span>`` em outro) |
||||
|
são reescritos pela sanitização ``rich``, que fecha ou descarta as tags. |
||||
|
""" |
||||
|
checker = _BalanceChecker() |
||||
|
checker.feed(value) |
||||
|
checker.close() |
||||
|
return checker.balanced and not checker.stack |
||||
@ -1,3 +1,34 @@ |
|||||
|
from django.contrib import admin |
||||
|
|
||||
|
from sapl.sessao.models import ExpedienteMateria, OrdemDia |
||||
from sapl.utils import register_all_models_in_admin |
from sapl.utils import register_all_models_in_admin |
||||
|
|
||||
|
|
||||
|
class NoAdminAccessModelAdmin(admin.ModelAdmin): |
||||
|
""" |
||||
|
SAPL não usa as páginas de admin e desencoraja seu uso — e votacao_aberta/ |
||||
|
registro_aberto só podem ser alterados com segurança através do fluxo de |
||||
|
abrir_votacao()/VotacaoNominalAbstract (sapl/sessao/views.py), que |
||||
|
garante a invariante de no máximo uma matéria aberta por vez. Em vez de |
||||
|
apenas tornar os campos somente-leitura, o acesso ao admin é desabilitado |
||||
|
por completo para estes dois modelos. |
||||
|
""" |
||||
|
|
||||
|
def has_view_permission(self, request, obj=None): |
||||
|
return False |
||||
|
|
||||
|
def has_add_permission(self, request): |
||||
|
return False |
||||
|
|
||||
|
def has_change_permission(self, request, obj=None): |
||||
|
return False |
||||
|
|
||||
|
def has_delete_permission(self, request, obj=None): |
||||
|
return False |
||||
|
|
||||
|
|
||||
|
admin.site.register(OrdemDia, NoAdminAccessModelAdmin) |
||||
|
admin.site.register(ExpedienteMateria, NoAdminAccessModelAdmin) |
||||
|
|
||||
|
# register_all_models_in_admin já pula modelos já registrados acima |
||||
register_all_models_in_admin(__name__) |
register_all_models_in_admin(__name__) |
||||
|
|||||
@ -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')}, |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,54 @@ |
|||||
|
# Generated by Django 2.2.28 on 2026-08-16 17:36 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
from django.db.models import Count |
||||
|
|
||||
|
|
||||
|
def remove_votos_duplicados(apps, schema_editor): |
||||
|
"""Preserva o voto canônico antes de criar os índices únicos.""" |
||||
|
VotoParlamentar = apps.get_model('sessao', 'VotoParlamentar') |
||||
|
|
||||
|
for materia_field in ('ordem_id', 'expediente_id'): |
||||
|
# order_by() limpa o Meta.ordering ('id'), que no Django 2.2 entraria |
||||
|
# no GROUP BY e deixaria todo grupo com n=1. |
||||
|
duplicadas = (VotoParlamentar.objects |
||||
|
.exclude(**{materia_field: None}) |
||||
|
.order_by() |
||||
|
.values('parlamentar_id', materia_field) |
||||
|
.annotate(n=Count('id')) |
||||
|
.filter(n__gt=1)) |
||||
|
for chave in duplicadas: |
||||
|
votos_duplicados = list(VotoParlamentar.objects.filter( |
||||
|
parlamentar_id=chave['parlamentar_id'], |
||||
|
**{materia_field: chave[materia_field]})) |
||||
|
voto_preservado = max( |
||||
|
votos_duplicados, |
||||
|
key=lambda voto: ( |
||||
|
voto.votacao_id is not None, |
||||
|
voto.data_hora is not None, |
||||
|
voto.data_hora, |
||||
|
voto.id, |
||||
|
)) |
||||
|
ids_removidos = [voto.id for voto in votos_duplicados |
||||
|
if voto.id != voto_preservado.id] |
||||
|
VotoParlamentar.objects.filter(id__in=ids_removidos).delete() |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('sessao', '0070_presenca_unica_por_sessao'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.RunPython( |
||||
|
remove_votos_duplicados, migrations.RunPython.noop), |
||||
|
migrations.AddConstraint( |
||||
|
model_name='votoparlamentar', |
||||
|
constraint=models.UniqueConstraint(condition=models.Q(ordem__isnull=False), fields=('parlamentar', 'ordem'), name='sessao_votoparlamentar_unique_parlamentar_ordem'), |
||||
|
), |
||||
|
migrations.AddConstraint( |
||||
|
model_name='votoparlamentar', |
||||
|
constraint=models.UniqueConstraint(condition=models.Q(expediente__isnull=False), fields=('parlamentar', 'expediente'), name='sessao_votoparlamentar_unique_parlamentar_expediente'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,45 @@ |
|||||
|
# Generated by Django 2.2.28 on 2026-08-16 18:58 |
||||
|
|
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
def fecha_materias_abertas_duplicadas(apps, schema_editor): |
||||
|
""" |
||||
|
Pré-requisito para o AddConstraint abaixo: se já existir mais de uma |
||||
|
OrdemDia/ExpedienteMateria com votacao_aberta=True (dado deixado por |
||||
|
versões anteriores, que não garantiam essa invariante), a constraint |
||||
|
falharia ao ser criada. Mantém aberta apenas a criada por último (maior |
||||
|
id) de cada tabela e fecha as demais. |
||||
|
|
||||
|
Também zera registro_aberto, que mudou de significado ("a Mesa abriu a |
||||
|
tela de registro" -> "novos votos bloqueados"): o fluxo antigo marcava |
||||
|
True ao abrir a tela e não voltava para False ao encerrar, o que faria |
||||
|
uma matéria reaberta voltar com os tablets bloqueados. |
||||
|
""" |
||||
|
for model_name in ('OrdemDia', 'ExpedienteMateria'): |
||||
|
model = apps.get_model('sessao', model_name) |
||||
|
model.objects.filter(registro_aberto=True).update(registro_aberto=False) |
||||
|
abertas = model.objects.filter(votacao_aberta=True).order_by('-id') |
||||
|
for materia in abertas[1:]: |
||||
|
materia.votacao_aberta = False |
||||
|
materia.save(update_fields=['votacao_aberta']) |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('sessao', '0071_votoparlamentar_unique_constraint'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.RunPython( |
||||
|
fecha_materias_abertas_duplicadas, migrations.RunPython.noop), |
||||
|
migrations.AddConstraint( |
||||
|
model_name='expedientemateria', |
||||
|
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'), |
||||
|
), |
||||
|
migrations.AddConstraint( |
||||
|
model_name='ordemdia', |
||||
|
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_ordemdia_unique_votacao_aberta'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,94 @@ |
|||||
|
from datetime import timedelta |
||||
|
|
||||
|
from django.contrib.auth import get_user_model |
||||
|
from django.core.cache import cache |
||||
|
from django.utils import timezone |
||||
|
from model_bakery import baker |
||||
|
import pytest |
||||
|
|
||||
|
from sapl.sessao.models import Orador, SessaoPlenaria |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(autouse=True) |
||||
|
def limpa_cache_appconfig(): |
||||
|
# o cache em arquivo sobrevive ao rollback do teste |
||||
|
cache.clear() |
||||
|
yield |
||||
|
cache.clear() |
||||
|
|
||||
|
|
||||
|
@pytest.fixture |
||||
|
def sessoes(): |
||||
|
# só a sessão futura, não iniciada e sem pauta fica oculta do público |
||||
|
previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False, |
||||
|
data_inicio=timezone.localdate() + timedelta(days=7)) |
||||
|
iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False) |
||||
|
baker.make(Orador, sessao_plenaria=previa) |
||||
|
baker.make(Orador, sessao_plenaria=iniciada) |
||||
|
return previa, iniciada |
||||
|
|
||||
|
|
||||
|
def _listados(client, endpoint, campo, sessoes): |
||||
|
"""Sessões de `sessoes` referenciadas na listagem completa do endpoint.""" |
||||
|
valores, page = set(), 1 |
||||
|
while page: |
||||
|
response = client.get('/api/sessao/%s/?page_size=100&page=%s' % ( |
||||
|
endpoint, page)) |
||||
|
assert response.status_code == 200 |
||||
|
dados = response.json() |
||||
|
valores |= {r[campo] for r in dados['results']} |
||||
|
page = dados['pagination']['next_page'] |
||||
|
return valores & {s.pk for s in sessoes} |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_api_oculta_sessao_previa_do_anonimo(client, sessoes): |
||||
|
previa, iniciada = sessoes |
||||
|
|
||||
|
assert _listados(client, 'sessaoplenaria', 'id', sessoes) == { |
||||
|
iniciada.pk} |
||||
|
|
||||
|
assert client.get('/api/sessao/sessaoplenaria/%s/' % previa.pk |
||||
|
).status_code == 404 |
||||
|
assert client.get('/api/sessao/sessaoplenaria/%s/ecidadania/' % previa.pk |
||||
|
).status_code == 404 |
||||
|
assert client.get('/api/sessao/sessaoplenaria/%s/expedientes/' % previa.pk |
||||
|
).status_code == 404 |
||||
|
assert client.get('/api/sessao/sessaoplenaria/%s/expedientes/' |
||||
|
% iniciada.pk).status_code == 200 |
||||
|
|
||||
|
assert _listados(client, 'orador', 'sessao_plenaria', sessoes) == { |
||||
|
iniciada.pk} |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_api_mostra_sessao_previa_ao_usuario_autenticado(client, sessoes): |
||||
|
previa, iniciada = sessoes |
||||
|
client.force_login(baker.make(get_user_model())) |
||||
|
|
||||
|
assert _listados(client, 'sessaoplenaria', 'id', sessoes) == { |
||||
|
previa.pk, iniciada.pk} |
||||
|
assert client.get('/api/sessao/sessaoplenaria/%s/' % previa.pk |
||||
|
).status_code == 200 |
||||
|
assert _listados(client, 'orador', 'sessao_plenaria', sessoes) == { |
||||
|
previa.pk, iniciada.pk} |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_media_de_sessao_oculta_do_anonimo(client, settings, sessoes): |
||||
|
settings.DEBUG = False |
||||
|
previa, iniciada = sessoes |
||||
|
orador_previa = Orador.objects.get(sessao_plenaria=previa) |
||||
|
orador_iniciada = Orador.objects.get(sessao_plenaria=iniciada) |
||||
|
|
||||
|
def get(path): |
||||
|
return client.get('/media/sapl/public/' + path).status_code |
||||
|
|
||||
|
assert get('sessaoplenaria/%s/pauta/p.pdf' % previa.pk) == 404 |
||||
|
assert get('sessaoplenaria/%s/pauta/p.pdf' % iniciada.pk) == 200 |
||||
|
assert get('orador/%s/anexo/a.pdf' % orador_previa.pk) == 404 |
||||
|
assert get('orador/%s/anexo/a.pdf' % orador_iniciada.pk) == 200 |
||||
|
|
||||
|
client.force_login(baker.make(get_user_model())) |
||||
|
assert get('sessaoplenaria/%s/pauta/p.pdf' % previa.pk) == 200 |
||||
|
assert get('orador/%s/anexo/a.pdf' % orador_previa.pk) == 200 |
||||
@ -0,0 +1,689 @@ |
|||||
|
import re |
||||
|
from unittest import mock |
||||
|
|
||||
|
import pytest |
||||
|
from django.contrib.messages import get_messages |
||||
|
from django.db import IntegrityError |
||||
|
from django.urls import reverse |
||||
|
from model_bakery import baker |
||||
|
|
||||
|
from sapl.base.models import AuditLog |
||||
|
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa |
||||
|
from sapl.parlamentares.models import (Legislatura, Parlamentar, |
||||
|
SessaoLegislativa) |
||||
|
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao, |
||||
|
SessaoPlenaria, TipoResultadoVotacao, |
||||
|
TipoSessaoPlenaria, VotoParlamentar) |
||||
|
|
||||
|
NOMINAL = 2 |
||||
|
|
||||
|
|
||||
|
def _sessao_plenaria(): |
||||
|
legislatura = baker.make(Legislatura) |
||||
|
sessao_legislativa = baker.make(SessaoLegislativa) |
||||
|
tipo = baker.make(TipoSessaoPlenaria) |
||||
|
return baker.make(SessaoPlenaria, legislatura=legislatura, |
||||
|
sessao_legislativa=sessao_legislativa, tipo=tipo, numero=1) |
||||
|
|
||||
|
|
||||
|
def _materia(): |
||||
|
tipo_materia = baker.make(TipoMateriaLegislativa) |
||||
|
return baker.make(MateriaLegislativa, tipo=tipo_materia) |
||||
|
|
||||
|
|
||||
|
def _ordem_nominal_aberta(): |
||||
|
sessao = _sessao_plenaria() |
||||
|
materia = _materia() |
||||
|
ordem = baker.make(OrdemDia, sessao_plenaria=sessao, materia=materia, |
||||
|
tipo_votacao=NOMINAL, votacao_aberta=True, |
||||
|
registro_aberto=False) |
||||
|
return sessao, ordem |
||||
|
|
||||
|
|
||||
|
def _presente(sessao): |
||||
|
parlamentar = baker.make(Parlamentar, ativo=True) |
||||
|
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=parlamentar) |
||||
|
return parlamentar |
||||
|
|
||||
|
|
||||
|
def _registrar_url(sessao, ordem): |
||||
|
return reverse('sapl.sessao:votacaonominal', |
||||
|
kwargs={'pk': sessao.pk, 'oid': ordem.pk, 'mid': ordem.materia_id}) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_get_registrar_votacao_nao_bloqueia_novos_votos(admin_client): |
||||
|
""" |
||||
|
Regressão da causa raiz #1: abrir a tela "Registrar Votação" não pode, |
||||
|
sozinho, impedir que vereadores que ainda não votaram continuem votando. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
_presente(sessao) |
||||
|
|
||||
|
response = admin_client.get(_registrar_url(sessao, ordem)) |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.registro_aberto is False |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_post_sem_acao_reconhecida_redireciona_para_registro(admin_client): |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
_presente(sessao) |
||||
|
|
||||
|
response = admin_client.post(_registrar_url(sessao, ordem) + '?page=2', {}) |
||||
|
|
||||
|
assert response.status_code == 302 |
||||
|
assert response.url == _registrar_url(sessao, ordem) + '?page=2' |
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.registro_aberto is False |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_botao_registrar_votacao_nominal_usa_get(admin_client): |
||||
|
""" |
||||
|
A tela de registro não tem efeito colateral no GET; com o botão em GET |
||||
|
o ?page vai na URL e é preservado até o redirect final do post(). |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
|
||||
|
response = admin_client.get( |
||||
|
reverse('sapl.sessao:ordemdia_list', kwargs={'pk': sessao.pk})) |
||||
|
|
||||
|
conteudo = response.content.decode() |
||||
|
form = re.search( |
||||
|
r'<form action="%s"[^>]*>' % re.escape(_registrar_url(sessao, ordem)), |
||||
|
conteudo) |
||||
|
assert form |
||||
|
assert form.group(0) == '<form action="%s">' % _registrar_url(sessao, ordem) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_bloquear_e_reabrir_votacao_nao_mexe_em_votos_existentes(admin_client): |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
votante = _presente(sessao) |
||||
|
baker.make(VotoParlamentar, ordem=ordem, parlamentar=votante, voto='Sim') |
||||
|
|
||||
|
url = _registrar_url(sessao, ordem) |
||||
|
|
||||
|
response = admin_client.post(url, {'bloquear-registro-votacao': '1'}) |
||||
|
assert response.status_code == 302 |
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.registro_aberto is True |
||||
|
|
||||
|
response = admin_client.post(url, {'reabrir-votacao': '1'}) |
||||
|
assert response.status_code == 302 |
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.registro_aberto is False |
||||
|
|
||||
|
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votante) |
||||
|
assert voto.voto == 'Sim' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_salvar_votacao_nao_sobrescreve_voto_ja_registrado(admin_client): |
||||
|
""" |
||||
|
Regressão do modelo de concorrência (3b): um formulário de "Fechar |
||||
|
Votação" com um valor obsoleto para quem já votou pelo tablet não pode |
||||
|
sobrescrever esse voto — só preenche quem ainda não tem voto registrado. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
ja_votou = _presente(sessao) |
||||
|
ainda_nao_votou = _presente(sessao) |
||||
|
baker.make(VotoParlamentar, ordem=ordem, parlamentar=ja_votou, voto='Sim') |
||||
|
|
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
||||
|
|
||||
|
url = _registrar_url(sessao, ordem) |
||||
|
payload = { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': [ |
||||
|
# valor obsoleto: a tela do operador ainda não sabia que este |
||||
|
# parlamentar já havia votado "Sim" pelo tablet |
||||
|
'Não Votou:{}'.format(ja_votou.pk), |
||||
|
'Não:{}'.format(ainda_nao_votou.pk), |
||||
|
], |
||||
|
} |
||||
|
|
||||
|
response = admin_client.post(url, payload) |
||||
|
assert response.status_code == 302 |
||||
|
|
||||
|
voto_ja_votou = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ja_votou) |
||||
|
assert voto_ja_votou.voto == 'Sim' |
||||
|
|
||||
|
voto_novo = VotoParlamentar.objects.get(ordem=ordem, parlamentar=ainda_nao_votou) |
||||
|
assert voto_novo.voto == 'Não' |
||||
|
|
||||
|
registro = RegistroVotacao.objects.get(ordem=ordem) |
||||
|
assert registro.numero_votos_sim == 1 |
||||
|
assert registro.numero_votos_nao == 1 |
||||
|
|
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.votacao_aberta is False |
||||
|
assert ordem.registro_aberto is False |
||||
|
|
||||
|
# O 'Não Votou' enviado para quem já votou é só o valor padrão do |
||||
|
# <select>, não um conflito — não pode gerar o aviso de não sobrescrito. |
||||
|
avisos = [str(m) for m in get_messages(response.wsgi_request)] |
||||
|
assert not any('não foram sobrescritos' in a for a in avisos) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_salvar_votacao_avisa_quando_voto_do_tablet_diverge_do_formulario(admin_client): |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
ja_votou = _presente(sessao) |
||||
|
baker.make(VotoParlamentar, ordem=ordem, parlamentar=ja_votou, voto='Sim') |
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
||||
|
|
||||
|
response = admin_client.post(_registrar_url(sessao, ordem), { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': ['Não:{}'.format(ja_votou.pk)], |
||||
|
}) |
||||
|
|
||||
|
assert response.status_code == 302 |
||||
|
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=ja_votou).voto == 'Sim' |
||||
|
avisos = [str(m) for m in get_messages(response.wsgi_request)] |
||||
|
assert any('não foram sobrescritos' in a for a in avisos) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_salvar_votacao_registra_nao_votou_para_presentes_sem_voto(admin_client): |
||||
|
""" |
||||
|
Relatórios, transparência e o painel leem VotoParlamentar por votação: |
||||
|
quem estava presente e não votou precisa ficar registrado como |
||||
|
'Não Votou', vinculado ao RegistroVotacao. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
votou = _presente(sessao) |
||||
|
nao_votou = _presente(sessao) |
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
||||
|
|
||||
|
response = admin_client.post(_registrar_url(sessao, ordem), { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': [ |
||||
|
'Sim:{}'.format(votou.pk), |
||||
|
'Não Votou:{}'.format(nao_votou.pk), |
||||
|
], |
||||
|
}) |
||||
|
|
||||
|
assert response.status_code == 302 |
||||
|
registro = RegistroVotacao.objects.get(ordem=ordem) |
||||
|
assert registro.numero_votos_sim == 1 |
||||
|
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=nao_votou) |
||||
|
assert voto.voto == 'Não Votou' |
||||
|
assert voto.votacao_id == registro.id |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_salvar_votacao_registra_votos_no_auditlog(admin_client): |
||||
|
""" |
||||
|
O AuditLog é gravado por post_save; o vínculo dos votos ao registro e |
||||
|
as linhas 'Não Votou' precisam passar por save() para ficarem auditados. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
votou = _presente(sessao) |
||||
|
nao_votou = _presente(sessao) |
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
||||
|
|
||||
|
admin_client.post(_registrar_url(sessao, ordem), { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': [ |
||||
|
'Sim:{}'.format(votou.pk), |
||||
|
'Não Votou:{}'.format(nao_votou.pk), |
||||
|
], |
||||
|
}) |
||||
|
|
||||
|
registro = RegistroVotacao.objects.get(ordem=ordem) |
||||
|
voto_sim = VotoParlamentar.objects.get(ordem=ordem, parlamentar=votou) |
||||
|
voto_nao_votou = VotoParlamentar.objects.get(ordem=ordem, parlamentar=nao_votou) |
||||
|
logs = AuditLog.objects.filter(model_name='VotoParlamentar') |
||||
|
vinculo = logs.filter(operation='U', object_id=voto_sim.pk).first() |
||||
|
assert vinculo is not None |
||||
|
assert vinculo.data['fields']['votacao'] == registro.pk |
||||
|
criacao = logs.get(operation='C', object_id=voto_nao_votou.pk) |
||||
|
assert criacao.data['fields']['voto'] == 'Não Votou' |
||||
|
assert criacao.data['fields']['votacao'] == registro.pk |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_salvar_votacao_ignora_parlamentar_inativo_presente(admin_client): |
||||
|
""" |
||||
|
O formulário da Mesa só lista parlamentares ativos; um inativo marcado |
||||
|
como presente não pode ganhar 'Não Votou' nem entrar no total. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
votou = _presente(sessao) |
||||
|
inativo = _presente(sessao) |
||||
|
inativo.ativo = False |
||||
|
inativo.save() |
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
||||
|
|
||||
|
response = admin_client.get(_registrar_url(sessao, ordem)) |
||||
|
assert response.context['total'] == 1 |
||||
|
|
||||
|
admin_client.post(_registrar_url(sessao, ordem), { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': ['Sim:{}'.format(votou.pk)], |
||||
|
}) |
||||
|
|
||||
|
assert RegistroVotacao.objects.filter(ordem=ordem).exists() |
||||
|
assert not VotoParlamentar.objects.filter( |
||||
|
ordem=ordem, parlamentar=inativo).exists() |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_salvar_votacao_ignora_voto_de_quem_teve_presenca_removida(admin_client): |
||||
|
""" |
||||
|
Parlamentar vota pelo tablet, sai e tem a presença removida antes do |
||||
|
encerramento: o voto não entra na contagem e é apagado. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
presente = _presente(sessao) |
||||
|
saiu = _presente(sessao) |
||||
|
baker.make(VotoParlamentar, ordem=ordem, parlamentar=saiu, voto='Sim') |
||||
|
PresencaOrdemDia.objects.filter(sessao_plenaria=sessao, parlamentar=saiu).delete() |
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Rejeitada', natureza='R') |
||||
|
|
||||
|
response = admin_client.post(_registrar_url(sessao, ordem), { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': ['Não:{}'.format(presente.pk)], |
||||
|
}) |
||||
|
|
||||
|
assert response.status_code == 302 |
||||
|
registro = RegistroVotacao.objects.get(ordem=ordem) |
||||
|
assert registro.numero_votos_sim == 0 |
||||
|
assert registro.numero_votos_nao == 1 |
||||
|
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=saiu).exists() |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_salvar_votacao_sem_votos_nao_trava_selects_para_nova_tentativa(admin_client): |
||||
|
""" |
||||
|
Regressão: fechar a votação sem nenhum voto real corretamente mostra um |
||||
|
erro, mas antes disso o laço de salvamento em lote criava um |
||||
|
VotoParlamentar com voto='Não Votou' para cada parlamentar cujo select |
||||
|
não foi tocado (o valor padrão do <select>). Esses registros persistiam |
||||
|
mesmo com o fechamento falhando (o bloco atomic não é revertido, já que |
||||
|
form_invalid retorna normalmente em vez de lançar), e nominal.html |
||||
|
desabilita o <select> de qualquer parlamentar com um VotoParlamentar |
||||
|
existente — travando o operador para sempre sem conseguir registrar |
||||
|
nenhum voto para essa matéria. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
parlamentar = _presente(sessao) |
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A') |
||||
|
|
||||
|
url = _registrar_url(sessao, ordem) |
||||
|
payload = { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': ['Não Votou:{}'.format(parlamentar.pk)], |
||||
|
} |
||||
|
|
||||
|
response = admin_client.post(url, payload) |
||||
|
assert response.status_code == 302 |
||||
|
|
||||
|
assert not VotoParlamentar.objects.filter( |
||||
|
ordem=ordem, parlamentar=parlamentar).exists() |
||||
|
|
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.votacao_aberta is True |
||||
|
|
||||
|
# O operador consegue tentar de novo, agora com um voto real. |
||||
|
response2 = admin_client.post(url, { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)], |
||||
|
}) |
||||
|
assert response2.status_code == 302 |
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.votacao_aberta is False |
||||
|
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_status_da_votacao_reflete_troca_de_voto_do_parlamentar(admin_client): |
||||
|
""" |
||||
|
Regressão: a tela de registro (nominal.html) não atualizava a linha de |
||||
|
um parlamentar que trocou o voto durante a janela de votação — o poll |
||||
|
antigo só marcava "já votou" uma vez e nunca revisitava o valor. O poll |
||||
|
(?status=1) precisa sempre devolver o voto atual, não só se existe. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
parlamentar = _presente(sessao) |
||||
|
voto = baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, |
||||
|
voto='Sim') |
||||
|
|
||||
|
url = _registrar_url(sessao, ordem) + '?status=1' |
||||
|
response = admin_client.get(url) |
||||
|
assert response.status_code == 200 |
||||
|
data = response.json() |
||||
|
assert data['votos'] == {str(parlamentar.pk): 'Sim'} |
||||
|
assert data['votacao_aberta'] is True |
||||
|
assert data['registro_aberto'] is False |
||||
|
assert data['ja_registrada'] is False |
||||
|
|
||||
|
voto.voto = 'Não' |
||||
|
voto.save() |
||||
|
|
||||
|
response2 = admin_client.get(url) |
||||
|
assert response2.json()['votos'] == {str(parlamentar.pk): 'Não'} |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_status_da_votacao_nao_depende_de_mostrar_voto(admin_client): |
||||
|
""" |
||||
|
O poll da tela de registro é só para a Mesa, não para o público — ao |
||||
|
contrário de sapl.painel:dados_painel, ele não pode mascarar o valor |
||||
|
real do voto por trás de "Voto Informado" mesmo quando a Casa configura |
||||
|
mostrar_voto=False (essa config controla o telão público, não a tela de |
||||
|
registro da própria Mesa). Como o endpoint nem consulta essa |
||||
|
configuração, isso é garantido por construção — este teste só |
||||
|
documenta a expectativa. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
parlamentar = _presente(sessao) |
||||
|
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, |
||||
|
voto='Abstenção') |
||||
|
|
||||
|
url = _registrar_url(sessao, ordem) + '?status=1' |
||||
|
data = admin_client.get(url).json() |
||||
|
assert data['votos'][str(parlamentar.pk)] == 'Abstenção' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_status_da_votacao_nao_redireciona_apos_encerrar_votacao( |
||||
|
admin_client): |
||||
|
""" |
||||
|
_get_materia_votacao (usado pelo GET normal) redireciona com uma |
||||
|
mensagem quando a matéria já foi votada — comportamento certo para |
||||
|
navegação, errado para um poll em background. O branch ?status=1 não |
||||
|
pode herdar esse redirect. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
parlamentar = _presente(sessao) |
||||
|
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', |
||||
|
natureza='A') |
||||
|
|
||||
|
url = _registrar_url(sessao, ordem) |
||||
|
admin_client.post(url, { |
||||
|
'salvar-votacao': '1', |
||||
|
'resultado_votacao': str(tipo_resultado.pk), |
||||
|
'observacao': '', |
||||
|
'voto_parlamentar': ['Sim:{}'.format(parlamentar.pk)], |
||||
|
}) |
||||
|
|
||||
|
response = admin_client.get(url + '?status=1') |
||||
|
assert response.status_code == 200 |
||||
|
data = response.json() |
||||
|
assert data['ja_registrada'] is True |
||||
|
assert data['votacao_aberta'] is False |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_abrir_votacao_ja_aberta_e_idempotente(admin_client): |
||||
|
""" |
||||
|
Regressão: clicar em "Abrir Votação" para uma matéria que já está aberta |
||||
|
(ex.: duplo clique, ou a lista ainda não recarregou para trocar o botão |
||||
|
por "Registrar Votação") caía em verifica_votacoes_abertas(), que trata |
||||
|
a própria matéria como uma "outra" votação conflitante — mostra a |
||||
|
mensagem "existem votações abertas... foram fechadas" e fecha a matéria |
||||
|
(para reabri-la em seguida, já que o código sempre prossegue). O |
||||
|
resultado final até ficava certo (votacao_aberta=True), mas a mensagem |
||||
|
era enganosa. Reabrir a própria matéria já aberta precisa ser |
||||
|
silenciosamente idempotente. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
sessao.iniciada = True |
||||
|
sessao.finalizada = False |
||||
|
sessao.save() |
||||
|
_presente(sessao) |
||||
|
|
||||
|
url = reverse('sapl.sessao:abrir_votacao', kwargs={'pk': ordem.pk, 'spk': sessao.pk}) |
||||
|
url += '?tipo_materia=ordem' |
||||
|
|
||||
|
response = admin_client.get(url, follow=True) |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.votacao_aberta is True |
||||
|
|
||||
|
mensagens = [str(m) for m in response.context['messages']] |
||||
|
assert not any('foram fechadas' in m for m in mensagens) |
||||
|
|
||||
|
|
||||
|
def _abrir_votacao_url(sessao, ordem): |
||||
|
return reverse('sapl.sessao:abrir_votacao', |
||||
|
kwargs={'pk': ordem.pk, 'spk': sessao.pk}) + '?tipo_materia=ordem' |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_abrir_outra_votacao_zera_bloqueio_da_anterior(admin_client): |
||||
|
""" |
||||
|
Abrir Y sem encerrar X fecha X; X não pode voltar com os tablets |
||||
|
bloqueados (registro_aberto=True) quando for reaberta. |
||||
|
""" |
||||
|
sessao, x = _ordem_nominal_aberta() |
||||
|
sessao.iniciada = True |
||||
|
sessao.finalizada = False |
||||
|
sessao.save() |
||||
|
_presente(sessao) |
||||
|
x.registro_aberto = True |
||||
|
x.save() |
||||
|
y = baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(), |
||||
|
tipo_votacao=NOMINAL, votacao_aberta=False) |
||||
|
|
||||
|
admin_client.get(_abrir_votacao_url(sessao, y)) |
||||
|
|
||||
|
x.refresh_from_db() |
||||
|
y.refresh_from_db() |
||||
|
assert y.votacao_aberta is True |
||||
|
assert x.votacao_aberta is False |
||||
|
assert x.registro_aberto is False |
||||
|
|
||||
|
# Legado: matéria fechada que ficou com registro_aberto=True. |
||||
|
OrdemDia.objects.filter(pk=x.pk).update(registro_aberto=True) |
||||
|
admin_client.get(_abrir_votacao_url(sessao, x)) |
||||
|
|
||||
|
x.refresh_from_db() |
||||
|
assert x.votacao_aberta is True |
||||
|
assert x.registro_aberto is False |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_abrir_votacao_concorrente_mostra_erro_em_vez_de_500(admin_client): |
||||
|
""" |
||||
|
O lock de abrir_votacao() é por sessão, mas a unicidade de |
||||
|
votacao_aberta é global: uma abertura simultânea em outra sessão faz o |
||||
|
save cair no índice parcial. O fechamento das outras votações é |
||||
|
desfeito junto, então a mensagem "foram fechadas" não pode aparecer. |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
ordem.votacao_aberta = False |
||||
|
ordem.save() |
||||
|
_, outra = _ordem_nominal_aberta() |
||||
|
sessao.iniciada = True |
||||
|
sessao.finalizada = False |
||||
|
sessao.save() |
||||
|
_presente(sessao) |
||||
|
|
||||
|
with mock.patch.object(OrdemDia, 'save', side_effect=IntegrityError): |
||||
|
response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True) |
||||
|
|
||||
|
assert response.status_code == 200 |
||||
|
ordem.refresh_from_db() |
||||
|
outra.refresh_from_db() |
||||
|
assert ordem.votacao_aberta is False |
||||
|
assert outra.votacao_aberta is True |
||||
|
mensagens = [str(m) for m in response.context['messages']] |
||||
|
assert any('aberta simultaneamente' in m for m in mensagens) |
||||
|
assert not any('foram fechadas' in m for m in mensagens) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_abrir_votacao_avisa_que_fechou_as_outras(admin_client): |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
ordem.votacao_aberta = False |
||||
|
ordem.save() |
||||
|
_, outra = _ordem_nominal_aberta() |
||||
|
sessao.iniciada = True |
||||
|
sessao.finalizada = False |
||||
|
sessao.save() |
||||
|
_presente(sessao) |
||||
|
|
||||
|
response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True) |
||||
|
|
||||
|
outra.refresh_from_db() |
||||
|
assert outra.votacao_aberta is False |
||||
|
mensagens = [str(m) for m in response.context['messages']] |
||||
|
assert any('foram fechadas' in m for m in mensagens) |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_abrir_votacao_audita_fechamento_das_outras(admin_client): |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
ordem.votacao_aberta = False |
||||
|
ordem.save() |
||||
|
_, outra = _ordem_nominal_aberta() |
||||
|
sessao.iniciada = True |
||||
|
sessao.finalizada = False |
||||
|
sessao.save() |
||||
|
_presente(sessao) |
||||
|
|
||||
|
admin_client.get(_abrir_votacao_url(sessao, ordem)) |
||||
|
|
||||
|
fechamento = AuditLog.objects.filter( |
||||
|
model_name='OrdemDia', operation='U', object_id=outra.pk).first() |
||||
|
assert fechamento is not None |
||||
|
assert fechamento.data['fields']['votacao_aberta'] is False |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_unique_constraint_impede_voto_duplicado(admin_client): |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
parlamentar = _presente(sessao) |
||||
|
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim') |
||||
|
|
||||
|
with pytest.raises(IntegrityError): |
||||
|
VotoParlamentar.objects.create( |
||||
|
ordem=ordem, parlamentar=parlamentar, voto='Não') |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_unique_constraint_impede_duas_ordens_abertas(): |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
outra = baker.make(OrdemDia, sessao_plenaria=sessao, materia=ordem.materia, |
||||
|
tipo_votacao=NOMINAL, votacao_aberta=False) |
||||
|
|
||||
|
with pytest.raises(IntegrityError): |
||||
|
outra.votacao_aberta = True |
||||
|
outra.save() |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_migracao_0071_remove_votos_duplicados_antes_da_constraint(): |
||||
|
import importlib |
||||
|
|
||||
|
from django.apps import apps as real_apps |
||||
|
from django.db import connection |
||||
|
|
||||
|
migracao = importlib.import_module( |
||||
|
'sapl.sessao.migrations.0071_votoparlamentar_unique_constraint') |
||||
|
|
||||
|
with connection.cursor() as cursor: |
||||
|
cursor.execute( |
||||
|
'DROP INDEX sessao_votoparlamentar_unique_parlamentar_ordem') |
||||
|
|
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
parlamentar = _presente(sessao) |
||||
|
antigo = baker.make(VotoParlamentar, ordem=ordem, |
||||
|
parlamentar=parlamentar, voto='Não') |
||||
|
recente = baker.make(VotoParlamentar, ordem=ordem, |
||||
|
parlamentar=parlamentar, voto='Sim') |
||||
|
|
||||
|
migracao.remove_votos_duplicados(real_apps, None) |
||||
|
|
||||
|
votos = VotoParlamentar.objects.filter( |
||||
|
ordem=ordem, parlamentar=parlamentar) |
||||
|
assert list(votos.values_list('id', flat=True)) == [recente.id] |
||||
|
assert not votos.filter(id=antigo.id).exists() |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_migracao_0072_fecha_duplicatas_antes_da_constraint(): |
||||
|
""" |
||||
|
A função de dados da migration 0072 precisa fechar duplicatas |
||||
|
pré-existentes antes do AddConstraint — senão a migration falharia ao |
||||
|
ser aplicada num banco com dado antigo (de antes desta invariante |
||||
|
existir). Testa a função isoladamente: como o teste roda dentro de uma |
||||
|
transação que é desfeita no final, é seguro derrubar o índice aqui |
||||
|
(DDL é transacional no Postgres). |
||||
|
""" |
||||
|
import importlib |
||||
|
|
||||
|
from django.apps import apps as real_apps |
||||
|
from django.db import connection |
||||
|
|
||||
|
migracao = importlib.import_module( |
||||
|
'sapl.sessao.migrations.0072_votacao_aberta_unique_constraint') |
||||
|
|
||||
|
with connection.cursor() as cursor: |
||||
|
cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta') |
||||
|
|
||||
|
sessao, mais_antiga = _ordem_nominal_aberta() |
||||
|
mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao, |
||||
|
materia=mais_antiga.materia, tipo_votacao=NOMINAL, |
||||
|
votacao_aberta=True, registro_aberto=True) |
||||
|
assert mais_recente.pk > mais_antiga.pk |
||||
|
# Legado: o GET antigo da tela de registro deixava registro_aberto=True |
||||
|
# em matérias já encerradas. |
||||
|
encerrada = baker.make(OrdemDia, sessao_plenaria=sessao, |
||||
|
materia=mais_antiga.materia, tipo_votacao=NOMINAL, |
||||
|
votacao_aberta=False, registro_aberto=True) |
||||
|
|
||||
|
migracao.fecha_materias_abertas_duplicadas(real_apps, None) |
||||
|
|
||||
|
mais_antiga.refresh_from_db() |
||||
|
mais_recente.refresh_from_db() |
||||
|
encerrada.refresh_from_db() |
||||
|
assert mais_antiga.votacao_aberta is False |
||||
|
assert mais_recente.votacao_aberta is True |
||||
|
assert mais_recente.registro_aberto is False |
||||
|
assert encerrada.registro_aberto is False |
||||
|
|
||||
|
|
||||
|
@pytest.mark.django_db(transaction=False) |
||||
|
def test_api_nao_permite_abrir_votacao_via_patch(admin_client): |
||||
|
""" |
||||
|
Regressão do bypass encontrado na auditoria: a API auto-gerada |
||||
|
(drfautoapi) não pode mais aceitar votacao_aberta/registro_aberto — |
||||
|
senão qualquer "Operador de Sessão Plenária" conseguiria abrir uma |
||||
|
matéria via PATCH direto, pulando verifica_votacoes_abertas() e o |
||||
|
lock em abrir_votacao(). |
||||
|
""" |
||||
|
sessao, ordem = _ordem_nominal_aberta() |
||||
|
ordem.votacao_aberta = False |
||||
|
ordem.save() |
||||
|
|
||||
|
url = '/api/sessao/ordemdia/{}/'.format(ordem.pk) |
||||
|
response = admin_client.patch( |
||||
|
url, data={'votacao_aberta': True}, content_type='application/json') |
||||
|
|
||||
|
assert response.status_code in (200, 202) |
||||
|
ordem.refresh_from_db() |
||||
|
assert ordem.votacao_aberta is False |
||||
File diff suppressed because it is too large
@ -1,120 +0,0 @@ |
|||||
{% load i18n %} |
|
||||
{% load common_tags %} |
|
||||
{% load render_bundle from webpack_loader %} |
|
||||
{% load webpack_static from webpack_loader %} |
|
||||
|
|
||||
<!DOCTYPE HTML> |
|
||||
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]--> |
|
||||
<!--[if gt IE 8]><!--> |
|
||||
<html lang="pt-br"> |
|
||||
<!--<![endif]--> |
|
||||
|
|
||||
<head> |
|
||||
<meta charset="UTF-8"> |
|
||||
<!-- TODO: does it need this head_title here? --> |
|
||||
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title> |
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
||||
|
|
||||
{% render_chunk_vendors 'css' %} |
|
||||
{% render_bundle 'global' 'css' %} |
|
||||
{% render_bundle 'painel' 'css' %} |
|
||||
|
|
||||
|
|
||||
<STYLE type="text/css"> |
|
||||
@media screen { |
|
||||
body {font-size: medium; color: white; line-height: 1em; background: black;} |
|
||||
} |
|
||||
</STYLE> |
|
||||
|
|
||||
</head> |
|
||||
<body> |
|
||||
<h1>{{ context.title }}</h1> |
|
||||
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}"> |
|
||||
<h2>Ajax refresh counter: <span id="counter"></span></h2> |
|
||||
<h3> |
|
||||
<span id="sessao_plenaria"></span><br/><br/> |
|
||||
<span id="sessao_plenaria_data"></span><br/><br/> |
|
||||
<span id="sessao_plenaria_hora_inicio"></span></br><br/> |
|
||||
<h2><span id="relogio"></span></h2></br><br/><br/> |
|
||||
<span id="materia_legislativa_texto"></span><br/> |
|
||||
<span id="observacao_materia"></span> |
|
||||
</h3> |
|
||||
</body> |
|
||||
|
|
||||
{% render_chunk_vendors 'js' %} |
|
||||
{% render_bundle 'global' 'js' %} |
|
||||
{% render_bundle 'painel' 'js' %} |
|
||||
|
|
||||
<script type="text/javascript"> |
|
||||
$(document).ready(function() { |
|
||||
|
|
||||
//TODO: replace by a fancy jQuery clock |
|
||||
function checkTime(i) { |
|
||||
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10 |
|
||||
return i; |
|
||||
} |
|
||||
function startTime() { |
|
||||
var today=new Date(); |
|
||||
var h=today.getHours(); |
|
||||
var m=today.getMinutes(); |
|
||||
var s=today.getSeconds(); |
|
||||
m = checkTime(m); |
|
||||
s = checkTime(s); |
|
||||
$("#relogio").text(h+":"+m+":"+s) |
|
||||
var t = setTimeout(function(){ |
|
||||
startTime() |
|
||||
},500); |
|
||||
} |
|
||||
|
|
||||
startTime(); |
|
||||
|
|
||||
var counter = 1; |
|
||||
(function poll() { |
|
||||
$.ajax({ |
|
||||
url: $("#json_url").val(), |
|
||||
type: "GET", |
|
||||
success: function(data) { |
|
||||
|
|
||||
//TODO: json spitted out is very complex, have to simplify/flat it |
|
||||
//TODO: probably building it by hand on REST side |
|
||||
|
|
||||
console.debug(data) |
|
||||
|
|
||||
var presentes = $("#parlamentares"); |
|
||||
presentes.children().remove(); |
|
||||
|
|
||||
presentes_ordem_dia = data.presentes_ordem_dia |
|
||||
$.each(presentes_ordem_dia, function(index, parlamentar) { |
|
||||
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes); |
|
||||
}); |
|
||||
|
|
||||
var votacao = $("#votacao") |
|
||||
votacao.children().remove() |
|
||||
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>") |
|
||||
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>") |
|
||||
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>") |
|
||||
votacao.append("<li>Presentes: " + data["presentes"] + "</li>") |
|
||||
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>") |
|
||||
|
|
||||
$("#sessao_plenaria").text(data["sessao_plenaria"]) |
|
||||
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"]) |
|
||||
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"]) |
|
||||
|
|
||||
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"]) |
|
||||
$("#observacao_materia").text(data["observacao_materia"]) |
|
||||
$("#resultado_votacao").text(data["tipo_resultado"]) |
|
||||
|
|
||||
$("#counter").text(counter); |
|
||||
counter++; |
|
||||
}, |
|
||||
error: function(err) { |
|
||||
console.error(err); |
|
||||
}, |
|
||||
dataType: "json", |
|
||||
//complete: setTimeout(function() {poll()}, 5000), |
|
||||
timeout: 20000 // TODO: decrease |
|
||||
}) |
|
||||
})(); |
|
||||
}); |
|
||||
</script> |
|
||||
</html> |
|
||||
@ -1,128 +0,0 @@ |
|||||
{% load i18n %} |
|
||||
{% load common_tags %} |
|
||||
|
|
||||
{% load render_bundle from webpack_loader %} |
|
||||
{% load webpack_static from webpack_loader %} |
|
||||
|
|
||||
<!DOCTYPE HTML> |
|
||||
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]--> |
|
||||
<!--[if gt IE 8]><!--> |
|
||||
<html lang="pt-br"> |
|
||||
<!--<![endif]--> |
|
||||
|
|
||||
<head> |
|
||||
<meta charset="UTF-8"> |
|
||||
<!-- TODO: does it need this head_title here? --> |
|
||||
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title> |
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
||||
|
|
||||
{% render_chunk_vendors 'css' %} |
|
||||
{% render_bundle 'global' 'css' %} |
|
||||
{% render_bundle 'painel' 'css' %} |
|
||||
|
|
||||
|
|
||||
|
|
||||
<STYLE type="text/css"> |
|
||||
@media screen { |
|
||||
body {font-size: medium; color: white; line-height: 1em; background: black;} |
|
||||
} |
|
||||
</STYLE> |
|
||||
|
|
||||
</head> |
|
||||
<body> |
|
||||
<h1>{{ context.title }}</h1> |
|
||||
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}"> |
|
||||
<h3> |
|
||||
<span id="sessao_plenaria"></span><br/><br/> |
|
||||
<span id="sessao_plenaria_data"></span><br/><br/> |
|
||||
<span id="sessao_plenaria_hora_inicio"></span></br><br/> |
|
||||
<h2><span id="relogio"></span></h2> |
|
||||
<table> |
|
||||
<tr> |
|
||||
<td> |
|
||||
<ul id="parlamentares"> |
|
||||
</ul> |
|
||||
</td> |
|
||||
</tr> |
|
||||
</table> |
|
||||
</h3> |
|
||||
</body> |
|
||||
|
|
||||
{% render_chunk_vendors 'js' %} |
|
||||
{% render_bundle 'global' 'js' %} |
|
||||
{% render_bundle 'painel' 'js' %} |
|
||||
|
|
||||
<script type="text/javascript"> |
|
||||
$(document).ready(function() { |
|
||||
|
|
||||
//TODO: replace by a fancy jQuery clock |
|
||||
function checkTime(i) { |
|
||||
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10 |
|
||||
return i; |
|
||||
} |
|
||||
function startTime() { |
|
||||
var today=new Date(); |
|
||||
var h=today.getHours(); |
|
||||
var m=today.getMinutes(); |
|
||||
var s=today.getSeconds(); |
|
||||
m = checkTime(m); |
|
||||
s = checkTime(s); |
|
||||
$("#relogio").text(h+":"+m+":"+s) |
|
||||
var t = setTimeout(function(){ |
|
||||
startTime() |
|
||||
},500); |
|
||||
} |
|
||||
|
|
||||
startTime(); |
|
||||
|
|
||||
var counter = 1; |
|
||||
(function poll() { |
|
||||
$.ajax({ |
|
||||
url: $("#json_url").val(), |
|
||||
type: "GET", |
|
||||
success: function(data) { |
|
||||
|
|
||||
//TODO: json spitted out is very complex, have to simplify/flat it |
|
||||
//TODO: probably building it by hand on REST side |
|
||||
|
|
||||
console.debug(data) |
|
||||
|
|
||||
var presentes = $("#parlamentares"); |
|
||||
presentes.children().remove(); |
|
||||
|
|
||||
presentes_ordem_dia = data.presentes_ordem_dia |
|
||||
$.each(presentes_ordem_dia, function(index, parlamentar) { |
|
||||
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido }).appendTo(presentes); |
|
||||
/*$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes);*/ |
|
||||
}); |
|
||||
|
|
||||
var votacao = $("#votacao") |
|
||||
votacao.children().remove() |
|
||||
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>") |
|
||||
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>") |
|
||||
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>") |
|
||||
votacao.append("<li>Presentes: " + data["presentes"] + "</li>") |
|
||||
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>") |
|
||||
|
|
||||
$("#sessao_plenaria").text(data["sessao_plenaria"]) |
|
||||
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"]) |
|
||||
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"]) |
|
||||
|
|
||||
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"]) |
|
||||
$("#observacao_materia").text(data["observacao_materia"]) |
|
||||
$("#resultado_votacao").text(data["tipo_resultado"]) |
|
||||
|
|
||||
$("#counter").text(counter); |
|
||||
counter++; |
|
||||
}, |
|
||||
error: function(err) { |
|
||||
console.error(err); |
|
||||
}, |
|
||||
dataType: "json", |
|
||||
//complete: setTimeout(function() {poll()}, 5000), |
|
||||
timeout: 20000 // TODO: decrease |
|
||||
}) |
|
||||
})(); |
|
||||
}); |
|
||||
</script> |
|
||||
</html> |
|
||||
@ -1,123 +0,0 @@ |
|||||
{% load i18n %} |
|
||||
{% load render_bundle from webpack_loader %} |
|
||||
{% load webpack_static from webpack_loader %} |
|
||||
|
|
||||
<!DOCTYPE HTML> |
|
||||
<!--[if IE 8]> <html class="no-js lt-ie9" lang="pt-br"> <![endif]--> |
|
||||
<!--[if gt IE 8]><!--> |
|
||||
<html lang="pt-br"> |
|
||||
<!--<![endif]--> |
|
||||
|
|
||||
<head> |
|
||||
<meta charset="UTF-8"> |
|
||||
<!-- TODO: does it need this head_title here? --> |
|
||||
<title>{% block head_title %}{% trans 'SAPL - Sistema de Apoio ao Processo Legislativo' %}{% endblock %}</title> |
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
||||
|
|
||||
{% render_chunk_vendors 'css' %} |
|
||||
{% render_bundle 'global' 'css' %} |
|
||||
{% render_bundle 'painel' 'css' %} |
|
||||
|
|
||||
<STYLE type="text/css"> |
|
||||
@media screen { |
|
||||
body {font-size: medium; color: white; line-height: 1em; background: black;} |
|
||||
} |
|
||||
</STYLE> |
|
||||
</head> |
|
||||
<body> |
|
||||
<h1>{{ context.title }}</h1> |
|
||||
<input id="json_url" type="hidden" value="{% url 'sapl.painel:dados_painel' %}"> |
|
||||
<h3> |
|
||||
<span id="sessao_plenaria"></span><br/><br/> |
|
||||
<span id="sessao_plenaria_data"></span><br/><br/> |
|
||||
<span id="sessao_plenaria_hora_inicio"></span></br><br/> |
|
||||
<h2><span id="relogio"></span></h2> |
|
||||
<table> |
|
||||
<tr> |
|
||||
<td> |
|
||||
<ul id="votacao"> |
|
||||
</ul> |
|
||||
</td> |
|
||||
</tr> |
|
||||
</table> |
|
||||
<span id="resultado_votacao"></span><br/> |
|
||||
</h3> |
|
||||
</body> |
|
||||
|
|
||||
{% render_chunk_vendors 'js' %} |
|
||||
{% render_bundle 'global' 'js' %} |
|
||||
{% render_bundle 'painel' 'js' %} |
|
||||
|
|
||||
<script type="text/javascript"> |
|
||||
$(document).ready(function() { |
|
||||
|
|
||||
//TODO: replace by a fancy jQuery clock |
|
||||
function checkTime(i) { |
|
||||
if (i<10) {i = "0" + i}; // add zero in front of numbers < 10 |
|
||||
return i; |
|
||||
} |
|
||||
function startTime() { |
|
||||
var today=new Date(); |
|
||||
var h=today.getHours(); |
|
||||
var m=today.getMinutes(); |
|
||||
var s=today.getSeconds(); |
|
||||
m = checkTime(m); |
|
||||
s = checkTime(s); |
|
||||
$("#relogio").text(h+":"+m+":"+s) |
|
||||
var t = setTimeout(function(){ |
|
||||
startTime() |
|
||||
},500); |
|
||||
} |
|
||||
|
|
||||
startTime(); |
|
||||
|
|
||||
var counter = 1; |
|
||||
(function poll() { |
|
||||
$.ajax({ |
|
||||
url: $("#json_url").val(), |
|
||||
type: "GET", |
|
||||
success: function(data) { |
|
||||
|
|
||||
//TODO: json spitted out is very complex, have to simplify/flat it |
|
||||
//TODO: probably building it by hand on REST side |
|
||||
|
|
||||
console.debug(data) |
|
||||
|
|
||||
var presentes = $("#parlamentares"); |
|
||||
presentes.children().remove(); |
|
||||
|
|
||||
presentes_ordem_dia = data.presentes_ordem_dia |
|
||||
$.each(presentes_ordem_dia, function(index, parlamentar) { |
|
||||
$('<li />', {text: parlamentar.nome + '/' + parlamentar.partido + ' ' + parlamentar.voto }).appendTo(presentes); |
|
||||
}); |
|
||||
|
|
||||
var votacao = $("#votacao") |
|
||||
votacao.children().remove() |
|
||||
votacao.append("<li>Sim: " + data["numero_votos_sim"] + "</li>") |
|
||||
votacao.append("<li>Não: " + data["numero_votos_nao"] + "</li>") |
|
||||
votacao.append("<li>Abstenções: " + data["numero_abstencoes"] + "</li>") |
|
||||
votacao.append("<li>Presentes: " + data["presentes"] + "</li>") |
|
||||
votacao.append("<li>Total votos: " + data["total_votos"] + "</li>") |
|
||||
|
|
||||
$("#sessao_plenaria").text(data["sessao_plenaria"]) |
|
||||
$("#sessao_plenaria_data").text("Data Início: " + data["sessao_plenaria_data"]) |
|
||||
$("#sessao_plenaria_hora_inicio").text("Hora Início: " + data["sessao_plenaria_hora_inicio"]) |
|
||||
|
|
||||
$("#materia_legislativa_texto").text(data["materia_legislativa_texto"]) |
|
||||
$("#observacao_materia").text(data["observacao_materia"]) |
|
||||
$("#resultado_votacao").text(data["tipo_resultado"]) |
|
||||
|
|
||||
$("#counter").text(counter); |
|
||||
counter++; |
|
||||
}, |
|
||||
error: function(err) { |
|
||||
console.error(err); |
|
||||
}, |
|
||||
dataType: "json", |
|
||||
//complete: setTimeout(function() {poll()}, 5000), |
|
||||
timeout: 20000 // TODO: decrease |
|
||||
}) |
|
||||
})(); |
|
||||
}); |
|
||||
</script> |
|
||||
</html> |
|
||||
@ -1,4 +1,5 @@ |
|||||
|
{% load common_tags %} |
||||
<h2 class="gray-title">Considerações Finais</h2> |
<h2 class="gray-title">Considerações Finais</h2> |
||||
{% for c in lst_consideracoes%} |
{% for c in lst_consideracoes%} |
||||
<p>{{c|striptags|safe}}</p> |
<p>{{c|sanitize|striptags}}</p> |
||||
{% endfor %} |
{% endfor %} |
||||
|
|||||
@ -1,5 +1,6 @@ |
|||||
|
{% load common_tags %} |
||||
<h2 class="gray-title">Expedientes</h2> |
<h2 class="gray-title">Expedientes</h2> |
||||
{% for expediente in lst_expedientes%} |
{% for expediente in lst_expedientes%} |
||||
<h3>{{expediente.nom_expediente}}</h3> |
<h3>{{expediente.nom_expediente}}</h3> |
||||
<div style="margin-bottom: 1cm">{{expediente.txt_expediente|safe}}</div> |
<div style="margin-bottom: 1cm">{{expediente.txt_expediente|sanitize}}</div> |
||||
{% endfor%} |
{% endfor%} |
||||
|
|||||
@ -1,4 +1,5 @@ |
|||||
|
{% load common_tags %} |
||||
<h2 class="gray-title">Ocorrências da Sessão</h2> |
<h2 class="gray-title">Ocorrências da Sessão</h2> |
||||
{% for o in lst_ocorrencias%} |
{% for o in lst_ocorrencias%} |
||||
<p>{{o.conteudo|striptags|safe}}</p> |
<p>{{o.conteudo|sanitize|striptags}}</p> |
||||
{% endfor %} |
{% endfor %} |
||||
|
|||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue