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 |
|||
|
|||
|
|||
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__) |
|||
|
|||
@ -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> |
|||
{% for c in lst_consideracoes%} |
|||
<p>{{c|striptags|safe}}</p> |
|||
<p>{{c|sanitize|striptags}}</p> |
|||
{% endfor %} |
|||
|
|||
@ -1,5 +1,6 @@ |
|||
{% load common_tags %} |
|||
<h2 class="gray-title">Expedientes</h2> |
|||
{% for expediente in lst_expedientes%} |
|||
<h3>{{expediente.nom_expediente}}</h3> |
|||
<div style="margin-bottom: 1cm">{{expediente.txt_expediente|safe}}</div> |
|||
<div style="margin-bottom: 1cm">{{expediente.txt_expediente|sanitize}}</div> |
|||
{% endfor%} |
|||
|
|||
@ -1,4 +1,5 @@ |
|||
{% load common_tags %} |
|||
<h2 class="gray-title">Ocorrências da Sessão</h2> |
|||
{% for o in lst_ocorrencias%} |
|||
<p>{{o.conteudo|striptags|safe}}</p> |
|||
<p>{{o.conteudo|sanitize|striptags}}</p> |
|||
{% endfor %} |
|||
|
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue