Browse Source

Impede presenças duplicadas de parlamentar na mesma sessão

O relatório de presença exibia percentuais acima de 100% — em Capanema/PR,
um vereador aparecia com 46 sessões em um período de 45. A contagem não
estava errada: existiam mesmo duas linhas de presença dele na sessão nº 22
de 30/06/2025, e o relatório contava linhas com Count('id').

A origem é PresencaView/PresencaOrdemDiaView. `presentes_banco` vem do banco
como inteiros e `marcados` vem do POST como strings, então
`set(presentes_banco) - set(marcados)` nunca casava e resultava em apagar
todas as presenças da sessão a cada salvamento, recriando-as em seguida.
Sequencialmente o resultado é correto, mas duas submissões concorrentes do
formulário — um duplo clique em Salvar — passam ambas pela janela entre o
apagar e o recriar e gravam duas linhas por parlamentar. Os ids gravados em
Capanema confirmam: pares adjacentes intercalados na ordem alfabética da
tela, assinatura de dois laços concorrentes.

Nada no banco impedia isso. Não é um caso isolado: Capanema tinha 625 linhas
excedentes em 313 sessões, e a base de Agudo/RS, 9 linhas em 1 sessão.

Corrige em quatro frentes:

- compara os ids com o mesmo tipo e passa a criar apenas quem ainda não tem
  presença, sem apagar e recriar quem permanece marcado;
- usa bulk_create(ignore_conflicts=True), para que a submissão concorrente
  descarte a inserção repetida em vez de estourar IntegrityError;
- adiciona unique_together (sessao_plenaria, parlamentar) nos dois modelos,
  que é a proteção efetiva contra concorrência, com migração que remove as
  duplicatas existentes antes de criar a restrição;
- conta sessões distintas no relatório, em vez de linhas, para que bases
  ainda não migradas não exibam percentuais impossíveis.

OSTicket #212222
fix/presencas-duplicadas-sessao
João Moraes 23 hours ago
parent
commit
8c766c3628
  1. 11
      sapl/relatorios/views.py
  2. 55
      sapl/sessao/migrations/0070_presenca_unica_por_sessao.py
  3. 6
      sapl/sessao/models.py
  4. 102
      sapl/sessao/tests/test_sessao_view.py
  5. 52
      sapl/sessao/views.py

11
sapl/relatorios/views.py

@ -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()

55
sapl/sessao/migrations/0070_presenca_unica_por_sessao.py

@ -0,0 +1,55 @@
# Generated by Django 2.2.28 on 2026-08-05 16:00
from django.db import migrations
from django.db.models import Count
def remove_presencas_duplicadas(apps, schema_editor):
"""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. Em
SessaoPlenariaPresenca -se preferência a uma linha com data_sessao
preenchida, caso exista, para não perder o único campo que as diferencia.
"""
for model_name, tem_data_sessao in (('SessaoPlenariaPresenca', True),
('PresencaOrdemDia', False)):
model = apps.get_model('sessao', model_name)
# list() para materializar antes de apagar dentro do laço
duplicados = list(model.objects.values_list(
'sessao_plenaria_id', 'parlamentar_id').annotate(
total=Count('id')).filter(total__gt=1))
for sessao_id, parlamentar_id, _total in duplicados:
linhas = list(model.objects.filter(
sessao_plenaria_id=sessao_id,
parlamentar_id=parlamentar_id).order_by('id'))
manter = linhas[0]
if tem_data_sessao:
com_data = [x for x in linhas if x.data_sessao is not None]
if com_data:
manter = com_data[0]
model.objects.filter(
sessao_plenaria_id=sessao_id,
parlamentar_id=parlamentar_id).exclude(
id=manter.id).delete()
class Migration(migrations.Migration):
dependencies = [
('sessao', '0069_auto_20220919_1705'),
]
operations = [
migrations.RunPython(remove_presencas_duplicadas,
migrations.RunPython.noop),
migrations.AlterUniqueTogether(
name='sessaoplenariapresenca',
unique_together={('sessao_plenaria', 'parlamentar')},
),
migrations.AlterUniqueTogether(
name='presencaordemdia',
unique_together={('sessao_plenaria', 'parlamentar')},
),
]

6
sapl/sessao/models.py

