Browse Source

more changes

feat/painel-votacao-v2
Edward Ribeiro 1 week ago
parent
commit
55bce97f83
  1. 13
      sapl/api/serializers.py
  2. 19
      sapl/api/tests/test_serializers_sessao.py
  3. 20
      sapl/audiencia/forms.py
  4. 59
      sapl/audiencia/migrations/0021_auto_20260623_1938.py
  5. 18
      sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py
  6. 2
      sapl/audiencia/migrations/0023_auto_20260916_1709.py
  7. 11
      sapl/audiencia/models.py
  8. 25
      sapl/comissoes/forms.py
  9. 18
      sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py
  10. 3
      sapl/comissoes/models.py
  11. 11
      sapl/comissoes/views.py
  12. 4
      sapl/compilacao/utils.py
  13. 5
      sapl/crud/base.py
  14. 64
      sapl/painel/tests/tests.py
  15. 265
      sapl/painel/views.py
  16. 18
      sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py
  17. 4
      sapl/parlamentares/models.py
  18. 28
      sapl/protocoloadm/migrations/0046_auto_20260626_1220.py
  19. 2
      sapl/protocoloadm/migrations/0047_auto_20260916_1709.py
  20. 3
      sapl/protocoloadm/models.py
  21. 17
      sapl/relatorios/views.py
  22. 40
      sapl/sessao/migrations/0070_presenca_unica_por_sessao.py
  23. 21
      sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py
  24. 54
      sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py
  25. 16
      sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py
  26. 2
      sapl/sessao/migrations/0073_sessao_presencas_e_materias_votacoes_views.py
  27. 2
      sapl/sessao/migrations/0074_sessaomateriasvotacoesview_sessaopresencasview.py
  28. 21
      sapl/sessao/models.py
  29. 156
      sapl/sessao/tests/test_sessao_view.py
  30. 328
      sapl/sessao/tests/test_votacao_nominal.py
  31. 120
      sapl/sessao/views.py
  32. 4
      sapl/templates/audiencia/layouts.yaml
  33. 7
      sapl/templates/base.html
  34. 2
      sapl/templates/compilacao/dispositivo_form_alteracao.html
  35. 2
      sapl/templates/compilacao/dispositivo_form_edicao_basica.html
  36. 6
      sapl/templates/compilacao/text_edit.html
  37. 2
      sapl/templates/relatorios/relatorio_materia_tramitacao.html
  38. 7
      sapl/templates/sessao/sessaoplenaria_filter.html
  39. 9
      sapl/utils.py

13
sapl/api/serializers.py

@ -313,6 +313,12 @@ class SessaoPlenariaECidadaniaSerializer(serializers.ModelSerializer):
def get_sigla_orgao(self, obj): def get_sigla_orgao(self, obj):
return self.casa().sigla return self.casa().sigla
def get_nome_orgao(self, obj):
return self.casa().nome
def casa(self):
return CasaLegislativa.objects.first()
class OrdemDiaSerializer(SaplSerializerMixin): class OrdemDiaSerializer(SaplSerializerMixin):
""" """
@ -333,10 +339,3 @@ class ExpedienteMateriaSerializer(SaplSerializerMixin):
model = ExpedienteMateria model = ExpedienteMateria
fields = '__all__' fields = '__all__'
read_only_fields = ('votacao_aberta', 'registro_aberto') read_only_fields = ('votacao_aberta', 'registro_aberto')
def get_nome_orgao(self, obj):
return self.casa().nome
def casa(self):
casa = CasaLegislativa.objects.first()
return casa

19
sapl/api/tests/test_serializers_sessao.py

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

20
sapl/audiencia/forms.py

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

59
sapl/audiencia/migrations/0021_auto_20260623_1938.py

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

18
sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py

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

2
sapl/audiencia/migrations/0021_auto_20260916_1709.py → sapl/audiencia/migrations/0023_auto_20260916_1709.py

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('audiencia', '0020_auto_20251201_1450'), ('audiencia', '0022_audienciapublica_data_ultima_atualizacao'),
] ]
operations = [ operations = [

11
sapl/audiencia/models.py

@ -3,7 +3,7 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from model_utils import Choices from model_utils import Choices
from sapl.materia.models import MateriaLegislativa from sapl.materia.models import MateriaLegislativa
from sapl.parlamentares.models import (CargoMesa, Parlamentar) from sapl.parlamentares.models import CargoMesa
from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericRelation, from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericRelation,
restringe_tipos_de_arquivo_txt, texto_upload_path, restringe_tipos_de_arquivo_txt, texto_upload_path,
@ -79,12 +79,15 @@ class AudienciaPublica(models.Model):
default=False, default=False,
choices=YES_NO_CHOICES, choices=YES_NO_CHOICES,
verbose_name=_('Audiência Cancelada?')) verbose_name=_('Audiência Cancelada?'))
parlamentar_autor = models.ForeignKey( autor = models.ForeignKey(
Parlamentar, 'base.Autor',
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=True, blank=True,
verbose_name=_('Parlamentar Autor')) verbose_name=_('Autor'))
data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True,
verbose_name=_('Data da Última Atualização'))
requerimento = models.ForeignKey( requerimento = models.ForeignKey(
MateriaLegislativa, MateriaLegislativa,
null=True, null=True,

25
sapl/comissoes/forms.py

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

18
sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py

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

3
sapl/comissoes/models.py

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

11
sapl/comissoes/views.py

@ -22,7 +22,7 @@ from sapl.comissoes.forms import (ComissaoForm, ComposicaoForm,
ParticipacaoCreateForm, ParticipacaoCreateForm,
ParticipacaoEditForm, ParticipacaoEditForm,
PautaReuniaoFilterSet, PautaReuniaoForm, PautaReuniaoFilterSet, PautaReuniaoForm,
PeriodoForm, ReuniaoForm) PeriodoForm, ReuniaoFilterSet, ReuniaoForm)
from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud, from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud,
PermissionRequiredForAppCrudMixin, RP_DETAIL, PermissionRequiredForAppCrudMixin, RP_DETAIL,
RP_LIST) RP_LIST)
@ -216,7 +216,7 @@ class ReuniaoCrud(MasterDetailCrud):
public = [RP_LIST, RP_DETAIL, ] public = [RP_LIST, RP_DETAIL, ]
class BaseMixin(MasterDetailCrud.BaseMixin): class BaseMixin(MasterDetailCrud.BaseMixin):
list_field_names = ['data', 'nome', 'tema', 'upload_ata'] list_field_names = ['data', 'nome', 'tema', 'upload_pauta', 'upload_ata']
class DetailView(MasterDetailCrud.DetailView): class DetailView(MasterDetailCrud.DetailView):
template_name = "comissoes/reuniao_detail.html" template_name = "comissoes/reuniao_detail.html"
@ -248,6 +248,11 @@ class ReuniaoCrud(MasterDetailCrud):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
paginate_by = 10 paginate_by = 10
def get_queryset(self):
qs = super().get_queryset()
self.filterset = ReuniaoFilterSet(self.request.GET, queryset=qs)
return self.filterset.qs
def take_reuniao_pk(self): def take_reuniao_pk(self):
username = self.request.user.username username = self.request.user.username
@ -276,6 +281,8 @@ class ReuniaoCrud(MasterDetailCrud):
context['documentoacessorio_set'] = DocumentoAcessorio.objects.filter( context['documentoacessorio_set'] = DocumentoAcessorio.objects.filter(
reuniao__pk=context['reuniao_pk'] reuniao__pk=context['reuniao_pk']
).order_by('id') ).order_by('id')
context['form'] = self.filterset.form
return context return context
class UpdateView(MasterDetailCrud.UpdateView): class UpdateView(MasterDetailCrud.UpdateView):

4
sapl/compilacao/utils.py

@ -59,8 +59,8 @@ def int_to_letter(int_value):
def get_integrations_view_names(): def get_integrations_view_names():
result = [] result = []
modules = sys.modules # list(): importar um módulo durante a varredura altera sys.modules.
for key, value in modules.items(): for key, value in list(sys.modules.items()):
if key.endswith('.views'): if key.endswith('.views'):
for v in value.__dict__.values(): for v in value.__dict__.values():
if hasattr(v, '__bases__'): if hasattr(v, '__bases__'):

5
sapl/crud/base.py

