Browse Source

Impede presenças duplicadas de parlamentar na mesma sessão (#3850)

* 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

* Remove presenças duplicadas com RunSQL na migração 0070

Substitui o RunPython por um DELETE ... USING por tabela, conforme
sugestão da revisão: mantém a presença de menor id de cada par
(sessão, parlamentar), agora também em sessao_presencaordemdia.
A preferência por data_sessao preenchida foi abandonada, já que o
campo não é lido nem escrito em nenhum ponto do código.
pull/3864/head
joaohortsenado 5 days ago
committed by GitHub
parent
commit
2e6acfa24d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 11
      sapl/relatorios/views.py
  2. 40
      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. 56
      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()

40
sapl/sessao/migrations/0070_presenca_unica_por_sessao.py

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

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)

56
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 username = request.user.username
ordem.parlamentar = Parlamentar.objects.get(id=p) novos = marcados - presentes_banco
ordem.save() PresencaOrdemDia.objects.bulk_create(
username = request.user.username [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