@ -594,6 +594,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
@ -736,6 +739,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 = [

102
sapl/sessao/tests/test_sessao_view.py

@ -2,6 +2,7 @@ import pytest
from datetime import date from datetime import date
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.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from model_bakery import baker from model_bakery import baker
@ -9,6 +10,7 @@ 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,
@ -235,3 +237,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)

52
sapl/sessao/views.py

@ -1420,26 +1420,31 @@ class PresencaView(FormMixin, PresencaMixin, DetailView):
if form.is_valid(): if form.is_valid():
# Pegar os presentes salvos no banco # Pegar os presentes salvos no banco
presentes_banco = SessaoPlenariaPresenca.objects.filter( presentes_banco = set(SessaoPlenariaPresenca.objects.filter(
sessao_plenaria_id=self.object.id).values_list( sessao_plenaria_id=self.object.id).values_list(
'parlamentar_id', flat=True).distinct() 'parlamentar_id', flat=True))
# Id dos parlamentares presentes # Id dos parlamentares presentes
marcados = request.POST.getlist('presenca_ativos') \ marcados = set(int(p) for p in
+ request.POST.getlist('presenca_inativos') request.POST.getlist('presenca_ativos')
+ request.POST.getlist('presenca_inativos'))
# Deletar os que foram desmarcados # Deletar os que foram desmarcados
deletar = set(presentes_banco) - set(marcados)
SessaoPlenariaPresenca.objects.filter( SessaoPlenariaPresenca.objects.filter(
parlamentar_id__in=deletar, parlamentar_id__in=presentes_banco - marcados,
sessao_plenaria_id=self.object.id).delete() sessao_plenaria_id=self.object.id).delete()
for p in marcados: # Criar apenas quem ainda não tem presença registrada. O
sessao = SessaoPlenariaPresenca() # ignore_conflicts descarta a inserção duplicada quando o
sessao.sessao_plenaria = self.object # formulário é submetido duas vezes em paralelo, em vez de
sessao.parlamentar = Parlamentar.objects.get(id=p) # gravar uma segunda linha para o mesmo parlamentar.
sessao.save()
username = request.user.username username = request.user.username
novos = marcados - presentes_banco
SessaoPlenariaPresenca.objects.bulk_create(
[SessaoPlenariaPresenca(sessao_plenaria=self.object,
parlamentar_id=p) for p in novos],
ignore_conflicts=True)
for p in novos:
self.logger.info( self.logger.info(
"user=" + username + ". SessaoPlenariaPresenca salva com sucesso (parlamentar_id={})!".format(p)) "user=" + username + ". SessaoPlenariaPresenca salva com sucesso (parlamentar_id={})!".format(p))
msg = _('Presença em Sessão salva com sucesso!') msg = _('Presença em Sessão salva com sucesso!')
@ -1535,26 +1540,29 @@ class PresencaOrdemDiaView(FormMixin, PresencaMixin, DetailView):
if form.is_valid(): if form.is_valid():
# Pegar os presentes salvos no banco # Pegar os presentes salvos no banco
presentes_banco = PresencaOrdemDia.objects.filter( presentes_banco = set(PresencaOrdemDia.objects.filter(
sessao_plenaria_id=self.object.id).values_list( sessao_plenaria_id=self.object.id).values_list(
'parlamentar_id', flat=True).distinct() 'parlamentar_id', flat=True))
# Id dos parlamentares presentes # Id dos parlamentares presentes
marcados = request.POST.getlist('presenca_ativos') \ marcados = set(int(p) for p in
+ request.POST.getlist('presenca_inativos') request.POST.getlist('presenca_ativos')
+ request.POST.getlist('presenca_inativos'))
# Deletar os que foram desmarcados # Deletar os que foram desmarcados
deletar = set(presentes_banco) - set(marcados)
PresencaOrdemDia.objects.filter( PresencaOrdemDia.objects.filter(
parlamentar_id__in=deletar, parlamentar_id__in=presentes_banco - marcados,
sessao_plenaria_id=self.object.id).delete() sessao_plenaria_id=self.object.id).delete()
for p in marcados: # Criar apenas quem ainda não tem presença registrada. Ver
ordem = PresencaOrdemDia() # comentário equivalente em PresencaView.post.
ordem.sessao_plenaria = self.object
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))

Loading…
Cancel
Save