@ -863,6 +863,11 @@ class CrudDetailView(PermissionRequiredContainerCrudMixin,
context[context_object_name] = self.object context[context_object_name] = self.object
context.update(kwargs) context.update(kwargs)
# Mensagem de atualidade (PNTP): data de última atualização da entidade,
# quando o modelo possuir o campo. Consumido pelo banner em base.html.
context['data_atualizacao_conteudo'] = getattr(
self.object, 'data_ultima_atualizacao', None)
return context return context
@property @property

64
sapl/painel/tests/tests.py

@ -13,7 +13,8 @@ from sapl.painel.views import build_dados_painel
from sapl.parlamentares.models import (Filiacao, Legislatura, Mandato, from sapl.parlamentares.models import (Filiacao, Legislatura, Mandato,
Parlamentar, Partido, Parlamentar, Partido,
SessaoLegislativa, Votante) SessaoLegislativa, Votante)
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, SessaoPlenaria, from sapl.sessao.models import (ExpedienteMateria, OrdemDia,
PresencaOrdemDia, SessaoPlenaria,
TipoResultadoVotacao, TipoSessaoPlenaria, TipoResultadoVotacao, TipoSessaoPlenaria,
VotoParlamentar) VotoParlamentar)
@ -183,7 +184,6 @@ def test_propria_tela_nao_mostra_voto_de_outra_materia(admin_client):
ordem_antiga.save() ordem_antiga.save()
baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(), baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(),
tipo_votacao=NOMINAL, votacao_aberta=True, registro_aberto=False) tipo_votacao=NOMINAL, votacao_aberta=True, registro_aberto=False)
baker.make(PresencaOrdemDia, sessao_plenaria=sessao, parlamentar=vereador)
status_url = reverse('sapl.painel:voto_individual_status') status_url = reverse('sapl.painel:voto_individual_status')
@ -262,3 +262,63 @@ def test_votante_status_reflete_estado_e_nao_exige_permissao_do_painel():
data2 = resposta2.json() data2 = resposta2.json()
assert data2['voto_parlamentar'] == 'Não' assert data2['voto_parlamentar'] == 'Não'
assert 'encerramento da votação' in data2['status_message'] 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
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('voto, registro_aberto', [
('Não Votou', False), # não é um voto de fato
('Sim', True), # Mesa bloqueou novos votos
])
def test_tablet_rejeita_voto_sem_erro_500(voto, registro_aberto):
sessao, ordem = _ordem_nominal_aberta(registro_aberto=registro_aberto)
vereador, votante_client = _votante_com_client(sessao)
response = votante_client.post(
reverse('sapl.painel:voto_individual'), {'voto': voto})
assert response.status_code == 302
assert not VotoParlamentar.objects.filter(
ordem=ordem, parlamentar=vereador).exists()
@pytest.mark.django_db(transaction=False)
def test_tablet_revalida_presenca_no_momento_do_voto():
"""
A presença é conferida de novo sob o lock da matéria, não só quando o
contexto do tablet foi resolvido.
"""
from sapl.painel.views import (VoteError, _resolve_votante_context,
_save_voto_individual)
sessao, ordem = _ordem_nominal_aberta()
vereador, votante_client = _votante_com_client(sessao)
request = votante_client.get(reverse('sapl.painel:voto_individual')).wsgi_request
request.user = Votante.objects.get(parlamentar=vereador).user
_, context_vars = _resolve_votante_context(request)
PresencaOrdemDia.objects.filter(sessao_plenaria=sessao, parlamentar=vereador).delete()
with pytest.raises(VoteError):
_save_voto_individual(request, context_vars, voto_valor='Sim', ip='127.0.0.1')
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=vereador).exists()

265
sapl/painel/views.py

