diff --git a/sapl/api/serializers.py b/sapl/api/serializers.py
index 39e46afc2..f4a82db35 100644
--- a/sapl/api/serializers.py
+++ b/sapl/api/serializers.py
@@ -313,6 +313,12 @@ class SessaoPlenariaECidadaniaSerializer(serializers.ModelSerializer):
def get_sigla_orgao(self, obj):
return self.casa().sigla
+ def get_nome_orgao(self, obj):
+ return self.casa().nome
+
+ def casa(self):
+ return CasaLegislativa.objects.first()
+
class OrdemDiaSerializer(SaplSerializerMixin):
"""
@@ -333,10 +339,3 @@ class ExpedienteMateriaSerializer(SaplSerializerMixin):
model = ExpedienteMateria
fields = '__all__'
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
diff --git a/sapl/api/tests/test_serializers_sessao.py b/sapl/api/tests/test_serializers_sessao.py
new file mode 100644
index 000000000..69e8d7259
--- /dev/null
+++ b/sapl/api/tests/test_serializers_sessao.py
@@ -0,0 +1,19 @@
+import pytest
+from model_bakery import baker
+
+from sapl.api.serializers import SessaoPlenariaECidadaniaSerializer
+from sapl.base.models import CasaLegislativa
+from sapl.sessao.models import SessaoPlenaria
+
+
+@pytest.mark.django_db(transaction=False)
+def test_sessao_ecidadania_serializa_dados_da_casa():
+ casa = baker.make(CasaLegislativa, nome='Câmara Municipal', sigla='CM',
+ endereco='Praça Central')
+ sessao = baker.make(SessaoPlenaria)
+
+ data = SessaoPlenariaECidadaniaSerializer(sessao).data
+
+ assert data['txtNomeOrgao'] == casa.nome
+ assert data['txtSiglaOrgao'] == casa.sigla
+ assert data['txtLocal'] == casa.endereco
diff --git a/sapl/audiencia/forms.py b/sapl/audiencia/forms.py
index f1978032f..d2c7dd45b 100755
--- a/sapl/audiencia/forms.py
+++ b/sapl/audiencia/forms.py
@@ -10,9 +10,9 @@ from django.utils.translation import ugettext_lazy as _
from crispy_forms.layout import Button, Column, Fieldset, HTML, Layout
from sapl.audiencia.models import AudienciaPublica, TipoAudienciaPublica, AnexoAudienciaPublica
+from sapl.base.models import Autor
from sapl.crispy_layout_mixin import form_actions, SaplFormHelper, SaplFormLayout, to_row
from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa
-from sapl.parlamentares.models import Parlamentar
from sapl.utils import timezone, FileFieldCheckMixin, validar_arquivo
@@ -44,10 +44,10 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
widget=forms.HiddenInput(),
queryset=MateriaLegislativa.objects.all())
- parlamentar_autor = forms.ModelChoiceField(
- label=_("Parlamentar Autor"),
+ autor = forms.ModelChoiceField(
+ label=_("Autor"),
required=False,
- queryset=Parlamentar.objects.all())
+ queryset=Autor.objects.all().order_by('nome'))
requerimento = forms.ModelChoiceField(
label=_("Requerimento"),
@@ -58,7 +58,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
model = AudienciaPublica
fields = ['tipo', 'numero', 'ano', 'nome',
'tema', 'data', 'hora_inicio', 'hora_fim',
- 'observacao', 'audiencia_cancelada', 'parlamentar_autor', 'requerimento', 'url_audio',
+ 'observacao', 'audiencia_cancelada', 'autor', 'requerimento', 'url_audio',
'url_video', 'upload_pauta', 'upload_ata',
'upload_anexo', 'tipo_materia', 'numero_materia',
'ano_materia', 'materia']
@@ -85,7 +85,7 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
materia = cleaned_data['numero_materia']
ano_materia = cleaned_data['ano_materia']
tipo_materia = cleaned_data['tipo_materia']
- parlamentar_autor = cleaned_data["parlamentar_autor"]
+ autor = cleaned_data["autor"]
requerimento = cleaned_data["requerimento"]
if cleaned_data["ano"] != cleaned_data["data"].year:
@@ -157,12 +157,12 @@ class AudienciaForm(FileFieldCheckMixin, forms.ModelForm):
raise ValidationError(msg)
# requerimento é optativo
- if parlamentar_autor and requerimento:
- if parlamentar_autor.autor.first() not in requerimento.autores.all():
- raise ValidationError("Parlamentar Autor selecionado não faz"
+ if autor and requerimento:
+ if autor not in requerimento.autores.all():
+ raise ValidationError("Autor selecionado não faz"
" parte da autoria do Requerimento "
"selecionado.")
- elif parlamentar_autor:
+ elif autor:
raise ValidationError("Para informar um autor deve-se informar um requerimento.")
elif requerimento:
raise ValidationError("Para informar um requerimento deve-se informar um autor.")
diff --git a/sapl/audiencia/migrations/0021_auto_20260623_1938.py b/sapl/audiencia/migrations/0021_auto_20260623_1938.py
new file mode 100644
index 000000000..a2e101c64
--- /dev/null
+++ b/sapl/audiencia/migrations/0021_auto_20260623_1938.py
@@ -0,0 +1,59 @@
+# Generated by Django 2.2.28 on 2026-06-23 22:38
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def copia_parlamentar_para_autor(apps, schema_editor):
+ """Converte o antigo parlamentar_autor no Autor genérico correspondente."""
+ AudienciaPublica = apps.get_model('audiencia', 'AudienciaPublica')
+ Autor = apps.get_model('base', 'Autor')
+ ContentType = apps.get_model('contenttypes', 'ContentType')
+ try:
+ ct = ContentType.objects.get(
+ app_label='parlamentares', model='parlamentar')
+ except ContentType.DoesNotExist:
+ return
+ for ap in AudienciaPublica.objects.filter(parlamentar_autor__isnull=False):
+ autor = Autor.objects.filter(
+ content_type=ct, object_id=ap.parlamentar_autor_id).first()
+ if autor:
+ ap.autor = autor
+ ap.save(update_fields=['autor'])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('base', '0060_auto_20240812_1628'),
+ ('audiencia', '0020_auto_20251201_1450'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='audienciapublica',
+ name='autor',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='base.Autor', verbose_name='Autor'),
+ ),
+ migrations.RunPython(
+ copia_parlamentar_para_autor, migrations.RunPython.noop),
+ # Mantém a coluna parlamentar_autor no banco (removida apenas do estado
+ # do Django). Assim uma casa pode dar rollback da imagem sem perder o
+ # vínculo, e o código novo simplesmente ignora a coluna. O DROP
+ # definitivo fica para uma migração posterior, quando toda a frota
+ # estiver estável nesta versão.
+ migrations.SeparateDatabaseAndState(
+ state_operations=[
+ migrations.RemoveField(
+ model_name='audienciapublica',
+ name='parlamentar_autor',
+ ),
+ ],
+ database_operations=[],
+ ),
+ migrations.AlterField(
+ model_name='audienciapublica',
+ name='ano',
+ field=models.PositiveSmallIntegerField(choices=[(2027, 2027), (2026, 2026), (2025, 2025), (2024, 2024), (2023, 2023), (2022, 2022), (2021, 2021), (2020, 2020), (2019, 2019), (2018, 2018), (2017, 2017), (2016, 2016), (2015, 2015), (2014, 2014), (2013, 2013), (2012, 2012), (2011, 2011), (2010, 2010), (2009, 2009), (2008, 2008), (2007, 2007), (2006, 2006), (2005, 2005), (2004, 2004), (2003, 2003), (2002, 2002), (2001, 2001), (2000, 2000), (1999, 1999), (1998, 1998), (1997, 1997), (1996, 1996), (1995, 1995), (1994, 1994), (1993, 1993), (1992, 1992), (1991, 1991), (1990, 1990), (1989, 1989), (1988, 1988), (1987, 1987), (1986, 1986), (1985, 1985), (1984, 1984), (1983, 1983), (1982, 1982), (1981, 1981), (1980, 1980), (1979, 1979), (1978, 1978), (1977, 1977), (1976, 1976), (1975, 1975), (1974, 1974), (1973, 1973), (1972, 1972), (1971, 1971), (1970, 1970), (1969, 1969), (1968, 1968), (1967, 1967), (1966, 1966), (1965, 1965), (1964, 1964), (1963, 1963), (1962, 1962), (1961, 1961), (1960, 1960), (1959, 1959), (1958, 1958), (1957, 1957), (1956, 1956), (1955, 1955), (1954, 1954), (1953, 1953), (1952, 1952), (1951, 1951), (1950, 1950), (1949, 1949), (1948, 1948), (1947, 1947), (1946, 1946), (1945, 1945), (1944, 1944), (1943, 1943), (1942, 1942), (1941, 1941), (1940, 1940), (1939, 1939), (1938, 1938), (1937, 1937), (1936, 1936), (1935, 1935), (1934, 1934), (1933, 1933), (1932, 1932), (1931, 1931), (1930, 1930), (1929, 1929), (1928, 1928), (1927, 1927), (1926, 1926), (1925, 1925), (1924, 1924), (1923, 1923), (1922, 1922), (1921, 1921), (1920, 1920), (1919, 1919), (1918, 1918), (1917, 1917), (1916, 1916), (1915, 1915), (1914, 1914), (1913, 1913), (1912, 1912), (1911, 1911), (1910, 1910), (1909, 1909), (1908, 1908), (1907, 1907), (1906, 1906), (1905, 1905), (1904, 1904), (1903, 1903), (1902, 1902), (1901, 1901), (1900, 1900), (1899, 1899), (1898, 1898), (1897, 1897), (1896, 1896), (1895, 1895), (1894, 1894), (1893, 1893), (1892, 1892), (1891, 1891), (1890, 1890)], verbose_name='Ano'),
+ ),
+ ]
diff --git a/sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py b/sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py
new file mode 100644
index 000000000..15f49d3fa
--- /dev/null
+++ b/sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.28 on 2026-06-26 15:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('audiencia', '0021_auto_20260623_1938'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='audienciapublica',
+ name='data_ultima_atualizacao',
+ field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'),
+ ),
+ ]
diff --git a/sapl/audiencia/migrations/0021_auto_20260916_1709.py b/sapl/audiencia/migrations/0023_auto_20260916_1709.py
similarity index 96%
rename from sapl/audiencia/migrations/0021_auto_20260916_1709.py
rename to sapl/audiencia/migrations/0023_auto_20260916_1709.py
index 82e60487d..9faefff55 100644
--- a/sapl/audiencia/migrations/0021_auto_20260916_1709.py
+++ b/sapl/audiencia/migrations/0023_auto_20260916_1709.py
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('audiencia', '0020_auto_20251201_1450'),
+ ('audiencia', '0022_audienciapublica_data_ultima_atualizacao'),
]
operations = [
diff --git a/sapl/audiencia/models.py b/sapl/audiencia/models.py
index 6b74cffd0..8ba14f29f 100755
--- a/sapl/audiencia/models.py
+++ b/sapl/audiencia/models.py
@@ -3,7 +3,7 @@ from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from model_utils import Choices
from sapl.materia.models import MateriaLegislativa
-from sapl.parlamentares.models import (CargoMesa, Parlamentar)
+from sapl.parlamentares.models import CargoMesa
from sapl.utils import (RANGE_ANOS, YES_NO_CHOICES, SaplGenericRelation,
restringe_tipos_de_arquivo_txt, texto_upload_path,
@@ -79,12 +79,15 @@ class AudienciaPublica(models.Model):
default=False,
choices=YES_NO_CHOICES,
verbose_name=_('Audiência Cancelada?'))
- parlamentar_autor = models.ForeignKey(
- Parlamentar,
+ autor = models.ForeignKey(
+ 'base.Autor',
on_delete=models.PROTECT,
null=True,
blank=True,
- verbose_name=_('Parlamentar Autor'))
+ verbose_name=_('Autor'))
+ data_ultima_atualizacao = models.DateTimeField(
+ blank=True, null=True, auto_now=True,
+ verbose_name=_('Data da Última Atualização'))
requerimento = models.ForeignKey(
MateriaLegislativa,
null=True,
diff --git a/sapl/comissoes/forms.py b/sapl/comissoes/forms.py
index 76f511ed2..599b9a5b3 100644
--- a/sapl/comissoes/forms.py
+++ b/sapl/comissoes/forms.py
@@ -558,3 +558,28 @@ class DocumentoAcessorioEditForm(FileFieldCheckMixin, forms.ModelForm):
# raise ValidationError(f'Favor anexar arquivo em {nome_arquivo}')
return self.cleaned_data
+
+
+class ReuniaoFilterSet(django_filters.FilterSet):
+
+ nome = django_filters.CharFilter(
+ lookup_expr='icontains', label=_('Nome da Reunião'))
+ tema = django_filters.CharFilter(
+ lookup_expr='icontains', label=_('Tema da Reunião'))
+
+ class Meta(FilterOverridesMetaMixin):
+ model = Reuniao
+ fields = ['numero', 'nome', 'tema', 'data']
+
+ def __init__(self, *args, **kwargs):
+ super(ReuniaoFilterSet, self).__init__(*args, **kwargs)
+
+ row1 = to_row([('numero', 3), ('nome', 5), ('tema', 4)])
+ row2 = to_row([('data', 6)])
+
+ self.form.helper = SaplFormHelper()
+ self.form.helper.form_method = 'GET'
+ self.form.helper.layout = Layout(
+ Fieldset(_('Pesquisar Reunião'),
+ row1, row2,
+ form_actions(label=_('Pesquisar'))))
diff --git a/sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py b/sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py
new file mode 100644
index 000000000..e35f98e0a
--- /dev/null
+++ b/sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.28 on 2026-06-26 15:20
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('comissoes', '0030_auto_20231007_2149'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='comissao',
+ name='data_ultima_atualizacao',
+ field=models.DateTimeField(auto_now=True, null=True, verbose_name='Data da Última Atualização'),
+ ),
+ ]
diff --git a/sapl/comissoes/models.py b/sapl/comissoes/models.py
index 2d9a323d3..694fdceaf 100644
--- a/sapl/comissoes/models.py
+++ b/sapl/comissoes/models.py
@@ -34,6 +34,9 @@ class Comissao(models.Model):
tipo = models.ForeignKey(TipoComissao,
on_delete=models.PROTECT,
verbose_name=_('Tipo'))
+ data_ultima_atualizacao = models.DateTimeField(
+ blank=True, null=True, auto_now=True,
+ verbose_name=_('Data da Última Atualização'))
nome = models.CharField(max_length=100, verbose_name=_('Nome'))
sigla = models.CharField(max_length=10, verbose_name=_('Sigla'))
data_criacao = models.DateField(verbose_name=_('Data de Criação'))
diff --git a/sapl/comissoes/views.py b/sapl/comissoes/views.py
index 6017ae2b3..92cde447f 100644
--- a/sapl/comissoes/views.py
+++ b/sapl/comissoes/views.py
@@ -22,7 +22,7 @@ from sapl.comissoes.forms import (ComissaoForm, ComposicaoForm,
ParticipacaoCreateForm,
ParticipacaoEditForm,
PautaReuniaoFilterSet, PautaReuniaoForm,
- PeriodoForm, ReuniaoForm)
+ PeriodoForm, ReuniaoFilterSet, ReuniaoForm)
from sapl.crud.base import (Crud, CrudAux, MasterDetailCrud,
PermissionRequiredForAppCrudMixin, RP_DETAIL,
RP_LIST)
@@ -216,7 +216,7 @@ class ReuniaoCrud(MasterDetailCrud):
public = [RP_LIST, RP_DETAIL, ]
class BaseMixin(MasterDetailCrud.BaseMixin):
- list_field_names = ['data', 'nome', 'tema', 'upload_ata']
+ list_field_names = ['data', 'nome', 'tema', 'upload_pauta', 'upload_ata']
class DetailView(MasterDetailCrud.DetailView):
template_name = "comissoes/reuniao_detail.html"
@@ -248,6 +248,11 @@ class ReuniaoCrud(MasterDetailCrud):
logger = logging.getLogger(__name__)
paginate_by = 10
+ def get_queryset(self):
+ qs = super().get_queryset()
+ self.filterset = ReuniaoFilterSet(self.request.GET, queryset=qs)
+ return self.filterset.qs
+
def take_reuniao_pk(self):
username = self.request.user.username
@@ -276,6 +281,8 @@ class ReuniaoCrud(MasterDetailCrud):
context['documentoacessorio_set'] = DocumentoAcessorio.objects.filter(
reuniao__pk=context['reuniao_pk']
).order_by('id')
+
+ context['form'] = self.filterset.form
return context
class UpdateView(MasterDetailCrud.UpdateView):
diff --git a/sapl/compilacao/utils.py b/sapl/compilacao/utils.py
index c56f618b8..526d9806d 100644
--- a/sapl/compilacao/utils.py
+++ b/sapl/compilacao/utils.py
@@ -59,8 +59,8 @@ def int_to_letter(int_value):
def get_integrations_view_names():
result = []
- modules = sys.modules
- for key, value in modules.items():
+ # list(): importar um módulo durante a varredura altera sys.modules.
+ for key, value in list(sys.modules.items()):
if key.endswith('.views'):
for v in value.__dict__.values():
if hasattr(v, '__bases__'):
diff --git a/sapl/crud/base.py b/sapl/crud/base.py
index fd091ec52..0966c4bc4 100644
--- a/sapl/crud/base.py
+++ b/sapl/crud/base.py
@@ -863,6 +863,11 @@ class CrudDetailView(PermissionRequiredContainerCrudMixin,
context[context_object_name] = self.object
context.update(kwargs)
+ # Mensagem de atualidade (PNTP): data de última atualização da entidade,
+ # quando o modelo possuir o campo. Consumido pelo banner em base.html.
+ context['data_atualizacao_conteudo'] = getattr(
+ self.object, 'data_ultima_atualizacao', None)
+
return context
@property
diff --git a/sapl/painel/tests/tests.py b/sapl/painel/tests/tests.py
index bb75b9753..d45246ce6 100644
--- a/sapl/painel/tests/tests.py
+++ b/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,
Parlamentar, Partido,
SessaoLegislativa, Votante)
-from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, SessaoPlenaria,
+from sapl.sessao.models import (ExpedienteMateria, OrdemDia,
+ PresencaOrdemDia, SessaoPlenaria,
TipoResultadoVotacao, TipoSessaoPlenaria,
VotoParlamentar)
@@ -183,7 +184,6 @@ def test_propria_tela_nao_mostra_voto_de_outra_materia(admin_client):
ordem_antiga.save()
baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(),
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')
@@ -262,3 +262,63 @@ def test_votante_status_reflete_estado_e_nao_exige_permissao_do_painel():
data2 = resposta2.json()
assert data2['voto_parlamentar'] == 'Não'
assert 'encerramento da votação' in data2['status_message']
+
+
+@pytest.mark.django_db(transaction=False)
+def test_votante_status_com_duas_votacoes_abertas_nao_acumula_mensagens():
+ """
+ Uma OrdemDia e uma ExpedienteMateria abertas ao mesmo tempo (as
+ constraints são por tabela): o poll do tablet devolve o erro em
+ error_message, em texto puro, sem empilhar mensagens na sessão.
+ """
+ sessao, ordem = _ordem_nominal_aberta()
+ vereador, votante_client = _votante_com_client(sessao)
+ outra_sessao = _sessao_plenaria()
+ baker.make(ExpedienteMateria, sessao_plenaria=outra_sessao,
+ materia=_materia(), tipo_votacao=NOMINAL, votacao_aberta=True)
+
+ status_url = reverse('sapl.painel:voto_individual_status')
+ for _ in range(2):
+ data = votante_client.get(status_url).json()
+
+ assert 'mais de uma' in data['error_message']
+ assert '<' not in data['error_message']
+ assert '_messages' not in votante_client.session
+
+
+@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()
diff --git a/sapl/painel/views.py b/sapl/painel/views.py
index aff2025c1..e2ef413df 100644
--- a/sapl/painel/views.py
+++ b/sapl/painel/views.py
@@ -2,12 +2,11 @@ import html
import json
import logging
-from django.contrib import messages
from django.contrib.auth.decorators import (login_required, permission_required,
user_passes_test)
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
-from django.db import IntegrityError, transaction
+from django.db import transaction
from django.urls import reverse
from django.db.models import Q
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
nenhuma. É utilizada como uma função auxiliar para a 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__)
username = request.user.username
@@ -58,19 +61,13 @@ def votacao_aberta(request):
Q(expedientemateria__votacao_aberta=True)).distinct()
if len(votacoes_abertas) > 1:
- msg_abertas = []
- for v in votacoes_abertas:
- msg_abertas.append('''
%s''' % (
- reverse('sapl.sessao:sessaoplenaria_detail',
- kwargs={'pk': v.id}),
- v.__str__()))
+ sessoes = ', '.join(str(v) for v in votacoes_abertas)
logger.info('user=' + username + '. Existe mais de uma votações aberta. Elas se encontram '
- 'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. '
- 'Para votar, peça para que o Operador feche-as.')
+ 'nas seguintes Sessões: ' + sessoes + '. '
+ 'Para votar, peça para que o Operador feche-as.')
msg = _('Existe mais de uma votações aberta. Elas se encontram '
- 'nas seguintes Sessões: ' + ', '.join(msg_abertas) + '. '
- 'Para votar, peça para que o Operador feche-as.')
- messages.add_message(request, messages.INFO, msg)
+ 'nas seguintes Sessões: ' + sessoes + '. '
+ 'Para votar, peça para que o Operador feche-as.')
return None, msg
elif len(votacoes_abertas) == 1:
@@ -83,18 +80,11 @@ def votacao_aberta(request):
numero_materias_abertas = len(ordens) + len(expedientes)
if numero_materias_abertas > 1:
+ sessao = str(votacoes_abertas.first())
logger.info('user=' + username + '. Existe mais de uma votação aberta na Sessão: ' +
- ('''%s''' % (
- reverse('sapl.sessao:sessaoplenaria_detail',
- kwargs={'pk': votacoes_abertas.first().id}),
- votacoes_abertas.first().__str__())))
- msg = _('Existe mais de uma votação aberta na Sessão: ' +
- ('''%s''' % (
- 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)
+ sessao)
+ msg = _('Existe mais de uma votação aberta na Sessão: ' + sessao +
+ '. Para votar, peça para que o Operador as feche.')
return None, msg
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):
"""
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,
que uma conexão WebSocket não tem; o caminho HTTP não passa nada e
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__)
username = request.user.username
- if context_vars['ordem_dia']:
- fase_sessao = {'ordem': context_vars['ordem_dia']}
- elif context_vars['expediente']:
- fase_sessao = {'expediente': context_vars['expediente']}
- else:
- fase_sessao = None
-
- if fase_sessao is None:
- return
+ materia = context_vars.get('ordem_dia') or context_vars.get('expediente')
+ parlamentar = context_vars.get('parlamentar')
+ if not materia or not parlamentar:
+ raise VoteError("A votação não está disponível para novos votos.", status=409)
- voto_valor = voto_valor if voto_valor is not None else request.POST['voto']
- if voto_valor not in VALID_VOTE_VALUES:
+ voto_valor = voto_valor if voto_valor is not None else request.POST.get('voto')
+ if voto_valor not in VOTOS_REAIS:
raise VoteError(
- f"Invalid vote value: {voto_valor}. Must be one of: {VALID_VOTE_VALUES}")
-
- # 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():
- voto, created = VotoParlamentar.objects.select_for_update().get_or_create(
- parlamentar=context_vars['parlamentar'], **fase_sessao)
- except IntegrityError:
- 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.ip = ip if ip is not None else get_client_ip(request)
- voto.user = request.user
- # Único caminho onde é o próprio parlamentar votando — vote_controller/
- # _cast_vote (operador) e o formulário em lote sempre gravam False.
- voto.votado_pelo_parlamentar = True
- voto.save()
-
+ f"Invalid vote value: {voto_valor}. Must be one of: {list(VOTOS_REAIS)}")
+
+ with transaction.atomic():
+ fase_sessao = _trava_materia_para_voto(materia, parlamentar)
+ voto, _created = VotoParlamentar.objects.select_for_update().get_or_create(
+ parlamentar=parlamentar, **fase_sessao)
+ voto.voto = voto_valor
+ voto.ip = ip if ip is not None else get_client_ip(request)
+ voto.user = request.user
+ # Único caminho onde é o próprio parlamentar votando — vote_controller/
+ # _cast_vote (operador) sempre grava False.
+ voto.votado_pelo_parlamentar = True
+ voto.save()
+
+ logger.info("user=" + username + ". VotoParlamentar para parlamentar={} salvo com sucesso."
+ .format(parlamentar))
broadcast_dados_painel(request, context_vars['sessao'].id)
return voto_valor
@@ -311,7 +321,13 @@ def votante_view(request):
context, context_vars = _resolve_votante_context(request)
if request.method == 'POST':
- _save_voto_individual(request, context_vars)
+ try:
+ _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(
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"]
+# Valores que contam como voto; "Não Votou" só é gravado no encerramento.
+VOTOS_REAIS = ("Sim", "Não", "Abstenção")
class VoteError(Exception):
@@ -782,17 +800,33 @@ def _cast_vote(user, controller_id, parlamentar_id, voto, ip=None):
except Parlamentar.DoesNotExist:
raise VoteError(f"Parlamentar {parlamentar_id} not found", status=404)
- # votado_pelo_parlamentar=False explícito (não só o default do campo):
- # se este parlamentar já tinha um voto marcado como do próprio (tablet)
- # e o operador está sobrescrevendo agora, a flag precisa acompanhar —
- # o valor atual deixou de ser o que o parlamentar escolheu.
- defaults = {'voto': voto, 'user': user, 'ip': ip, 'votado_pelo_parlamentar': False}
- if ordem_dia:
- voto_obj, created = VotoParlamentar.objects.update_or_create(
- parlamentar=parlamentar, ordem=ordem_dia, defaults=defaults)
- else:
- voto_obj, created = VotoParlamentar.objects.update_or_create(
- parlamentar=parlamentar, expediente=expediente, defaults=defaults)
+ with transaction.atomic():
+ fase_sessao = _trava_materia_para_voto(materia_aberta, parlamentar)
+ voto_obj = VotoParlamentar.objects.select_for_update().filter(
+ parlamentar=parlamentar, **fase_sessao).first()
+
+ # O voto registrado pelo próprio parlamentar (tablet) prevalece: a
+ # Mesa só lança ou corrige votos que ela mesma registrou.
+ 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:
+ if created:
+ 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(
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:
raise VoteError("Materia is not nominal voting type")
- materia_aberta.registro_aberto = aberto
- materia_aberta.save()
+ # update() só toca registro_aberto: um save() completo regravaria
+ # 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(
f"registro_aberto={aberto} para materia id={materia_aberta.pk} "
f"(sessao={controller_id}, user={user.username})")
@@ -985,57 +1021,79 @@ def close_voting(request, controller_id):
if not materia_aberta:
return JsonResponse({"type": "error", "message": "No open materia for voting"}, status=400)
- # Count votes from VotoParlamentar
- if ordem_dia:
- votos = VotoParlamentar.objects.filter(ordem=ordem_dia)
- else:
- votos = VotoParlamentar.objects.filter(expediente=expediente)
-
- votos_sim = votos.filter(voto='Sim').count()
- votos_nao = votos.filter(voto='Não').count()
- abstencoes = votos.filter(voto='Abstenção').count()
-
- # All votes must not be "Não Votou"
- total_votados = votos_sim + votos_nao + abstencoes
- if total_votados == 0:
- return JsonResponse({
- "type": "error",
- "message": "Não é possível finalizar a votação sem nenhum voto"
- }, status=400)
-
- # Remove old RegistroVotacao if exists
- if ordem_dia:
- RegistroVotacao.objects.filter(ordem=ordem_dia).delete()
- else:
- RegistroVotacao.objects.filter(expediente=expediente).delete()
-
- # Create RegistroVotacao
- registro = RegistroVotacao()
- registro.numero_votos_sim = votos_sim
- registro.numero_votos_nao = votos_nao
- 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()
-
- # Link VotoParlamentar records to RegistroVotacao and update user/ip
- for voto_obj in votos:
- voto_obj.votacao = registro
- voto_obj.user = request.user
- voto_obj.ip = get_client_ip(request)
- voto_obj.save()
-
- # Build redirect URL before closing
- materia_id = materia_aberta.materia_id
+ 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:
+ fase_sessao = {'ordem': materia}
+ presenca_model = PresencaOrdemDia
+ else:
+ 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_nao = votos.filter(voto='Não').count()
+ abstencoes = votos.filter(voto='Abstenção').count()
+
+ if votos_sim + votos_nao + abstencoes == 0:
+ return JsonResponse({
+ "type": "error",
+ "message": "Não é possível finalizar a votação sem nenhum voto"
+ }, status=400)
+
+ registro = RegistroVotacao(
+ numero_votos_sim=votos_sim,
+ numero_votos_nao=votos_nao,
+ numero_abstencoes=abstencoes,
+ observacao=observacoes,
+ user=request.user,
+ ip=get_client_ip(request),
+ materia=materia.materia,
+ tipo_resultado_votacao=tipo_resultado,
+ **fase_sessao)
+ registro.save()
+
+ # user/ip de cada voto continuam sendo de quem votou.
+ votos.update(votacao=registro)
+ VotoParlamentar.objects.bulk_create([
+ VotoParlamentar(
+ parlamentar_id=presenca.parlamentar_id,
+ voto='Não Votou',
+ votacao=registro,
+ user=request.user,
+ 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:
redirect_url = reverse('sapl.sessao:ordemdia_list',
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',
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(
f"Voting closed for sessao={controller_id}: "
f"sim={votos_sim}, nao={votos_nao}, abstencoes={abstencoes}, "
diff --git a/sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py b/sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py
new file mode 100644
index 000000000..c4ae53dee
--- /dev/null
+++ b/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'),
+ ),
+ ]
diff --git a/sapl/parlamentares/models.py b/sapl/parlamentares/models.py
index eecc2f314..6098835c4 100644
--- a/sapl/parlamentares/models.py
+++ b/sapl/parlamentares/models.py
@@ -193,6 +193,10 @@ class Parlamentar(models.Model):
SEXO_CHOICE = ((FEMININO, _('Feminino')),
(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(
NivelInstrucao,
blank=True,
diff --git a/sapl/protocoloadm/migrations/0046_auto_20260626_1220.py b/sapl/protocoloadm/migrations/0046_auto_20260626_1220.py
new file mode 100644
index 000000000..313bcd98a
--- /dev/null
+++ b/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'),
+ ),
+ ]
diff --git a/sapl/protocoloadm/migrations/0046_auto_20260916_1709.py b/sapl/protocoloadm/migrations/0047_auto_20260916_1709.py
similarity index 98%
rename from sapl/protocoloadm/migrations/0046_auto_20260916_1709.py
rename to sapl/protocoloadm/migrations/0047_auto_20260916_1709.py
index d3ac3297d..d4207a464 100644
--- a/sapl/protocoloadm/migrations/0046_auto_20260916_1709.py
+++ b/sapl/protocoloadm/migrations/0047_auto_20260916_1709.py
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('protocoloadm', '0045_auto_20240711_1405'),
+ ('protocoloadm', '0046_auto_20260626_1220'),
]
operations = [
diff --git a/sapl/protocoloadm/models.py b/sapl/protocoloadm/models.py
index 3cdf32735..1a169733a 100644
--- a/sapl/protocoloadm/models.py
+++ b/sapl/protocoloadm/models.py
@@ -158,6 +158,9 @@ class DocumentoAdministrativo(models.Model):
tipo = models.ForeignKey(
TipoDocumentoAdministrativo, on_delete=models.PROTECT,
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'))
complemento = models.CharField(max_length=10, blank=True,
diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py
index 8b89342fd..1c1d2299f 100755
--- a/sapl/relatorios/views.py
+++ b/sapl/relatorios/views.py
@@ -711,7 +711,7 @@ def get_sessao_plenaria(sessao, casa, user):
lst_expediente_materia_vot_nom = []
materias_expediente_votacao_nominal = ExpedienteMateria.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \
- .order_by('-materia')
+ .order_by('numero_ordem')
for mevn in materias_expediente_votacao_nominal:
votos_materia = []
@@ -824,7 +824,7 @@ def get_sessao_plenaria(sessao, casa, user):
lst_votacao_vot_nom = []
materias_ordem_dia_votacao_nominal = OrdemDia.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \
- .order_by('-materia')
+ .order_by('numero_ordem')
for modvn in materias_ordem_dia_votacao_nominal:
votos_materia_od = []
@@ -1792,7 +1792,7 @@ def relatorio_materia_tramitacao(request, pk):
'materia': materia_legislativa,
'ano': materia_legislativa.ano,
'numero': materia_legislativa.numero,
- 'autor': materia_legislativa.autores.first(),
+ 'autores': materia_legislativa.autores.all(),
'tipo': materia_legislativa.tipo.descricao,
'rodape': rodape,
'data': dt.today().strftime('%d/%m/%Y'),
@@ -2080,17 +2080,22 @@ class RelatorioPresencaSessaoView(RelatorioMixin, FilterView):
parlamentares_id = parlamentares_qs.values_list('id', flat=True)
# Presenças de cada Parlamentar em Sessões
+ # Conta sessões distintas, e não linhas de presença: bases com
+ # presenças repetidas para o mesmo parlamentar na mesma sessão
+ # produziam percentuais acima de 100%.
presenca_sessao = SessaoPlenariaPresenca.objects.filter(
- **param0).values_list('parlamentar_id').annotate(sessao_count=Count('id'))
+ **param0).values_list('parlamentar_id').annotate(
+ sessao_count=Count('sessao_plenaria_id', distinct=True))
# Presenças de cada Ordem do Dia
presenca_ordem = PresencaOrdemDia.objects.filter(
- **param0).values_list('parlamentar_id').annotate(sessao_count=Count('id'))
+ **param0).values_list('parlamentar_id').annotate(
+ sessao_count=Count('sessao_plenaria_id', distinct=True))
# Ausencias justificadas
ausencia_justificadas = JustificativaAusencia.objects.filter(
**param0, ausencia=2).values_list('parlamentar_id')\
- .annotate(sessao_count=Count('id'))
+ .annotate(sessao_count=Count('sessao_plenaria_id', distinct=True))
total_ordemdia = PresencaOrdemDia.objects.filter(
**param0).distinct('sessao_plenaria__id').order_by('sessao_plenaria__id').count()
diff --git a/sapl/sessao/migrations/0070_presenca_unica_por_sessao.py b/sapl/sessao/migrations/0070_presenca_unica_por_sessao.py
new file mode 100644
index 000000000..ff71601b1
--- /dev/null
+++ b/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')},
+ ),
+ ]
diff --git a/sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py b/sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py
deleted file mode 100644
index 3f83971d8..000000000
--- a/sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py
+++ /dev/null
@@ -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'),
- ),
- ]
diff --git a/sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py b/sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py
new file mode 100644
index 000000000..f23ff2cea
--- /dev/null
+++ b/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'),
+ ),
+ ]
diff --git a/sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py b/sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py
similarity index 65%
rename from sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py
rename to sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py
index 897207077..0fc5e8d48 100644
--- a/sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py
+++ b/sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py
@@ -3,16 +3,22 @@
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
OrdemDia/ExpedienteMateria com votacao_aberta=True (dado deixado por
versões anteriores, que não garantiam essa invariante), a constraint
- falharia ao ser criada. Mantém aberta apenas a mais recentemente
- modificada de cada tabela e fecha as demais.
+ falharia ao ser criada. Mantém aberta apenas a criada por último (maior
+ id) de cada tabela e fecha as demais.
+
+ Também zera registro_aberto, que mudou de significado ("a Mesa abriu a
+ tela de registro" -> "novos votos bloqueados"): o fluxo antigo marcava
+ True ao abrir a tela e não voltava para False ao encerrar, o que faria
+ uma matéria reaberta voltar com os tablets bloqueados.
"""
for model_name in ('OrdemDia', 'ExpedienteMateria'):
model = apps.get_model('sessao', model_name)
+ model.objects.filter(registro_aberto=True).update(registro_aberto=False)
abertas = model.objects.filter(votacao_aberta=True).order_by('-id')
for materia in abertas[1:]:
materia.votacao_aberta = False
@@ -22,12 +28,12 @@ def fecha_matérias_abertas_duplicadas(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
- ('sessao', '0070_votoparlamentar_unique_constraint'),
+ ('sessao', '0071_votoparlamentar_unique_constraint'),
]
operations = [
migrations.RunPython(
- fecha_matérias_abertas_duplicadas, migrations.RunPython.noop),
+ fecha_materias_abertas_duplicadas, migrations.RunPython.noop),
migrations.AddConstraint(
model_name='expedientemateria',
constraint=models.UniqueConstraint(condition=models.Q(votacao_aberta=True), fields=('votacao_aberta',), name='sessao_expedientemateria_unique_votacao_aberta'),
diff --git a/sapl/sessao/migrations/0072_sessao_presencas_e_materias_votacoes_views.py b/sapl/sessao/migrations/0073_sessao_presencas_e_materias_votacoes_views.py
similarity index 99%
rename from sapl/sessao/migrations/0072_sessao_presencas_e_materias_votacoes_views.py
rename to sapl/sessao/migrations/0073_sessao_presencas_e_materias_votacoes_views.py
index 3d0d05dc9..7787f072c 100644
--- a/sapl/sessao/migrations/0072_sessao_presencas_e_materias_votacoes_views.py
+++ b/sapl/sessao/migrations/0073_sessao_presencas_e_materias_votacoes_views.py
@@ -20,7 +20,7 @@ class Migration(migrations.Migration):
"""
dependencies = [
- ('sessao', '0071_votacao_aberta_unique_constraint'),
+ ('sessao', '0072_votacao_aberta_unique_constraint'),
]
operations = [
diff --git a/sapl/sessao/migrations/0073_sessaomateriasvotacoesview_sessaopresencasview.py b/sapl/sessao/migrations/0074_sessaomateriasvotacoesview_sessaopresencasview.py
similarity index 97%
rename from sapl/sessao/migrations/0073_sessaomateriasvotacoesview_sessaopresencasview.py
rename to sapl/sessao/migrations/0074_sessaomateriasvotacoesview_sessaopresencasview.py
index b860fa7ba..03637bb75 100644
--- a/sapl/sessao/migrations/0073_sessaomateriasvotacoesview_sessaopresencasview.py
+++ b/sapl/sessao/migrations/0074_sessaomateriasvotacoesview_sessaopresencasview.py
@@ -7,7 +7,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('sessao', '0072_sessao_presencas_e_materias_votacoes_views'),
+ ('sessao', '0073_sessao_presencas_e_materias_votacoes_views'),
]
operations = [
diff --git a/sapl/sessao/models.py b/sapl/sessao/models.py
index a8b3b3a61..8d00b20ca 100644
--- a/sapl/sessao/models.py
+++ b/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.
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
- iniciadas e sem pauta publicada.
+ sessões cadastradas com antecedência, ou seja, as de data futura
+ marcadas como não iniciadas e sem pauta publicada.
+
+ A data é o que distingue o cadastro antecipado: sessões de hoje ou de
+ datas passadas ficam sempre visíveis, mesmo com `iniciada` em Não, porque
+ há Casas que não marcam o campo nas sessões já realizadas.
`iniciada` nulo conta como visível: o default do campo só surgiu na
migração 0027, que não preencheu as linhas já existentes, então bases
@@ -174,7 +178,8 @@ def restringe_sessoes_visiveis(qs, user):
return qs.filter(
Q(publicar_pauta=True) |
Q(iniciada=True) |
- Q(iniciada__isnull=True))
+ Q(iniciada__isnull=True) |
+ Q(data_inicio__lte=timezone.localdate()))
class SessaoPlenaria(models.Model):
@@ -616,6 +621,9 @@ class PresencaOrdemDia(models.Model): # OrdemDiaPresenca
verbose_name = _('Presença da Ordem do Dia')
verbose_name_plural = _('Presenças da Ordem do Dia')
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):
# FIXME ambigous
@@ -778,6 +786,9 @@ class SessaoPlenariaPresenca(models.Model):
verbose_name = _('Presença em Sessão Plenária')
verbose_name_plural = _('Presenças em Sessões Plenárias')
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 = [
@@ -1116,7 +1127,7 @@ class Correspondencia(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
N+1 que sapl/painel/views.py::get_presentes() fazia consultando
mandato_set/filiacao_data() por parlamentar presente. `id` não é
@@ -1139,7 +1150,7 @@ class SessaoPresencasView(models.Model):
class SessaoMateriasVotacoesView(models.Model):
'''
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
única linha, evitando o N+1 que
sapl/painel/views.py::get_votos() fazia consultando VotoParlamentar
diff --git a/sapl/sessao/tests/test_sessao_view.py b/sapl/sessao/tests/test_sessao_view.py
index 70bb920a9..9e68953a6 100644
--- a/sapl/sessao/tests/test_sessao_view.py
+++ b/sapl/sessao/tests/test_sessao_view.py
@@ -1,14 +1,17 @@
import pytest
-from datetime import date
+from datetime import timedelta
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
+from django.db import IntegrityError, transaction
from django.urls import reverse
+from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from model_bakery import baker
from sapl.parlamentares.models import Legislatura, SessaoLegislativa
from sapl.sessao.models import (SessaoPlenaria, TipoSessaoPlenaria,
IntegranteMesa, SessaoPlenariaPresenca,
+ PresencaOrdemDia,
JustificativaAusencia, ExpedienteSessao,
TipoExpediente, ExpedienteMateria,
Orador, OcorrenciaSessao,
@@ -153,13 +156,22 @@ class TestResumoView():
assert resultado_get_ocorrencia['ocorrencias_da_sessao'][0] == ocorrencia
+# Sessão cadastrada com antecedência: data futura, sem pauta e não iniciada.
+def data_futura():
+ return timezone.localdate() + timedelta(days=30)
+
+
@pytest.mark.django_db(transaction=False)
def test_visiveis_para_oculta_do_anonimo_apenas_a_sessao_previa():
- previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False)
- com_pauta = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=True)
- iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False)
+ previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
+ data_inicio=data_futura())
+ com_pauta = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=True,
+ data_inicio=data_futura())
+ iniciada = baker.make(SessaoPlenaria, iniciada=True, publicar_pauta=False,
+ data_inicio=data_futura())
# Sessões anteriores à migração 0027 ficaram com `iniciada` em NULL.
- legada = baker.make(SessaoPlenaria, iniciada=None, publicar_pauta=False)
+ legada = baker.make(SessaoPlenaria, iniciada=None, publicar_pauta=False,
+ data_inicio=data_futura())
visiveis = restringe_sessoes_visiveis(
SessaoPlenaria.objects.all(), AnonymousUser())
@@ -170,9 +182,25 @@ def test_visiveis_para_oculta_do_anonimo_apenas_a_sessao_previa():
assert legada in visiveis
+@pytest.mark.django_db(transaction=False)
+def test_visiveis_para_mostra_ao_anonimo_sessao_realizada_nao_marcada():
+ # Casas que não marcam "Sessão iniciada?" nas sessões já realizadas.
+ passada = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
+ data_inicio=timezone.localdate() - timedelta(days=1))
+ de_hoje = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
+ data_inicio=timezone.localdate())
+
+ visiveis = restringe_sessoes_visiveis(
+ SessaoPlenaria.objects.all(), AnonymousUser())
+
+ assert passada in visiveis
+ assert de_hoje in visiveis
+
+
@pytest.mark.django_db(transaction=False)
def test_visiveis_para_nao_oculta_nada_de_usuario_autenticado():
- previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False)
+ previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
+ data_inicio=data_futura())
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)
def test_pesquisar_sessao_nao_lista_sessao_previa_para_anonimo(client):
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,
- data_inicio=date(2025, 11, 5))
+ data_inicio=data_futura())
response = client.get(reverse('sapl.sessao:pesquisar_sessao'),
- {'data_inicio__year': '2025'})
+ {'data_inicio__year': data_futura().year})
assert response.status_code == 200
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)
def test_pesquisar_sessao_lista_sessao_previa_para_autenticado(admin_client):
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'),
- {'data_inicio__year': '2025'})
+ {'data_inicio__year': data_futura().year})
assert response.status_code == 200
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)
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',
kwargs={'pk': previa.pk}))
@@ -219,7 +248,8 @@ def test_detail_sessao_previa_indisponivel_para_anonimo(client):
@pytest.mark.django_db(transaction=False)
def test_resumo_de_sessao_previa_indisponivel_para_anonimo(client):
- previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False)
+ previa = baker.make(SessaoPlenaria, iniciada=False, publicar_pauta=False,
+ data_inicio=data_futura())
response = client.get(reverse('sapl.sessao:resumo',
kwargs={'pk': previa.pk}))
@@ -235,3 +265,103 @@ def test_pauta_nao_publicada_indisponivel_para_anonimo(client):
kwargs={'pk': sem_pauta.pk}))
assert response.status_code == 404
+
+
+def cria_sessao_com_parlamentares():
+ legislatura = baker.make(Legislatura)
+ sessao_legislativa = baker.make(SessaoLegislativa)
+ tipo = baker.make(TipoSessaoPlenaria)
+ sessao = baker.make(SessaoPlenaria,
+ legislatura=legislatura,
+ sessao_legislativa=sessao_legislativa,
+ tipo=tipo)
+ parlamentares = [baker.make(Parlamentar, ativo=True) for _i in range(3)]
+ return sessao, parlamentares
+
+
+@pytest.mark.django_db(transaction=False)
+def test_presenca_sessao_preserva_registros_ao_salvar_novamente(admin_client):
+ # Cada salvamento apagava todas as presenças da sessão e as recriava,
+ # trocando os ids. Além do desperdício, é essa janela entre apagar e
+ # recriar que permite a duplicação quando o formulário é submetido duas
+ # vezes em paralelo. Salvar de novo deve ser inócuo.
+ sessao, parlamentares = cria_sessao_com_parlamentares()
+ url = reverse('sapl.sessao:presenca', kwargs={'pk': sessao.pk})
+ dados = {'presenca_ativos': [str(p.pk) for p in parlamentares]}
+
+ admin_client.post(url, dados, follow=True)
+ presencas = SessaoPlenariaPresenca.objects.filter(sessao_plenaria=sessao)
+ ids_iniciais = sorted(presencas.values_list('id', flat=True))
+
+ admin_client.post(url, dados, follow=True)
+ admin_client.post(url, dados, follow=True)
+
+ assert presencas.count() == len(parlamentares)
+ assert sorted(presencas.values_list('parlamentar_id', flat=True)) == \
+ sorted(p.pk for p in parlamentares)
+ assert sorted(presencas.values_list('id', flat=True)) == ids_iniciais
+
+
+@pytest.mark.django_db(transaction=False)
+def test_presenca_sessao_ignora_registro_ja_existente(admin_client):
+ # Reproduz o efeito da submissão concorrente: a linha já existe quando a
+ # view vai gravar. Deve terminar com uma única presença, sem erro.
+ sessao, parlamentares = cria_sessao_com_parlamentares()
+ parlamentar = parlamentares[0]
+ SessaoPlenariaPresenca.objects.create(sessao_plenaria=sessao,
+ parlamentar=parlamentar)
+
+ url = reverse('sapl.sessao:presenca', kwargs={'pk': sessao.pk})
+ response = admin_client.post(
+ url, {'presenca_ativos': [str(parlamentar.pk)]}, follow=True)
+
+ assert response.status_code == 200
+ assert SessaoPlenariaPresenca.objects.filter(
+ sessao_plenaria=sessao, parlamentar=parlamentar).count() == 1
+
+
+@pytest.mark.django_db(transaction=False)
+def test_presenca_sessao_remove_desmarcados(admin_client):
+ sessao, parlamentares = cria_sessao_com_parlamentares()
+ url = reverse('sapl.sessao:presenca', kwargs={'pk': sessao.pk})
+
+ admin_client.post(url, {'presenca_ativos': [str(p.pk)
+ for p in parlamentares]},
+ follow=True)
+ admin_client.post(url, {'presenca_ativos': [str(parlamentares[0].pk)]},
+ follow=True)
+
+ presencas = SessaoPlenariaPresenca.objects.filter(sessao_plenaria=sessao)
+ assert list(presencas.values_list('parlamentar_id', flat=True)) == \
+ [parlamentares[0].pk]
+
+
+@pytest.mark.django_db(transaction=False)
+def test_presenca_ordem_dia_preserva_registros_ao_resalvar(admin_client):
+ sessao, parlamentares = cria_sessao_com_parlamentares()
+ url = reverse('sapl.sessao:presencaordemdia', kwargs={'pk': sessao.pk})
+ dados = {'presenca_ativos': [str(p.pk) for p in parlamentares]}
+
+ admin_client.post(url, dados, follow=True)
+ presencas = PresencaOrdemDia.objects.filter(sessao_plenaria=sessao)
+ ids_iniciais = sorted(presencas.values_list('id', flat=True))
+
+ admin_client.post(url, dados, follow=True)
+
+ assert presencas.count() == len(parlamentares)
+ assert sorted(presencas.values_list('id', flat=True)) == ids_iniciais
+
+
+@pytest.mark.django_db(transaction=False)
+def test_presenca_unica_por_sessao_e_parlamentar():
+ # A restrição de unicidade é a proteção contra submissões concorrentes,
+ # que a lógica da view sozinha não cobre.
+ sessao, parlamentares = cria_sessao_com_parlamentares()
+ parlamentar = parlamentares[0]
+
+ for model in (SessaoPlenariaPresenca, PresencaOrdemDia):
+ model.objects.create(sessao_plenaria=sessao, parlamentar=parlamentar)
+ with pytest.raises(IntegrityError):
+ with transaction.atomic():
+ model.objects.create(sessao_plenaria=sessao,
+ parlamentar=parlamentar)
diff --git a/sapl/sessao/tests/test_votacao_nominal.py b/sapl/sessao/tests/test_votacao_nominal.py
index fbd86d433..0e1aa9aa0 100644
--- a/sapl/sessao/tests/test_votacao_nominal.py
+++ b/sapl/sessao/tests/test_votacao_nominal.py
@@ -1,3 +1,5 @@
+from unittest import mock
+
import pytest
from django.contrib.auth import get_user_model
from django.db import IntegrityError
@@ -5,7 +7,7 @@ from django.urls import reverse
from model_bakery import baker
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,
SessaoLegislativa)
from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao,
@@ -15,6 +17,12 @@ from sapl.sessao.models import (OrdemDia, PresencaOrdemDia, RegistroVotacao,
NOMINAL = 2
+@pytest.fixture(autouse=True)
+def in_memory_channel_layer(settings):
+ settings.CHANNEL_LAYERS = {
+ 'default': {'BACKEND': 'channels.layers.InMemoryChannelLayer'}}
+
+
def _sessao_plenaria():
legislatura = baker.make(Legislatura)
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)
+def _abrir_votacao_url(sessao, ordem):
+ return reverse('sapl.sessao:abrir_votacao',
+ kwargs={'pk': ordem.pk, 'spk': sessao.pk}) + '?tipo_materia=ordem'
+
+
+@pytest.mark.django_db(transaction=False)
+def test_abrir_outra_votacao_zera_bloqueio_da_anterior(admin_client):
+ """
+ Abrir Y sem encerrar X fecha X; X não pode voltar com os tablets
+ bloqueados (registro_aberto=True) quando for reaberta.
+ """
+ sessao, x = _ordem_nominal_aberta()
+ sessao.iniciada = True
+ sessao.finalizada = False
+ sessao.save()
+ _presente(sessao)
+ x.registro_aberto = True
+ x.save()
+ y = baker.make(OrdemDia, sessao_plenaria=sessao, materia=_materia(),
+ tipo_votacao=NOMINAL, votacao_aberta=False)
+
+ admin_client.get(_abrir_votacao_url(sessao, y))
+
+ x.refresh_from_db()
+ y.refresh_from_db()
+ assert y.votacao_aberta is True
+ assert x.votacao_aberta is False
+ assert x.registro_aberto is False
+
+ # Legado: matéria fechada que ficou com registro_aberto=True.
+ OrdemDia.objects.filter(pk=x.pk).update(registro_aberto=True)
+ admin_client.get(_abrir_votacao_url(sessao, x))
+
+ x.refresh_from_db()
+ assert x.votacao_aberta is True
+ assert x.registro_aberto is False
+
+
+@pytest.mark.django_db(transaction=False)
+def test_abrir_votacao_concorrente_mostra_erro_em_vez_de_500(admin_client):
+ """
+ O lock de abrir_votacao() é por sessão, mas a unicidade de
+ votacao_aberta é global: uma abertura simultânea em outra sessão faz o
+ save cair no índice parcial. O fechamento das outras votações é
+ desfeito junto, então a mensagem "foram fechadas" não pode aparecer.
+ """
+ sessao, ordem = _ordem_nominal_aberta()
+ ordem.votacao_aberta = False
+ ordem.save()
+ _, outra = _ordem_nominal_aberta()
+ sessao.iniciada = True
+ sessao.finalizada = False
+ sessao.save()
+ _presente(sessao)
+
+ with mock.patch.object(OrdemDia, 'save', side_effect=IntegrityError):
+ response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True)
+
+ assert response.status_code == 200
+ ordem.refresh_from_db()
+ outra.refresh_from_db()
+ assert ordem.votacao_aberta is False
+ assert outra.votacao_aberta is True
+ mensagens = [str(m) for m in response.context['messages']]
+ assert any('aberta simultaneamente' in m for m in mensagens)
+ assert not any('foram fechadas' in m for m in mensagens)
+
+
+@pytest.mark.django_db(transaction=False)
+def test_abrir_votacao_avisa_que_fechou_as_outras(admin_client):
+ sessao, ordem = _ordem_nominal_aberta()
+ ordem.votacao_aberta = False
+ ordem.save()
+ _, outra = _ordem_nominal_aberta()
+ sessao.iniciada = True
+ sessao.finalizada = False
+ sessao.save()
+ _presente(sessao)
+
+ response = admin_client.get(_abrir_votacao_url(sessao, ordem), follow=True)
+
+ outra.refresh_from_db()
+ assert outra.votacao_aberta is False
+ mensagens = [str(m) for m in response.context['messages']]
+ assert any('foram fechadas' in m for m in mensagens)
+
+
@pytest.mark.django_db(transaction=False)
def test_unique_constraint_impede_voto_duplicado(admin_client):
sessao, ordem = _ordem_nominal_aberta()
@@ -144,9 +239,38 @@ def test_unique_constraint_impede_duas_ordens_abertas():
@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
ser aplicada num banco com dado antigo (de antes desta invariante
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
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:
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()
mais_recente = baker.make(OrdemDia, sessao_plenaria=sessao,
materia=mais_antiga.materia, tipo_votacao=NOMINAL,
- votacao_aberta=True)
+ votacao_aberta=True, registro_aberto=True)
assert mais_recente.pk > mais_antiga.pk
+ # Legado: o GET antigo da tela de registro deixava registro_aberto=True
+ # em matérias já encerradas.
+ encerrada = baker.make(OrdemDia, sessao_plenaria=sessao,
+ materia=mais_antiga.materia, tipo_votacao=NOMINAL,
+ votacao_aberta=False, registro_aberto=True)
- migracao.fecha_matérias_abertas_duplicadas(real_apps, None)
+ migracao.fecha_materias_abertas_duplicadas(real_apps, None)
mais_antiga.refresh_from_db()
mais_recente.refresh_from_db()
+ encerrada.refresh_from_db()
assert mais_antiga.votacao_aberta is False
assert mais_recente.votacao_aberta is True
+ assert mais_recente.registro_aberto is False
+ assert encerrada.registro_aberto is False
@pytest.mark.django_db(transaction=False)
@@ -198,3 +330,187 @@ def test_api_nao_permite_abrir_votacao_via_patch(admin_client):
assert response.status_code in (200, 202)
ordem.refresh_from_db()
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