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 %s ''' % ( - url, metodo, csrf_input, page_number) + url, page_number) else: btn_registrar = '''
    @@ -933,8 +937,7 @@ class MateriaOrdemDiaCrud(MasterDetailCrud): context = super().get_context_data(**kwargs) has_permition = self.request.user.has_module_perms(AppConfig.label) - return customize_link_materia(context, self.kwargs['pk'], has_permition, False, - request=self.request) + return customize_link_materia(context, self.kwargs['pk'], has_permition, False) def recuperar_materia(request): @@ -1010,8 +1013,7 @@ class ExpedienteMateriaCrud(MasterDetailCrud): context['page'] = self.request.GET.get('page') has_permition = self.request.user.has_module_perms(AppConfig.label) - return customize_link_materia(context, self.kwargs['pk'], has_permition, True, - request=self.request) + return customize_link_materia(context, self.kwargs['pk'], has_permition, True) class CreateView(MasterDetailCrud.CreateView): form_class = ExpedienteMateriaForm @@ -1539,26 +1541,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!') @@ -1664,26 +1671,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)) @@ -2370,7 +2380,7 @@ class ResumoView(DetailView): # Votos de Votação Nominal de Matérias Expediente votacoes = [] for mevn in ExpedienteMateria.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2) \ - .order_by('-materia'): + .order_by('numero_ordem'): votos_materia = [] titulo_materia = mevn.materia registro = RegistroVotacao.objects.filter(expediente=mevn) @@ -2419,7 +2429,7 @@ class ResumoView(DetailView): # Matérias Ordem do Dia # Votos de Votação Nominal de Matérias Ordem do Dia votacoes_od = [] - for modvn in OrdemDia.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2).order_by('-materia'): + for modvn in OrdemDia.objects.filter(sessao_plenaria_id=self.object.id, tipo_votacao=2).order_by('numero_ordem'): votos_materia_od = [] t_materia = modvn.materia registro_od = RegistroVotacao.objects.filter(ordem=modvn) diff --git a/sapl/templates/audiencia/layouts.yaml b/sapl/templates/audiencia/layouts.yaml index 916bfa287..18bc46e46 100644 --- a/sapl/templates/audiencia/layouts.yaml +++ b/sapl/templates/audiencia/layouts.yaml @@ -6,7 +6,7 @@ AudienciaPublica: {% trans 'Dados' %}: - tipo_materia numero_materia ano_materia - tipo:4 data:2 hora_inicio:3 hora_fim:3 - - parlamentar_autor requerimento + - autor requerimento - url_audio url_video - upload_pauta upload_ata upload_anexo - observacao @@ -19,7 +19,7 @@ AudienciaPublicaDetail: {% trans 'Dados' %}: - materia tipo - data hora_inicio hora_fim - - parlamentar_autor requerimento + - autor requerimento - url_audio url_video - upload_pauta upload_ata upload_anexo - observacao diff --git a/sapl/templates/base.html b/sapl/templates/base.html index a11841704..e3582e2ef 100644 --- a/sapl/templates/base.html +++ b/sapl/templates/base.html @@ -40,6 +40,13 @@
    + {% if not request|has_iframe %} +
    + {% trans 'Informações foram atualizadas em' %} + {% if data_atualizacao_conteudo %}{{ data_atualizacao_conteudo|date:"d/m/Y" }}{% else %}{% now "d/m/Y" %}{% endif %} +
    + {% endif %} + {% if not sapl_as_sapn or user.is_authenticated%} {% if not request|has_iframe %} {% block navigation %} diff --git a/sapl/templates/compilacao/dispositivo_form_alteracao.html b/sapl/templates/compilacao/dispositivo_form_alteracao.html index 711472d01..c7d8562d8 100644 --- a/sapl/templates/compilacao/dispositivo_form_alteracao.html +++ b/sapl/templates/compilacao/dispositivo_form_alteracao.html @@ -7,6 +7,6 @@ 'url_form': '{% url 'sapl.compilacao:dispositivo_search_form'%}', 'text_button': '{% trans 'Definir Dispositivo'%}' }); - initTextRichEditor(); + initTextRichEditor(null, false, false, true); {% endblock %} diff --git a/sapl/templates/compilacao/dispositivo_form_edicao_basica.html b/sapl/templates/compilacao/dispositivo_form_edicao_basica.html index 5eca7a09d..c4c5d8e2b 100644 --- a/sapl/templates/compilacao/dispositivo_form_edicao_basica.html +++ b/sapl/templates/compilacao/dispositivo_form_edicao_basica.html @@ -31,6 +31,6 @@ }); } - initTextRichEditor(); + initTextRichEditor(null, false, false, true); {% endblock %} diff --git a/sapl/templates/compilacao/text_edit.html b/sapl/templates/compilacao/text_edit.html index ada437934..ed7055e62 100644 --- a/sapl/templates/compilacao/text_edit.html +++ b/sapl/templates/compilacao/text_edit.html @@ -17,8 +17,14 @@ {% if object.editing_locked %}{% trans 'Desbloquear Edição' %}{%else%}{% trans 'Publicar Texto' %}{% endif %} {% endif %}
    -
    - {% include 'compilacao/textoarticulado_menu_config.html' %} +
    +
    + a + A +
    +
    + {% include 'compilacao/textoarticulado_menu_config.html' %} +
    {% if object.dispositivos_set.count == 1 %} diff --git a/sapl/templates/relatorios/relatorio_materia_tramitacao.html b/sapl/templates/relatorios/relatorio_materia_tramitacao.html index ddfe6e38b..a75a7344b 100644 --- a/sapl/templates/relatorios/relatorio_materia_tramitacao.html +++ b/sapl/templates/relatorios/relatorio_materia_tramitacao.html @@ -9,7 +9,7 @@

    Histórico de Tramitações da Matéria: {{ numero }}/{{ ano }}

    Tipo de matéria: {{ tipo }}
    - Autor: {{ autor }}
    + {% if autores|length > 1 %}Autores: {% else %}Autor: {% endif %}{{ autores|join:", " }}
    {% for tramitacao in object%} diff --git a/sapl/templates/sessao/sessaoplenaria_filter.html b/sapl/templates/sessao/sessaoplenaria_filter.html index 51c2fdb17..7ea3f8b65 100644 --- a/sapl/templates/sessao/sessaoplenaria_filter.html +++ b/sapl/templates/sessao/sessaoplenaria_filter.html @@ -53,6 +53,13 @@ {% if s.upload_ata %} Ata da Sessão
    {% endif %} + {% if s.upload_pauta %} + Pauta da Sessão
    + {% endif %} + {% if s.upload_anexo %} + Anexo da Sessão
    + {% endif %} + Votações Nominais
    {% endfor %} diff --git a/sapl/utils.py b/sapl/utils.py index 9bd6231dc..588494bd5 100644 --- a/sapl/utils.py +++ b/sapl/utils.py @@ -623,6 +623,15 @@ TIPOS_TEXTO_PERMITIDOS = ( 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/xml', 'text/xml', + # Formatos editáveis de planilha/CSV (atende à gravação de relatórios do PNTP). + 'text/csv', + 'application/csv', + 'text/comma-separated-values', + 'application/vnd.ms-excel', # .xls + 'application/x-ole-storage', # .xls (detecção OLE2 legada do libmagic) + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', # .xlsx + 'application/vnd.oasis.opendocument.spreadsheet', # .ods / calc + 'application/x-vnd.oasis.opendocument.spreadsheet', ) TIPOS_IMG_PERMITIDOS = (