@ -2,12 +2,11 @@ import html
import json import json
import logging import logging
from django.contrib import messages
from django.contrib.auth.decorators import (login_required, permission_required, from django.contrib.auth.decorators import (login_required, permission_required,
user_passes_test) user_passes_test)
from django.core.cache import cache from django.core.cache import cache
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db import IntegrityError, transaction from django.db import transaction
from django.urls import reverse from django.urls import reverse
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponse, JsonResponse from django.http import HttpResponse, JsonResponse
@ -49,6 +48,10 @@ def votacao_aberta(request):
Função que verifica se há somente 1 uma matéria aberta ou Função que verifica se há somente 1 uma matéria aberta ou
nenhuma. É utilizada como uma função auxiliar para a view nenhuma. É utilizada como uma função auxiliar para a view
votante_view. votante_view.
A mensagem de erro volta como texto puro e é exibida pelo chamador em
error_message (inclusive no poll JSON do tablet), então não é
registrada em messages — senão cada poll empilharia uma cópia na sessão.
''' '''
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
username = request.user.username username = request.user.username
@ -58,19 +61,13 @@ def votacao_aberta(request):
Q(expedientemateria__votacao_aberta=True)).distinct() Q(expedientemateria__votacao_aberta=True)).distinct()
if len(votacoes_abertas) > 1: if len(votacoes_abertas) > 1:
msg_abertas = [] sessoes = ', '.join(str(v) for v in votacoes_abertas)
for v in votacoes_abertas:
msg_abertas.append('''<li><a href="%s">%s</a></li>''' % (
reverse('sapl.sessao:sessaoplenaria_detail',
kwargs={'pk': v.id}),
v.__str__()))
logger.info('user=' + username + '. Existe mais de uma votações aberta. Elas se encontram ' logger.info('user=' + username + '. Existe mais de uma votações aberta. Elas se encontram '
'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. ' 'nas seguintes Sessões: ' + sessoes + '. '
'Para votar, peça para que o Operador feche-as.') 'Para votar, peça para que o Operador feche-as.')
msg = _('Existe mais de uma votações aberta. Elas se encontram ' msg = _('Existe mais de uma votações aberta. Elas se encontram '
'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. ' 'nas seguintes Sessões: ' + sessoes + '. '
'Para votar, peça para que o Operador feche-as.') 'Para votar, peça para que o Operador feche-as.')
messages.add_message(request, messages.INFO, msg)
return None, msg return None, msg
elif len(votacoes_abertas) == 1: elif len(votacoes_abertas) == 1:
@ -83,18 +80,11 @@ def votacao_aberta(request):
numero_materias_abertas = len(ordens) + len(expedientes) numero_materias_abertas = len(ordens) + len(expedientes)
if numero_materias_abertas > 1: if numero_materias_abertas > 1:
sessao = str(votacoes_abertas.first())
logger.info('user=' + username + '. Existe mais de uma votação aberta na Sessão: ' + logger.info('user=' + username + '. Existe mais de uma votação aberta na Sessão: ' +
('''<li><a href="%s">%s</a></li>''' % ( sessao)
reverse('sapl.sessao:sessaoplenaria_detail', msg = _('Existe mais de uma votação aberta na Sessão: ' + sessao +
kwargs={'pk': votacoes_abertas.first().id}), '. Para votar, peça para que o Operador as feche.')
votacoes_abertas.first().__str__())))
msg = _('Existe mais de uma votação aberta na Sessão: ' +
('''<li><a href="%s">%s</a></li>''' % (
reverse('sapl.sessao:sessaoplenaria_detail',
kwargs={'pk': votacoes_abertas.first().id}),
votacoes_abertas.first().__str__())) +
'Para votar, peça para que o Operador as feche.')
messages.add_message(request, messages.INFO, msg)
return None, msg return None, msg
return votacoes_abertas.first(), None return votacoes_abertas.first(), None
@ -250,6 +240,37 @@ def votante_status(request):
}) })
def _trava_materia_para_voto(materia, parlamentar):
"""
Trava a linha da matéria (select_for_update) e confere, sob o lock, que
ela ainda aceita voto deste parlamentar: presente, votação aberta, não
bloqueada pela Mesa, nominal e ainda sem RegistroVotacao. Deve ser
chamada dentro de transaction.atomic(). Devolve o filtro da matéria
para VotoParlamentar ({'ordem': ...} ou {'expediente': ...}).
"""
if isinstance(materia, OrdemDia):
materia = OrdemDia.objects.select_for_update().get(pk=materia.pk)
fase_sessao = {'ordem': materia}
presenca_model = PresencaOrdemDia
else:
materia = ExpedienteMateria.objects.select_for_update().get(pk=materia.pk)
fase_sessao = {'expediente': materia}
presenca_model = SessaoPlenariaPresenca
esta_presente = presenca_model.objects.filter(
sessao_plenaria_id=materia.sessao_plenaria_id,
parlamentar=parlamentar).exists()
if not esta_presente:
raise VoteError(
"O parlamentar não está presente na sessão.", status=409)
if (not materia.votacao_aberta or materia.registro_aberto or
materia.tipo_votacao != VOTACAO_NOMINAL or
RegistroVotacao.objects.filter(**fase_sessao).exists()):
raise VoteError(
"A votação não está mais disponível para novos votos.", status=409)
return fase_sessao
def _save_voto_individual(request, context_vars, voto_valor=None, ip=None): def _save_voto_individual(request, context_vars, voto_valor=None, ip=None):
""" """
Salva o voto do Votante autenticado — compartilhado por votante_view Salva o voto do Votante autenticado — compartilhado por votante_view
@ -259,48 +280,37 @@ def _save_voto_individual(request, context_vars, voto_valor=None, ip=None):
voto/IP explicitamente em vez de ler de request.POST/request.META, voto/IP explicitamente em vez de ler de request.POST/request.META,
que uma conexão WebSocket não tem; o caminho HTTP não passa nada e que uma conexão WebSocket não tem; o caminho HTTP não passa nada e
mantém o comportamento de sempre. mantém o comportamento de sempre.
O voto do próprio parlamentar sempre prevalece sobre um valor lançado
pela Mesa. Levanta VoteError quando a votação não aceita mais votos.
""" """
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
username = request.user.username username = request.user.username
if context_vars['ordem_dia']: materia = context_vars.get('ordem_dia') or context_vars.get('expediente')
fase_sessao = {'ordem': context_vars['ordem_dia']} parlamentar = context_vars.get('parlamentar')
elif context_vars['expediente']: if not materia or not parlamentar:
fase_sessao = {'expediente': context_vars['expediente']} raise VoteError("A votação não está disponível para novos votos.", status=409)
else:
fase_sessao = None
if fase_sessao is None:
return
voto_valor = voto_valor if voto_valor is not None else request.POST['voto'] voto_valor = voto_valor if voto_valor is not None else request.POST.get('voto')
if voto_valor not in VALID_VOTE_VALUES: if voto_valor not in VOTOS_REAIS:
raise VoteError( raise VoteError(
f"Invalid vote value: {voto_valor}. Must be one of: {VALID_VOTE_VALUES}") f"Invalid vote value: {voto_valor}. Must be one of: {list(VOTOS_REAIS)}")
# select_for_update+atomic evita corrida com uma escrita concorrente
# na mesma linha (ex.: o operador registrando este mesmo parlamentar
# em lote na tela "Registrar Votação" ao mesmo tempo). Diferente do
# formulário em lote do operador, aqui é sempre seguro aplicar o
# valor enviado: é o próprio parlamentar atualizando o próprio voto.
try:
with transaction.atomic(): with transaction.atomic():
voto, created = VotoParlamentar.objects.select_for_update().get_or_create( fase_sessao = _trava_materia_para_voto(materia, parlamentar)
parlamentar=context_vars['parlamentar'], **fase_sessao) voto, _created = VotoParlamentar.objects.select_for_update().get_or_create(
except IntegrityError: parlamentar=parlamentar, **fase_sessao)
voto = VotoParlamentar.objects.select_for_update().get(
parlamentar=context_vars['parlamentar'], **fase_sessao)
logger.info("user=" + username + ". VotoParlamentar para parlamentar={} obtido com sucesso."
.format(context_vars['parlamentar']))
voto.voto = voto_valor voto.voto = voto_valor
voto.ip = ip if ip is not None else get_client_ip(request) voto.ip = ip if ip is not None else get_client_ip(request)
voto.user = request.user voto.user = request.user
# Único caminho onde é o próprio parlamentar votando — vote_controller/ # Único caminho onde é o próprio parlamentar votando — vote_controller/
# _cast_vote (operador) e o formulário em lote sempre gravam False. # _cast_vote (operador) sempre grava False.
voto.votado_pelo_parlamentar = True voto.votado_pelo_parlamentar = True
voto.save() voto.save()
logger.info("user=" + username + ". VotoParlamentar para parlamentar={} salvo com sucesso."
.format(parlamentar))
broadcast_dados_painel(request, context_vars['sessao'].id) broadcast_dados_painel(request, context_vars['sessao'].id)
return voto_valor return voto_valor
@ -311,7 +321,13 @@ def votante_view(request):
context, context_vars = _resolve_votante_context(request) context, context_vars = _resolve_votante_context(request)
if request.method == 'POST': if request.method == 'POST':
try:
_save_voto_individual(request, context_vars) _save_voto_individual(request, context_vars)
except VoteError as e:
# Recarregar a tela já mostra o estado atual (bloqueada,
# encerrada, ausente) em error_message.
logging.getLogger(__name__).info(
"user=%s. Voto recusado: %s", request.user.username, e.message)
return HttpResponseRedirect( return HttpResponseRedirect(
reverse('sapl.painel:voto_individual')) reverse('sapl.painel:voto_individual'))
@ -732,6 +748,8 @@ def painel_view(request, sessao_id):
VALID_VOTE_VALUES = ["Sim", "Não", "Abstenção", "Não Votou"] VALID_VOTE_VALUES = ["Sim", "Não", "Abstenção", "Não Votou"]
# Valores que contam como voto; "Não Votou" só é gravado no encerramento.
VOTOS_REAIS = ("Sim", "Não", "Abstenção")
class VoteError(Exception): class VoteError(Exception):
@ -782,17 +800,33 @@ def _cast_vote(user, controller_id, parlamentar_id, voto, ip=None):
except Parlamentar.DoesNotExist: except Parlamentar.DoesNotExist:
raise VoteError(f"Parlamentar {parlamentar_id} not found", status=404) raise VoteError(f"Parlamentar {parlamentar_id} not found", status=404)
# votado_pelo_parlamentar=False explícito (não só o default do campo): with transaction.atomic():
# se este parlamentar já tinha um voto marcado como do próprio (tablet) fase_sessao = _trava_materia_para_voto(materia_aberta, parlamentar)
# e o operador está sobrescrevendo agora, a flag precisa acompanhar — voto_obj = VotoParlamentar.objects.select_for_update().filter(
# o valor atual deixou de ser o que o parlamentar escolheu. parlamentar=parlamentar, **fase_sessao).first()
defaults = {'voto': voto, 'user': user, 'ip': ip, 'votado_pelo_parlamentar': False}
if ordem_dia: # O voto registrado pelo próprio parlamentar (tablet) prevalece: a
voto_obj, created = VotoParlamentar.objects.update_or_create( # Mesa só lança ou corrige votos que ela mesma registrou.
parlamentar=parlamentar, ordem=ordem_dia, defaults=defaults) if voto_obj and voto_obj.votado_pelo_parlamentar:
raise VoteError(
f"O voto de {parlamentar.nome_parlamentar} já foi registrado "
f"pelo próprio parlamentar e não pode ser alterado pela Mesa.",
status=409)
created = voto_obj is None
if voto == 'Não Votou':
# Desfaz o lançamento da Mesa; a linha 'Não Votou' só é
# gravada no encerramento, para os presentes sem voto.
if voto_obj:
voto_obj.delete()
else: else:
voto_obj, created = VotoParlamentar.objects.update_or_create( if created:
parlamentar=parlamentar, expediente=expediente, defaults=defaults) voto_obj = VotoParlamentar(parlamentar=parlamentar, **fase_sessao)
voto_obj.voto = voto
voto_obj.user = user
voto_obj.ip = ip
voto_obj.votado_pelo_parlamentar = False
voto_obj.save()
logger.info( logger.info(
f"Vote {'created' if created else 'updated'}: " f"Vote {'created' if created else 'updated'}: "
@ -859,8 +893,10 @@ def _toggle_registro(user, controller_id, aberto):
if materia_aberta.tipo_votacao != VOTACAO_NOMINAL: if materia_aberta.tipo_votacao != VOTACAO_NOMINAL:
raise VoteError("Materia is not nominal voting type") raise VoteError("Materia is not nominal voting type")
materia_aberta.registro_aberto = aberto # update() só toca registro_aberto: um save() completo regravaria
materia_aberta.save() # resultado/votacao_aberta lidos antes de um encerramento concorrente.
type(materia_aberta).objects.filter(pk=materia_aberta.pk).update(
registro_aberto=aberto)
logging.getLogger(__name__).info( logging.getLogger(__name__).info(
f"registro_aberto={aberto} para materia id={materia_aberta.pk} " f"registro_aberto={aberto} para materia id={materia_aberta.pk} "
f"(sessao={controller_id}, user={user.username})") f"(sessao={controller_id}, user={user.username})")
@ -985,57 +1021,79 @@ def close_voting(request, controller_id):
if not materia_aberta: if not materia_aberta:
return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400) return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
# Count votes from VotoParlamentar with transaction.atomic():
# Trava a matéria: votos do tablet/da Mesa esperam o encerramento
# terminar, e um segundo "Encerrar" (duplo clique) vê a votação já
# registrada.
materia = type(materia_aberta).objects.select_for_update().get(
pk=materia_aberta.pk)
if ordem_dia: if ordem_dia:
votos = VotoParlamentar.objects.filter(ordem=ordem_dia) fase_sessao = {'ordem': materia}
presenca_model = PresencaOrdemDia
else: else:
votos = VotoParlamentar.objects.filter(expediente=expediente) fase_sessao = {'expediente': materia}
presenca_model = SessaoPlenariaPresenca
if (not materia.votacao_aberta or
RegistroVotacao.objects.filter(**fase_sessao).exists()):
return JsonResponse({
"type": "error",
"message": "A votação já foi encerrada por outra operação."
}, status=409)
# Só contam os presentes: quem votou e teve a presença removida
# antes do encerramento fica de fora (e o voto é apagado abaixo).
presentes = presenca_model.objects.filter(
sessao_plenaria_id=materia.sessao_plenaria_id)
votos = VotoParlamentar.objects.filter(
**fase_sessao,
parlamentar_id__in=presentes.values('parlamentar_id'))
votos_sim = votos.filter(voto='Sim').count() votos_sim = votos.filter(voto='Sim').count()
votos_nao = votos.filter(voto='Não').count() votos_nao = votos.filter(voto='Não').count()
abstencoes = votos.filter(voto='Abstenção').count() abstencoes = votos.filter(voto='Abstenção').count()
# All votes must not be "Não Votou" if votos_sim + votos_nao + abstencoes == 0:
total_votados = votos_sim + votos_nao + abstencoes
if total_votados == 0:
return JsonResponse({ return JsonResponse({
"type": "error", "type": "error",
"message": "Não é possível finalizar a votação sem nenhum voto" "message": "Não é possível finalizar a votação sem nenhum voto"
}, status=400) }, status=400)
# Remove old RegistroVotacao if exists registro = RegistroVotacao(
if ordem_dia: numero_votos_sim=votos_sim,
RegistroVotacao.objects.filter(ordem=ordem_dia).delete() numero_votos_nao=votos_nao,
else: numero_abstencoes=abstencoes,
RegistroVotacao.objects.filter(expediente=expediente).delete() observacao=observacoes,
user=request.user,
# Create RegistroVotacao ip=get_client_ip(request),
registro = RegistroVotacao() materia=materia.materia,
registro.numero_votos_sim = votos_sim tipo_resultado_votacao=tipo_resultado,
registro.numero_votos_nao = votos_nao **fase_sessao)
registro.numero_abstencoes = abstencoes
registro.observacao = observacoes
registro.user = request.user
registro.ip = get_client_ip(request)
registro.materia = materia_aberta.materia
registro.tipo_resultado_votacao = tipo_resultado
if ordem_dia:
registro.ordem = ordem_dia
else:
registro.expediente = expediente
registro.save() registro.save()
# Link VotoParlamentar records to RegistroVotacao and update user/ip # user/ip de cada voto continuam sendo de quem votou.
for voto_obj in votos: votos.update(votacao=registro)
voto_obj.votacao = registro VotoParlamentar.objects.bulk_create([
voto_obj.user = request.user VotoParlamentar(
voto_obj.ip = get_client_ip(request) parlamentar_id=presenca.parlamentar_id,
voto_obj.save() voto='Não Votou',
votacao=registro,
# Build redirect URL before closing user=request.user,
materia_id = materia_aberta.materia_id ip=get_client_ip(request),
**fase_sessao)
for presenca in presentes.exclude(
parlamentar_id__in=votos.values('parlamentar_id'))])
materia.resultado = tipo_resultado.nome
materia.votacao_aberta = False
materia.registro_aberto = False
materia.save()
VotoParlamentar.objects.filter(
**fase_sessao, votacao__isnull=True).delete()
# Build redirect URL
materia_id = materia.materia_id
if ordem_dia: if ordem_dia:
redirect_url = reverse('sapl.sessao:ordemdia_list', redirect_url = reverse('sapl.sessao:ordemdia_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}' kwargs={'pk': controller_id}) + f'#id{materia_id}'
@ -1043,17 +1101,6 @@ def close_voting(request, controller_id):
redirect_url = reverse('sapl.sessao:expedientemateria_list', redirect_url = reverse('sapl.sessao:expedientemateria_list',
kwargs={'pk': controller_id}) + f'#id{materia_id}' kwargs={'pk': controller_id}) + f'#id{materia_id}'
# Close materia with resultado
materia_aberta.resultado = tipo_resultado.nome
materia_aberta.votacao_aberta = False
materia_aberta.save()
# Clean up orphan VotoParlamentar (without votacao)
if ordem_dia:
VotoParlamentar.objects.filter(ordem=ordem_dia, votacao__isnull=True).delete()
else:
VotoParlamentar.objects.filter(expediente=expediente, votacao__isnull=True).delete()
logger.info( logger.info(
f"Voting closed for sessao={controller_id}: " f"Voting closed for sessao={controller_id}: "
f"sim={votos_sim}, nao={votos_nao}, abstencoes={abstencoes}, " f"sim={votos_sim}, nao={votos_nao}, abstencoes={abstencoes}, "

18
sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py

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

4
sapl/parlamentares/models.py

@ -193,6 +193,10 @@ class Parlamentar(models.Model):
SEXO_CHOICE = ((FEMININO, _('Feminino')), SEXO_CHOICE = ((FEMININO, _('Feminino')),
(MASCULINO, _('Masculino'))) (MASCULINO, _('Masculino')))
data_ultima_atualizacao = models.DateTimeField(
blank=True, null=True, auto_now=True,
verbose_name=_('Data da Última Atualização'))
nivel_instrucao = models.ForeignKey( nivel_instrucao = models.ForeignKey(
NivelInstrucao, NivelInstrucao,
blank=True, blank=True,

28
sapl/protocoloadm/migrations/0046_auto_20260626_1220.py

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

2
sapl/protocoloadm/migrations/0046_auto_20260916_1709.py → sapl/protocoloadm/migrations/0047_auto_20260916_1709.py

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('protocoloadm', '0045_auto_20240711_1405'), ('protocoloadm', '0046_auto_20260626_1220'),
] ]
operations = [ operations = [

3
sapl/protocoloadm/models.py

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

17
sapl/relatorios/views.py

@ -711,7 +711,7 @@ def get_sessao_plenaria(sessao, casa, user):
lst_expediente_materia_vot_nom = [] lst_expediente_materia_vot_nom = []
materias_expediente_votacao_nominal = ExpedienteMateria.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \ materias_expediente_votacao_nominal = ExpedienteMateria.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \
.order_by('-materia') .order_by('numero_ordem')
for mevn in materias_expediente_votacao_nominal: for mevn in materias_expediente_votacao_nominal:
votos_materia = [] votos_materia = []
@ -824,7 +824,7 @@ def get_sessao_plenaria(sessao, casa, user):
lst_votacao_vot_nom = [] lst_votacao_vot_nom = []
materias_ordem_dia_votacao_nominal = OrdemDia.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \ materias_ordem_dia_votacao_nominal = OrdemDia.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \
.order_by('-materia') .order_by('numero_ordem')
for modvn in materias_ordem_dia_votacao_nominal: for modvn in materias_ordem_dia_votacao_nominal:
votos_materia_od = [] votos_materia_od = []
@ -1792,7 +1792,7 @@ def relatorio_materia_tramitacao(request, pk):
'materia': materia_legislativa, 'materia': materia_legislativa,
'ano': materia_legislativa.ano, 'ano': materia_legislativa.ano,
'numero': materia_legislativa.numero, 'numero': materia_legislativa.numero,
'autor': materia_legislativa.autores.first(), 'autores': materia_legislativa.autores.all(),
'tipo': materia_legislativa.tipo.descricao, 'tipo': materia_legislativa.tipo.descricao,
'rodape': rodape, 'rodape': rodape,
'data': dt.today().strftime('%d/%m/%Y'), 'data': dt.today().strftime('%d/%m/%Y'),
@ -2080,17 +2080,22 @@ class RelatorioPresencaSessaoView(RelatorioMixin, FilterView):
parlamentares_id = parlamentares_qs.values_list('id', flat=True) parlamentares_id = parlamentares_qs.values_list('id', flat=True)
# Presenças de cada Parlamentar em Sessões # Presenças de cada Parlamentar em Sessões
# Conta sessões distintas, e não linhas de presença: bases com
# presenças repetidas para o mesmo parlamentar na mesma sessão
# produziam percentuais acima de 100%.
presenca_sessao = SessaoPlenariaPresenca.objects.filter( presenca_sessao = SessaoPlenariaPresenca.objects.filter(
**param0).values_list('parlamentar_id').annotate(sessao_count=Count('id')) **param0).values_list('parlamentar_id').annotate(
sessao_count=Count('sessao_plenaria_id', distinct=True))
# Presenças de cada Ordem do Dia # Presenças de cada Ordem do Dia
presenca_ordem = PresencaOrdemDia.objects.filter( presenca_ordem = PresencaOrdemDia.objects.filter(
**param0).values_list('parlamentar_id').annotate(sessao_count=Count('id')) **param0).values_list('parlamentar_id').annotate(
sessao_count=Count('sessao_plenaria_id', distinct=True))
# Ausencias justificadas # Ausencias justificadas
ausencia_justificadas = JustificativaAusencia.objects.filter( ausencia_justificadas = JustificativaAusencia.objects.filter(
**param0, ausencia=2).values_list('parlamentar_id')\ **param0, ausencia=2).values_list('parlamentar_id')\
.annotate(sessao_count=Count('id')) .annotate(sessao_count=Count('sessao_plenaria_id', distinct=True))
total_ordemdia = PresencaOrdemDia.objects.filter( total_ordemdia = PresencaOrdemDia.objects.filter(
**param0).distinct('sessao_plenaria__id').order_by('sessao_plenaria__id').count() **param0).distinct('sessao_plenaria__id').order_by('sessao_plenaria__id').count()

40
sapl/sessao/migrations/0070_presenca_unica_por_sessao.py

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

21
sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py

@ -1,21 +0,0 @@
# Generated by Django 2.2.28 on 2026-08-16 17:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sessao', '0069_auto_20220919_1705'),
]
operations = [
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'),
),
]

54
sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py

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

16
sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py → sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py

@ -3,16 +3,22 @@
from django.db import migrations, models from django.db import migrations, models
def fecha_matérias_abertas_duplicadas(apps, schema_editor): def fecha_materias_abertas_duplicadas(apps, schema_editor):
""" """
Pré-requisito para o AddConstraint abaixo: se já existir mais de uma Pré-requisito para o AddConstraint abaixo: se já existir mais de uma
OrdemDia/ExpedienteMateria com votacao_aberta=True (dado deixado por OrdemDia/ExpedienteMateria com votacao_aberta=True (dado deixado por
versões anteriores, que não garantiam essa invariante), a constraint versões anteriores, que não garantiam essa invariante), a constraint
falharia ao ser criada. Mantém aberta apenas a mais recentemente falharia ao ser criada. Mantém aberta apenas a criada por último (maior
modificada de cada tabela e fecha as demais. 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'): for model_name in ('OrdemDia', 'ExpedienteMateria'):
model = apps.get_model('sessao', model_name) 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') abertas = model.objects.filter(votacao_aberta=True).order_by('-id')
for materia in abertas[1:]: for materia in abertas[1:]:
materia.votacao_aberta = False materia.votacao_aberta = False
@ -22,12 +28,12 @@ def fecha_matérias_abertas_duplicadas(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('sessao', '0070_votoparlamentar_unique_constraint'), ('sessao', '0071_votoparlamentar_unique_constraint'),
] ]
operations = [ operations = [
migrations.RunPython( migrations.RunPython(
fecha_matérias_abertas_duplicadas, migrations.RunPython.noop), fecha_materias_abertas_duplicadas, migrations.RunPython.noop),
migrations.AddConstraint( migrations.AddConstraint(
model_name='expedientemateria', model_name='expedientemateria',
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'), constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'),

2
sapl/sessao/migrations/0072_sessao_presencas_e_materias_votacoes_views.py → sapl/sessao/migrations/0073_sessao_presencas_e_materias_votacoes_views.py

@ -20,7 +20,7 @@ class Migration(migrations.Migration):
""" """
dependencies = [ dependencies = [
('sessao', '0071_votacao_aberta_unique_constraint'), ('sessao', '0072_votacao_aberta_unique_constraint'),
] ]
operations = [ operations = [

2
sapl/sessao/migrations/0073_sessaomateriasvotacoesview_sessaopresencasview.py → sapl/sessao/migrations/0074_sessaomateriasvotacoesview_sessaopresencasview.py

@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('sessao', '0072_sessao_presencas_e_materias_votacoes_views'), ('sessao', '0073_sessao_presencas_e_materias_votacoes_views'),
] ]
operations = [ operations = [

21
sapl/sessao/models.py

@ -161,8 +161,12 @@ def restringe_sessoes_visiveis(qs, user):
"""Restringe o queryset às sessões visíveis na consulta pública. """Restringe o queryset às sessões visíveis na consulta pública.
Usuário autenticado enxerga todas. Para o público, ficam ocultas as Usuário autenticado enxerga todas. Para o público, ficam ocultas as
sessões cadastradas com antecedência, ou seja, as marcadas como não sessões cadastradas com antecedência, ou seja, as de data futura
iniciadas e sem pauta publicada. marcadas como não iniciadas e sem pauta publicada.
A data é o que distingue o cadastro antecipado: sessões de hoje ou de
datas passadas ficam sempre visíveis, mesmo com `iniciada` em Não, porque
há Casas que não marcam o campo nas sessões já realizadas.
`iniciada` nulo conta como visível: o default do campo só surgiu na `iniciada` nulo conta como visível: o default do campo só surgiu na
migração 0027, que não preencheu as linhas já existentes, então bases migração 0027, que não preencheu as linhas já existentes, então bases
@ -174,7 +178,8 @@ def restringe_sessoes_visiveis(qs, user):
return qs.filter( return qs.filter(
Q(publicar_pauta=True) | Q(publicar_pauta=True) |
Q(iniciada=True) | Q(iniciada=True) |
Q(iniciada__isnull=True)) Q(iniciada__isnull=True) |
Q(data_inicio__lte=timezone.localdate()))
class SessaoPlenaria(models.Model): class SessaoPlenaria(models.Model):
@ -616,6 +621,9 @@ class PresencaOrdemDia(models.Model): # OrdemDiaPresenca
verbose_name = _('Presença da Ordem do Dia') verbose_name = _('Presença da Ordem do Dia')
verbose_name_plural = _('Presenças da Ordem do Dia') verbose_name_plural = _('Presenças da Ordem do Dia')
ordering = ['parlamentar__nome_parlamentar'] ordering = ['parlamentar__nome_parlamentar']
# Presença é um sim/não: mais de uma linha para o mesmo parlamentar
# na mesma sessão infla a contagem dos relatórios.
unique_together = ('sessao_plenaria', 'parlamentar')
def __str__(self): def __str__(self):
# FIXME ambigous # FIXME ambigous
@ -778,6 +786,9 @@ class SessaoPlenariaPresenca(models.Model):
verbose_name = _('Presença em Sessão Plenária') verbose_name = _('Presença em Sessão Plenária')
verbose_name_plural = _('Presenças em Sessões Plenárias') verbose_name_plural = _('Presenças em Sessões Plenárias')
ordering = ['parlamentar__nome_parlamentar'] ordering = ['parlamentar__nome_parlamentar']
# Presença é um sim/não: mais de uma linha para o mesmo parlamentar
# na mesma sessão infla a contagem dos relatórios.
unique_together = ('sessao_plenaria', 'parlamentar')
ORDENACAO_RESUMO = [ ORDENACAO_RESUMO = [
@ -1116,7 +1127,7 @@ class Correspondencia(models.Model):
class SessaoPresencasView(models.Model): class SessaoPresencasView(models.Model):
''' '''
Backed by the `sessao_presencas_view` Postgres view (migration 0072) — Backed by the `sessao_presencas_view` Postgres view (migration 0073) —
pré-agrega presença + mandato ativo + filiação partidária, evitando o pré-agrega presença + mandato ativo + filiação partidária, evitando o
N+1 que sapl/painel/views.py::get_presentes() fazia consultando N+1 que sapl/painel/views.py::get_presentes() fazia consultando
mandato_set/filiacao_data() por parlamentar presente. `id` não é mandato_set/filiacao_data() por parlamentar presente. `id` não é
@ -1139,7 +1150,7 @@ class SessaoPresencasView(models.Model):
class SessaoMateriasVotacoesView(models.Model): class SessaoMateriasVotacoesView(models.Model):
''' '''
Backed by the `sessao_materias_votacoes_view` Postgres view Backed by the `sessao_materias_votacoes_view` Postgres view
(migration 0072) — pré-agrega o resultado da votação e os votos de (migration 0073) — pré-agrega o resultado da votação e os votos de
cada parlamentar (`votos_parlamentares`, JSONB, chaves string) numa cada parlamentar (`votos_parlamentares`, JSONB, chaves string) numa
única linha, evitando o N+1 que única linha, evitando o N+1 que
sapl/painel/views.py::get_votos() fazia consultando VotoParlamentar sapl/painel/views.py::get_votos() fazia consultando VotoParlamentar

156
sapl/sessao/tests/test_sessao_view.py

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

328
sapl/sessao/tests/test_votacao_nominal.py

@ -1,3 +1,5 @@
from unittest import mock
import pytest import pytest
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import IntegrityError from django.db import IntegrityError
@ -5,7 +7,7 @@ from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
from sapl.painel.views import VoteError, _toggle_registro from sapl.painel.views import VoteError, _cast_vote, _toggle_registro
from sapl.parlamentares.models import (Legislatura, Parlamentar, from sapl.parlamentares.models import (Legislatura, Parlamentar,
SessaoLegislativa) SessaoLegislativa)
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao, from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao,
@ -15,6 +17,12 @@ from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao,
NOMINAL = 2 NOMINAL = 2
@pytest.fixture(autouse=True)
def in_memory_channel_layer(settings):
settings.CHANNEL_LAYERS = {
'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}}
def _sessao_plenaria(): def _sessao_plenaria():
legislatura = baker.make(Legislatura) legislatura = baker.make(Legislatura)
sessao_legislativa = baker.make(SessaoLegislativa) sessao_legislativa = baker.make(SessaoLegislativa)
@ -121,6 +129,93 @@ def test_abrir_votacao_ja_aberta_e_idempotente(admin_client):
assert not any('foram fechadas' in m for m in mensagens) 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) @pytest.mark.django_db(transaction=False)
def test_unique_constraint_impede_voto_duplicado(admin_client): def test_unique_constraint_impede_voto_duplicado(admin_client):
sessao, ordem = _ordem_nominal_aberta() sessao, ordem = _ordem_nominal_aberta()
@ -144,9 +239,38 @@ def test_unique_constraint_impede_duas_ordens_abertas():
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_migracao_0071_fecha_duplicatas_antes_da_constraint(): 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 0071 precisa fechar duplicatas A função de dados da migration 0072 precisa fechar duplicatas
pré-existentes antes do AddConstraint — senão a migration falharia ao pré-existentes antes do AddConstraint — senão a migration falharia ao
ser aplicada num banco com dado antigo (de antes desta invariante ser aplicada num banco com dado antigo (de antes desta invariante
existir). Testa a função isoladamente: como o teste roda dentro de uma existir). Testa a função isoladamente: como o teste roda dentro de uma
@ -159,7 +283,7 @@ def test_migracao_0071_fecha_duplicatas_antes_da_constraint():
from django.db import connection from django.db import connection
migracao = importlib.import_module( migracao = importlib.import_module(
'sapl.sessao.migrations.0071_votacao_aberta_unique_constraint') 'sapl.sessao.migrations.0072_votacao_aberta_unique_constraint')
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta') cursor.execute('DROP INDEX sessao_ordemdia_unique_votacao_aberta')
@ -167,15 +291,23 @@ def test_migracao_0071_fecha_duplicatas_antes_da_constraint():
sessao, mais_antiga = _ordem_nominal_aberta() sessao, mais_antiga = _ordem_nominal_aberta()
mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao, mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao,
materia=mais_antiga.materia, tipo_votacao=NOMINAL, materia=mais_antiga.materia, tipo_votacao=NOMINAL,
votacao_aberta=True) votacao_aberta=True, registro_aberto=True)
assert mais_recente.pk > mais_antiga.pk 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_matérias_abertas_duplicadas(real_apps, None) migracao.fecha_materias_abertas_duplicadas(real_apps, None)
mais_antiga.refresh_from_db() mais_antiga.refresh_from_db()
mais_recente.refresh_from_db() mais_recente.refresh_from_db()
encerrada.refresh_from_db()
assert mais_antiga.votacao_aberta is False assert mais_antiga.votacao_aberta is False
assert mais_recente.votacao_aberta is True 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) @pytest.mark.django_db(transaction=False)
@ -198,3 +330,187 @@ def test_api_nao_permite_abrir_votacao_via_patch(admin_client):
assert response.status_code in (200, 202) assert response.status_code in (200, 202)
ordem.refresh_from_db() ordem.refresh_from_db()
assert ordem.votacao_aberta is False assert ordem.votacao_aberta is False
# Regras do #3855 aplicadas ao fluxo v2 (close_voting / _cast_vote /
# votante_view), que substituiu VotacaoNominalAbstract.
def _close_voting(client, sessao, tipo_resultado):
return client.post(
reverse('sapl.painel:close_voting', kwargs={'controller_id': sessao.pk}),
{'resultado_id': tipo_resultado.pk, 'observacoes': ''})
@pytest.mark.django_db(transaction=False)
def test_close_voting_conta_so_presentes_e_apaga_voto_de_quem_saiu(admin_client):
sessao, ordem = _ordem_nominal_aberta()
presente = _presente(sessao)
saiu = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=presente, voto='Não')
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 = _close_voting(admin_client, sessao, tipo_resultado)
assert response.status_code == 200
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_close_voting_registra_nao_votou_para_presentes_sem_voto(admin_client):
sessao, ordem = _ordem_nominal_aberta()
votou = _presente(sessao)
nao_votou = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=votou, voto='Sim')
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
response = _close_voting(admin_client, sessao, tipo_resultado)
assert response.status_code == 200
registro = RegistroVotacao.objects.get(ordem=ordem)
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_close_voting_sem_votos_nao_grava_nao_votou(admin_client):
"""O operador precisa poder corrigir e tentar de novo."""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
response = _close_voting(admin_client, sessao, tipo_resultado)
assert response.status_code == 400
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists()
ordem.refresh_from_db()
assert ordem.votacao_aberta is True
@pytest.mark.django_db(transaction=False)
def test_close_voting_preserva_autoria_e_zera_bloqueio(admin_client):
sessao, ordem = _ordem_nominal_aberta()
ordem.registro_aberto = True
ordem.save()
parlamentar = _presente(sessao)
autor = baker.make(get_user_model())
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim',
user=autor, ip='10.0.0.1', votado_pelo_parlamentar=True)
tipo_resultado = baker.make(TipoResultadoVotacao, nome='Aprovada', natureza='A')
response = _close_voting(admin_client, sessao, tipo_resultado)
assert response.status_code == 200
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar)
assert voto.user_id == autor.pk
assert voto.ip == '10.0.0.1'
ordem.refresh_from_db()
assert ordem.votacao_aberta is False
assert ordem.registro_aberto is False
@pytest.mark.django_db(transaction=False)
def test_operador_nao_sobrescreve_voto_do_parlamentar():
"""O voto registrado pelo próprio parlamentar (tablet) prevalece."""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim',
votado_pelo_parlamentar=True)
operador = baker.make(get_user_model())
with pytest.raises(VoteError):
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não', ip='127.0.0.1')
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar)
assert voto.voto == 'Sim'
assert voto.votado_pelo_parlamentar is True
@pytest.mark.django_db(transaction=False)
def test_operador_pode_corrigir_voto_que_ele_mesmo_registrou():
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
operador = baker.make(get_user_model())
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Sim', ip='127.0.0.1')
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não', ip='127.0.0.1')
voto = VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar)
assert voto.voto == 'Não'
assert voto.votado_pelo_parlamentar is False
@pytest.mark.django_db(transaction=False)
@pytest.mark.parametrize('cenario', ['ausente', 'bloqueado'])
def test_operador_nao_registra_voto_invalido(cenario):
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
if cenario == 'ausente':
PresencaOrdemDia.objects.filter(sessao_plenaria=sessao).delete()
else:
ordem.registro_aberto = True
ordem.save()
operador = baker.make(get_user_model())
with pytest.raises(VoteError):
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Sim', ip='127.0.0.1')
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists()
@pytest.mark.django_db(transaction=False)
def test_operador_nao_votou_desfaz_o_proprio_lancamento_sem_gravar_linha():
"""
'Não Votou' no <select> da Mesa desfaz o lançamento do operador; a
linha 'Não Votou' só é criada no encerramento.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
operador = baker.make(get_user_model())
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não Votou', ip='127.0.0.1')
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists()
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Sim', ip='127.0.0.1')
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não Votou', ip='127.0.0.1')
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists()
@pytest.mark.django_db(transaction=False)
def test_operador_nao_desfaz_voto_do_parlamentar():
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
baker.make(VotoParlamentar, ordem=ordem, parlamentar=parlamentar, voto='Sim',
votado_pelo_parlamentar=True)
operador = baker.make(get_user_model())
with pytest.raises(VoteError):
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não Votou', ip='127.0.0.1')
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_toggle_registro_nao_regrava_outros_campos():
sessao, ordem = _ordem_nominal_aberta()
user = baker.make(get_user_model())
# Outra operação altera a matéria depois que _toggle_registro a leu.
original = _toggle_registro.__globals__['get_materia_aberta']
def get_materia_aberta_obsoleta(pk):
materia = original(pk)
OrdemDia.objects.filter(pk=materia.pk).update(resultado='Aprovada')
return materia
with mock.patch.dict(_toggle_registro.__globals__,
{'get_materia_aberta': get_materia_aberta_obsoleta}):
_toggle_registro(user, sessao.pk, True)
ordem.refresh_from_db()
assert ordem.registro_aberto is True
assert ordem.resultado == 'Aprovada'

120
sapl/sessao/views.py

@ -13,7 +13,6 @@ from django.db import IntegrityError, transaction
from django.db.models import Max, Q from django.db.models import Max, Q
from django.http import JsonResponse, QueryDict from django.http import JsonResponse, QueryDict
from django.http.response import Http404, HttpResponseRedirect from django.http.response import Http404, HttpResponseRedirect
from django.middleware.csrf import get_token
from django.shortcuts import render from django.shortcuts import render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -133,7 +132,12 @@ def verifica_presenca(request, model, spk, is_leitura=False):
return True return True
def verifica_votacoes_abertas(request): def verifica_votacoes_abertas(request, mensagens):
"""
Fecha as votações abertas. A mensagem para o usuário vai para
`mensagens`, e o chamador só a registra depois do commit: se a transação
for desfeita, as votações não foram fechadas.
"""
votacoes_abertas = SessaoPlenaria.objects.filter( votacoes_abertas = SessaoPlenaria.objects.filter(
Q(ordemdia__votacao_aberta=True) | Q(ordemdia__votacao_aberta=True) |
Q(expedientemateria__votacao_aberta=True)).distinct() Q(expedientemateria__votacao_aberta=True)).distinct()
@ -152,18 +156,15 @@ def verifica_votacoes_abertas(request):
', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.') ', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.')
msg = _('Já existem votações ou leituras abertas nas seguintes Sessões: ' + msg = _('Já existem votações ou leituras abertas nas seguintes Sessões: ' +
', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.') ', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.')
messages.add_message(request, messages.INFO, msg) mensagens.append(msg)
for sessao in votacoes_abertas: # update() em vez de save(): só toca estes dois campos e reavalia o
ordens = sessao.ordemdia_set.filter(votacao_aberta=True) # WHERE sob o lock da linha, sem regravar um resultado que um
expediente = sessao.expedientemateria_set.filter( # "Encerrar Votação" concorrente tenha acabado de salvar.
votacao_aberta=True) OrdemDia.objects.filter(votacao_aberta=True).update(
for o in ordens: votacao_aberta=False, registro_aberto=False)
o.votacao_aberta = False ExpedienteMateria.objects.filter(votacao_aberta=True).update(
o.save() votacao_aberta=False, registro_aberto=False)
for e in expediente:
e.votacao_aberta = False
e.save()
return True return True
@ -208,7 +209,9 @@ def abrir_votacao(request, pk, spk):
is_leitura = materia_votacao.tipo_votacao == 4 is_leitura = materia_votacao.tipo_votacao == 4
is_expediente = model is ExpedienteMateria is_expediente = model is ExpedienteMateria
opened = False opened = False
mensagens = []
try:
with transaction.atomic(): with transaction.atomic():
# select_for_update trava a linha da SessaoPlenaria durante toda a # select_for_update trava a linha da SessaoPlenaria durante toda a
# checagem+fechamento+abertura, para que dois "abrir votação" # checagem+fechamento+abertura, para que dois "abrir votação"
@ -230,14 +233,25 @@ def abrir_votacao(request, pk, spk):
# votações abertas... foram fechadas") não faz sentido quando a # votações abertas... foram fechadas") não faz sentido quando a
# única "conflitante" é ela mesma. # única "conflitante" é ela mesma.
if (verifica_presenca(request, presenca_model, spk, is_leitura) and if (verifica_presenca(request, presenca_model, spk, is_leitura) and
(ja_aberta or verifica_votacoes_abertas(request)) and (ja_aberta or verifica_votacoes_abertas(request, mensagens)) and
verifica_sessao_iniciada(request, spk, is_leitura)): verifica_sessao_iniciada(request, spk, is_leitura)):
materia_votacao.votacao_aberta = True materia_votacao.votacao_aberta = True
if not ja_aberta:
materia_votacao.registro_aberto = False
sessao = SessaoPlenaria.objects.get(id=spk) sessao = SessaoPlenaria.objects.get(id=spk)
sessao.painel_aberto = True sessao.painel_aberto = True
sessao.save() sessao.save()
materia_votacao.save() materia_votacao.save()
opened = True opened = True
for msg in mensagens:
messages.add_message(request, messages.INFO, msg)
except IntegrityError:
# O lock acima é por sessão, mas a unicidade de votacao_aberta é
# global: uma abertura simultânea em outra sessão cai no índice
# parcial.
opened = False
messages.add_message(request, messages.ERROR, _(
'Outra votação foi aberta simultaneamente. Tente novamente.'))
if opened: if opened:
broadcast_dados_painel(request, spk) broadcast_dados_painel(request, spk)
@ -298,7 +312,7 @@ def abrir_votacao(request, pk, spk):
return HttpResponseRedirect(success_url) return HttpResponseRedirect(success_url)
def customize_link_materia(context, pk, has_permission, is_expediente, request=None): def customize_link_materia(context, pk, has_permission, is_expediente):
for i, row in enumerate(context['rows']): for i, row in enumerate(context['rows']):
materia = context['object_list'][i].materia materia = context['object_list'][i].materia
obj = context['object_list'][i] obj = context['object_list'][i]
@ -429,23 +443,13 @@ def customize_link_materia(context, pk, has_permission, is_expediente, request=N
if has_permission: if has_permission:
if obj.tipo_votacao != TipoVotacao.LEITURA: if obj.tipo_votacao != TipoVotacao.LEITURA:
# Nominal (Ordem do Dia e Expediente, ambos agora)
# aponta para votacaonominal_v2, que só faz GET —
# mesmo tratamento de Simbólica/Secreta. O POST
# especial que existia aqui só para Nominal de
# Expediente era necessário quando ela ainda
# apontava para VotacaoNominalAbstract/nominal.html
# (retirado desta unificação — ver plano).
metodo = ''
csrf_input = ''
btn_registrar = ''' btn_registrar = '''
<form action="%s"%s> <form action="%s">
%s
<input type="submit" class="btn btn-primary" <input type="submit" class="btn btn-primary"
value="Registrar Votação" /> value="Registrar Votação" />
%s %s
</form>''' % ( </form>''' % (
url, metodo, csrf_input, page_number) url, page_number)
else: else:
btn_registrar = ''' btn_registrar = '''
<form action="%s"> <form action="%s">
@ -933,8 +937,7 @@ class MateriaOrdemDiaCrud(MasterDetailCrud):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
has_permition = self.request.user.has_module_perms(AppConfig.label) has_permition = self.request.user.has_module_perms(AppConfig.label)
return customize_link_materia(context, self.kwargs['pk'], has_permition, False, return customize_link_materia(context, self.kwargs['pk'], has_permition, False)
request=self.request)
def recuperar_materia(request): def recuperar_materia(request):
@ -1010,8 +1013,7 @@ class ExpedienteMateriaCrud(MasterDetailCrud):
context['page'] = self.request.GET.get('page') context['page'] = self.request.GET.get('page')
has_permition = self.request.user.has_module_perms(AppConfig.label) has_permition = self.request.user.has_module_perms(AppConfig.label)
return customize_link_materia(context, self.kwargs['pk'], has_permition, True, return customize_link_materia(context, self.kwargs['pk'], has_permition, True)
request=self.request)
class CreateView(MasterDetailCrud.CreateView): class CreateView(MasterDetailCrud.CreateView):
form_class = ExpedienteMateriaForm form_class = ExpedienteMateriaForm
@ -1539,26 +1541,31 @@ class PresencaView(FormMixin, PresencaMixin, DetailView):
if form.is_valid(): if form.is_valid():
# Pegar os presentes salvos no banco # Pegar os presentes salvos no banco
presentes_banco = SessaoPlenariaPresenca.objects.filter( presentes_banco = set(SessaoPlenariaPresenca.objects.filter(
sessao_plenaria_id=self.object.id).values_list( sessao_plenaria_id=self.object.id).values_list(
'parlamentar_id', flat=True).distinct() 'parlamentar_id', flat=True))
# Id dos parlamentares presentes # Id dos parlamentares presentes
marcados = request.POST.getlist('presenca_ativos') \ marcados = set(int(p) for p in
+ request.POST.getlist('presenca_inativos') request.POST.getlist('presenca_ativos')
+ request.POST.getlist('presenca_inativos'))
# Deletar os que foram desmarcados # Deletar os que foram desmarcados
deletar = set(presentes_banco) - set(marcados)
SessaoPlenariaPresenca.objects.filter( SessaoPlenariaPresenca.objects.filter(
parlamentar_id__in=deletar, parlamentar_id__in=presentes_banco - marcados,
sessao_plenaria_id=self.object.id).delete() sessao_plenaria_id=self.object.id).delete()
for p in marcados: # Criar apenas quem ainda não tem presença registrada. O
sessao = SessaoPlenariaPresenca() # ignore_conflicts descarta a inserção duplicada quando o
sessao.sessao_plenaria = self.object # formulário é submetido duas vezes em paralelo, em vez de
sessao.parlamentar = Parlamentar.objects.get(id=p) # gravar uma segunda linha para o mesmo parlamentar.
sessao.save()
username = request.user.username username = request.user.username
novos = marcados - presentes_banco
SessaoPlenariaPresenca.objects.bulk_create(
[SessaoPlenariaPresenca(sessao_plenaria=self.object,
parlamentar_id=p) for p in novos],
ignore_conflicts=True)
for p in novos:
self.logger.info( self.logger.info(
"user=" + username + ". SessaoPlenariaPresenca salva com sucesso (parlamentar_id={})!".format(p)) "user=" + username + ". SessaoPlenariaPresenca salva com sucesso (parlamentar_id={})!".format(p))
msg = _('Presença em Sessão salva com sucesso!') msg = _('Presença em Sessão salva com sucesso!')
@ -1664,26 +1671,29 @@ class PresencaOrdemDiaView(FormMixin, PresencaMixin, DetailView):
if form.is_valid(): if form.is_valid():
# Pegar os presentes salvos no banco # Pegar os presentes salvos no banco
presentes_banco = PresencaOrdemDia.objects.filter( presentes_banco = set(PresencaOrdemDia.objects.filter(
sessao_plenaria_id=self.object.id).values_list( sessao_plenaria_id=self.object.id).values_list(
'parlamentar_id', flat=True).distinct() 'parlamentar_id', flat=True))
# Id dos parlamentares presentes # Id dos parlamentares presentes
marcados = request.POST.getlist('presenca_ativos') \ marcados = set(int(p) for p in
+ request.POST.getlist('presenca_inativos') request.POST.getlist('presenca_ativos')
+ request.POST.getlist('presenca_inativos'))
# Deletar os que foram desmarcados # Deletar os que foram desmarcados
deletar = set(presentes_banco) - set(marcados)
PresencaOrdemDia.objects.filter( PresencaOrdemDia.objects.filter(
parlamentar_id__in=deletar, parlamentar_id__in=presentes_banco - marcados,
sessao_plenaria_id=self.object.id).delete() sessao_plenaria_id=self.object.id).delete()
for p in marcados: # Criar apenas quem ainda não tem presença registrada. Ver
ordem = PresencaOrdemDia() # comentário equivalente em PresencaView.post.
ordem.sessao_plenaria = self.object
ordem.parlamentar = Parlamentar.objects.get(id=p)
ordem.save()
username = request.user.username username = request.user.username
novos = marcados - presentes_banco
PresencaOrdemDia.objects.bulk_create(
[PresencaOrdemDia(sessao_plenaria=self.object,
parlamentar_id=p) for p in novos],
ignore_conflicts=True)
for p in novos:
self.logger.info( self.logger.info(
'user=' + username + '. PresencaOrdemDia (parlamentar com id={}) salva com sucesso!'.format(p)) 'user=' + username + '. PresencaOrdemDia (parlamentar com id={}) salva com sucesso!'.format(p))
@ -2370,7 +2380,7 @@ class ResumoView(DetailView):
# Votos de Votação Nominal de Matérias Expediente # Votos de Votação Nominal de Matérias Expediente
votacoes = [] votacoes = []
for mevn in ExpedienteMateria.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2) \ for mevn in ExpedienteMateria.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2) \
.order_by('-materia'): .order_by('numero_ordem'):
votos_materia = [] votos_materia = []
titulo_materia = mevn.materia titulo_materia = mevn.materia
registro = RegistroVotacao.objects.filter(expediente=mevn) registro = RegistroVotacao.objects.filter(expediente=mevn)
@ -2419,7 +2429,7 @@ class ResumoView(DetailView):
# Matérias Ordem do Dia # Matérias Ordem do Dia
# Votos de Votação Nominal de Matérias Ordem do Dia # Votos de Votação Nominal de Matérias Ordem do Dia
votacoes_od = [] votacoes_od = []
for modvn in OrdemDia.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2).order_by('-materia'): for modvn in OrdemDia.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2).order_by('numero_ordem'):
votos_materia_od = [] votos_materia_od = []
t_materia = modvn.materia t_materia = modvn.materia
registro_od = RegistroVotacao.objects.filter(ordem=modvn) registro_od = RegistroVotacao.objects.filter(ordem=modvn)

4
sapl/templates/audiencia/layouts.yaml

@ -6,7 +6,7 @@ AudienciaPublica:
{% trans 'Dados' %}: {% trans 'Dados' %}:
- tipo_materia numero_materia ano_materia - tipo_materia numero_materia ano_materia
- tipo:4 data:2 hora_inicio:3 hora_fim:3 - tipo:4 data:2 hora_inicio:3 hora_fim:3
- parlamentar_autor requerimento - autor requerimento
- url_audio url_video - url_audio url_video
- upload_pauta upload_ata upload_anexo - upload_pauta upload_ata upload_anexo
- observacao - observacao
@ -19,7 +19,7 @@ AudienciaPublicaDetail:
{% trans 'Dados' %}: {% trans 'Dados' %}:
- materia tipo - materia tipo
- data hora_inicio hora_fim - data hora_inicio hora_fim
- parlamentar_autor requerimento - autor requerimento
- url_audio url_video - url_audio url_video
- upload_pauta upload_ata upload_anexo - upload_pauta upload_ata upload_anexo
- observacao - observacao

7
sapl/templates/base.html

@ -40,6 +40,13 @@
<body> <body>
<div class="page fadein"> <div class="page fadein">
{% if not request|has_iframe %}
<div class="info-atualizacao hidden-print" style="background-color:#0d2b45;color:#fff;text-align:center;padding:6px 12px;font-size:14px;font-weight:600;">
{% trans 'Informações foram atualizadas em' %}
{% if data_atualizacao_conteudo %}{{ data_atualizacao_conteudo|date:"d/m/Y" }}{% else %}{% now "d/m/Y" %}{% endif %}
</div>
{% endif %}
{% if not sapl_as_sapn or user.is_authenticated%} {% if not sapl_as_sapn or user.is_authenticated%}
{% if not request|has_iframe %} {% if not request|has_iframe %}
{% block navigation %} {% block navigation %}

2
sapl/templates/compilacao/dispositivo_form_alteracao.html

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

2
sapl/templates/compilacao/dispositivo_form_edicao_basica.html

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

6
sapl/templates/compilacao/text_edit.html

@ -17,10 +17,16 @@
<a href="{% url 'sapl.compilacao:ta_text_edit' object.pk %}?{% if object.editing_locked %}unlock{%else%}lock{% endif %}" class="btn {% if object.editing_locked %}btn-outline-danger{%else%}btn-outline-primary{% endif %}">{% if object.editing_locked %}{% trans 'Desbloquear Edição' %}{%else%}{% trans 'Publicar Texto' %}{% endif %}</a> <a href="{% url 'sapl.compilacao:ta_text_edit' object.pk %}?{% if object.editing_locked %}unlock{%else%}lock{% endif %}" class="btn {% if object.editing_locked %}btn-outline-danger{%else%}btn-outline-primary{% endif %}">{% if object.editing_locked %}{% trans 'Desbloquear Edição' %}{%else%}{% trans 'Publicar Texto' %}{% endif %}</a>
{% endif %} {% endif %}
</div> </div>
<div class="d-flex">
<div class="actions btn-group mr-2" role="group">
<a class="btn btn-outline-primary" id="btn_font_menos" title="{% trans 'Diminuir tamanho da letra' %}">a</a>
<a class="btn btn-outline-primary" id="btn_font_mais" title="{% trans 'Aumentar tamanho da letra' %}">A</a>
</div>
<div class="actions btn-group dropleft" role="group"> <div class="actions btn-group dropleft" role="group">
{% include 'compilacao/textoarticulado_menu_config.html' %} {% include 'compilacao/textoarticulado_menu_config.html' %}
</div> </div>
</div> </div>
</div>
{% if object.dispositivos_set.count == 1 %} {% if object.dispositivos_set.count == 1 %}
{% with view.materia_da_norma_deste_texto_articulado as materia %} {% with view.materia_da_norma_deste_texto_articulado as materia %}
{% if materia %} {% if materia %}

2
sapl/templates/relatorios/relatorio_materia_tramitacao.html

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

7
sapl/templates/sessao/sessaoplenaria_filter.html

@ -53,6 +53,13 @@
{% if s.upload_ata %} {% if s.upload_ata %}
<strong><a href="{{s.upload_ata.url}}">Ata da Sessão</strong></a></br> <strong><a href="{{s.upload_ata.url}}">Ata da Sessão</strong></a></br>
{% endif %} {% endif %}
{% if s.upload_pauta %}
<strong><a href="{{s.upload_pauta.url}}">Pauta da Sessão</strong></a></br>
{% endif %}
{% if s.upload_anexo %}
<strong><a href="{{s.upload_anexo.url}}">Anexo da Sessão</strong></a></br>
{% endif %}
<strong><a href="{% url 'sapl.sessao:resumo' s.id %}">Votações Nominais</strong></a></br>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

9
sapl/utils.py

@ -623,6 +623,15 @@ TIPOS_TEXTO_PERMITIDOS = (
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/xml', 'application/xml',
'text/xml', 'text/xml',
# Formatos editáveis de planilha/CSV (atende à gravação de relatórios do PNTP).
'text/csv',
'application/csv',
'text/comma-separated-values',
'application/vnd.ms-excel', # .xls
'application/x-ole-storage', # .xls (detecção OLE2 legada do libmagic)
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xlsx
'application/vnd.oasis.opendocument.spreadsheet', # .ods / calc
'application/x-vnd.oasis.opendocument.spreadsheet',
) )
TIPOS_IMG_PERMITIDOS = ( TIPOS_IMG_PERMITIDOS = (

Loading…
Cancel
Save