From 8c766c3628004384c64f8e4367bdf25cbf2adc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Moraes?= Date: Wed, 5 Aug 2026 18:28:53 -0300 Subject: [PATCH] =?UTF-8?q?Impede=20presen=C3=A7as=20duplicadas=20de=20par?= =?UTF-8?q?lamentar=20na=20mesma=20sess=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- sapl/relatorios/views.py | 11 +- .../0070_presenca_unica_por_sessao.py | 55 ++++++++++ sapl/sessao/models.py | 6 ++ sapl/sessao/tests/test_sessao_view.py | 102 ++++++++++++++++++ sapl/sessao/views.py | 56 +++++----- 5 files changed, 203 insertions(+), 27 deletions(-) create mode 100644 sapl/sessao/migrations/0070_presenca_unica_por_sessao.py diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py index 8b89342fd..073fa8ba6 100755 --- a/sapl/relatorios/views.py +++ b/sapl/relatorios/views.py @@ -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..3f6f384b2 --- /dev/null +++ b/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 dá-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')}, + ), + ] diff --git a/sapl/sessao/models.py b/sapl/sessao/models.py index ad71be072..fc418b08f 100644 --- a/sapl/sessao/models.py +++ b/sapl/sessao/models.py @@ -594,6 +594,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 @@ -736,6 +739,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 = [ diff --git a/sapl/sessao/tests/test_sessao_view.py b/sapl/sessao/tests/test_sessao_view.py index 70bb920a9..aef74c2ae 100644 --- a/sapl/sessao/tests/test_sessao_view.py +++ b/sapl/sessao/tests/test_sessao_view.py @@ -2,6 +2,7 @@ import pytest from datetime import date 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.translation import ugettext_lazy as _ from model_bakery import baker @@ -9,6 +10,7 @@ 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, @@ -235,3 +237,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/views.py b/sapl/sessao/views.py index 64f7ce7c1..4298f8f44 100755 --- a/sapl/sessao/views.py +++ b/sapl/sessao/views.py @@ -1420,26 +1420,31 @@ class PresencaView(FormMixin, PresencaMixin, DetailView): if form.is_valid(): # 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( - 'parlamentar_id', flat=True).distinct() + 'parlamentar_id', flat=True)) # Id dos parlamentares presentes - marcados = request.POST.getlist('presenca_ativos') \ - + request.POST.getlist('presenca_inativos') + marcados = set(int(p) for p in + request.POST.getlist('presenca_ativos') + + request.POST.getlist('presenca_inativos')) # Deletar os que foram desmarcados - deletar = set(presentes_banco) - set(marcados) SessaoPlenariaPresenca.objects.filter( - parlamentar_id__in=deletar, + parlamentar_id__in=presentes_banco - marcados, sessao_plenaria_id=self.object.id).delete() - for p in marcados: - sessao = SessaoPlenariaPresenca() - sessao.sessao_plenaria = self.object - sessao.parlamentar = Parlamentar.objects.get(id=p) - sessao.save() - username = request.user.username + # Criar apenas quem ainda não tem presença registrada. O + # ignore_conflicts descarta a inserção duplicada quando o + # formulário é submetido duas vezes em paralelo, em vez de + # gravar uma segunda linha para o mesmo parlamentar. + 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( "user=" + username + ". SessaoPlenariaPresenca salva com sucesso (parlamentar_id={})!".format(p)) msg = _('Presença em Sessão salva com sucesso!') @@ -1535,26 +1540,29 @@ class PresencaOrdemDiaView(FormMixin, PresencaMixin, DetailView): if form.is_valid(): # 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( - 'parlamentar_id', flat=True).distinct() + 'parlamentar_id', flat=True)) # Id dos parlamentares presentes - marcados = request.POST.getlist('presenca_ativos') \ - + request.POST.getlist('presenca_inativos') + marcados = set(int(p) for p in + request.POST.getlist('presenca_ativos') + + request.POST.getlist('presenca_inativos')) # Deletar os que foram desmarcados - deletar = set(presentes_banco) - set(marcados) PresencaOrdemDia.objects.filter( - parlamentar_id__in=deletar, + parlamentar_id__in=presentes_banco - marcados, sessao_plenaria_id=self.object.id).delete() - for p in marcados: - ordem = PresencaOrdemDia() - ordem.sessao_plenaria = self.object - ordem.parlamentar = Parlamentar.objects.get(id=p) - ordem.save() - username = request.user.username + # Criar apenas quem ainda não tem presença registrada. Ver + # comentário equivalente em PresencaView.post. + 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( 'user=' + username + '. PresencaOrdemDia (parlamentar com id={}) salva com sucesso!'.format(p))