Browse Source

more changes

feat/painel-votacao-v2
Edward Ribeiro 1 week ago
parent
commit
55bce97f83
  1. 13
      sapl/api/serializers.py
  2. 19
      sapl/api/tests/test_serializers_sessao.py
  3. 20
      sapl/audiencia/forms.py
  4. 59
      sapl/audiencia/migrations/0021_auto_20260623_1938.py
  5. 18
      sapl/audiencia/migrations/0022_audienciapublica_data_ultima_atualizacao.py
  6. 2
      sapl/audiencia/migrations/0023_auto_20260916_1709.py
  7. 11
      sapl/audiencia/models.py
  8. 25
      sapl/comissoes/forms.py
  9. 18
      sapl/comissoes/migrations/0031_comissao_data_ultima_atualizacao.py
  10. 3
      sapl/comissoes/models.py
  11. 11
      sapl/comissoes/views.py
  12. 4
      sapl/compilacao/utils.py
  13. 5
      sapl/crud/base.py
  14. 64
      sapl/painel/tests/tests.py
  15. 265
      sapl/painel/views.py
  16. 18
      sapl/parlamentares/migrations/0046_parlamentar_data_ultima_atualizacao.py
  17. 4
      sapl/parlamentares/models.py
  18. 28
      sapl/protocoloadm/migrations/0046_auto_20260626_1220.py
  19. 2
      sapl/protocoloadm/migrations/0047_auto_20260916_1709.py
  20. 3
      sapl/protocoloadm/models.py
  21. 17
      sapl/relatorios/views.py
  22. 40
      sapl/sessao/migrations/0070_presenca_unica_por_sessao.py
  23. 21
      sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py
  24. 54
      sapl/sessao/migrations/0071_votoparlamentar_unique_constraint.py
  25. 16
      sapl/sessao/migrations/0072_votacao_aberta_unique_constraint.py
  26. 2
      sapl/sessao/migrations/0073_sessao_presencas_e_materias_votacoes_views.py
  27. 2
      sapl/sessao/migrations/0074_sessaomateriasvotacoesview_sessaopresencasview.py
  28. 21
      sapl/sessao/models.py
  29. 156
      sapl/sessao/tests/test_sessao_view.py
  30. 328
      sapl/sessao/tests/test_votacao_nominal.py
  31. 120
      sapl/sessao/views.py
  32. 4
      sapl/templates/audiencia/layouts.yaml
  33. 7
      sapl/templates/base.html
  34. 2
      sapl/templates/compilacao/dispositivo_form_alteracao.html
  35. 2
      sapl/templates/compilacao/dispositivo_form_edicao_basica.html
  36. 6
      sapl/templates/compilacao/text_edit.html
  37. 2
      sapl/templates/relatorios/relatorio_materia_tramitacao.html
  38. 7
      sapl/templates/sessao/sessaoplenaria_filter.html
  39. 9
      sapl/utils.py

13
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

19
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

20
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.")

59
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'),
),
]

18
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'),
),
]

2
sapl/audiencia/migrations/0021_auto_20260916_1709.py → 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 = [

11
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,

25
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'))))

18
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'),
),
]

3
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'))

11
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):

4
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__'):

5
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

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

265
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('''<li><a href="%s">%s</a></li>''' % (
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) + '. '
'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) + '. '
'nas seguintes Sessões: ' + sessoes + '. '
'Para votar, peça para que o Operador feche-as.')
messages.add_message(request, messages.INFO, msg)
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: ' +
('''<li><a href="%s">%s</a></li>''' % (
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: ' +
('''<li><a href="%s">%s</a></li>''' % (
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}")
f"Invalid vote value: {voto_valor}. Must be one of: {list(VOTOS_REAIS)}")
# 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']))
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) e o formulário em lote sempre gravam False.
# _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':
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)
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:
voto_obj, created = VotoParlamentar.objects.update_or_create(
parlamentar=parlamentar, expediente=expediente, defaults=defaults)
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
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:
votos = VotoParlamentar.objects.filter(ordem=ordem_dia)
fase_sessao = {'ordem': materia}
presenca_model = PresencaOrdemDia
else:
votos = VotoParlamentar.objects.filter(expediente=expediente)
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()
# All votes must not be "Não Votou"
total_votados = votos_sim + votos_nao + abstencoes
if total_votados == 0:
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)
# 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 = 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()
# 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
# 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}, "

18
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'),
),
]

4
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,

28
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'),
),
]

2
sapl/protocoloadm/migrations/0046_auto_20260916_1709.py → 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 = [

3
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,

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

40
sapl/sessao/migrations/0070_presenca_unica_por_sessao.py

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

21
sapl/sessao/migrations/0070_votoparlamentar_unique_constraint.py

@ -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'),
),
]

54
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'),
),
]

16
sapl/sessao/migrations/0071_votacao_aberta_unique_constraint.py → 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'),

2
sapl/sessao/migrations/0072_sessao_presencas_e_materias_votacoes_views.py → 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 = [

2
sapl/sessao/migrations/0073_sessaomateriasvotacoesview_sessaopresencasview.py → 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 = [

21
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

156
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)

328
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 <select> da Mesa desfaz o lançamento do operador; a
linha 'Não Votou' só é criada no encerramento.
"""
sessao, ordem = _ordem_nominal_aberta()
parlamentar = _presente(sessao)
operador = baker.make(get_user_model())
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não Votou', ip='127.0.0.1')
assert not VotoParlamentar.objects.filter(ordem=ordem, parlamentar=parlamentar).exists()
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Sim', ip='127.0.0.1')
_cast_vote(operador, sessao.pk, parlamentar.pk, 'Não Votou', 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_desfaz_voto_do_parlamentar():
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 Votou', ip='127.0.0.1')
assert VotoParlamentar.objects.get(ordem=ordem, parlamentar=parlamentar).voto == 'Sim'
@pytest.mark.django_db(transaction=False)
def test_toggle_registro_nao_regrava_outros_campos():
sessao, ordem = _ordem_nominal_aberta()
user = baker.make(get_user_model())
# Outra operação altera a matéria depois que _toggle_registro a leu.
original = _toggle_registro.__globals__['get_materia_aberta']
def get_materia_aberta_obsoleta(pk):
materia = original(pk)
OrdemDia.objects.filter(pk=materia.pk).update(resultado='Aprovada')
return materia
with mock.patch.dict(_toggle_registro.__globals__,
{'get_materia_aberta': get_materia_aberta_obsoleta}):
_toggle_registro(user, sessao.pk, True)
ordem.refresh_from_db()
assert ordem.registro_aberto is True
assert ordem.resultado == 'Aprovada'

120
sapl/sessao/views.py

@ -13,7 +13,6 @@ from django.db import IntegrityError, transaction
from django.db.models import Max, Q
from django.http import JsonResponse, QueryDict
from django.http.response import Http404, HttpResponseRedirect
from django.middleware.csrf import get_token
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
@ -133,7 +132,12 @@ def verifica_presenca(request, model, spk, is_leitura=False):
return True
def verifica_votacoes_abertas(request):
def verifica_votacoes_abertas(request, mensagens):
"""
Fecha as votações abertas. A mensagem para o usuário vai para
`mensagens`, e o chamador só a registra depois do commit: se a transação
for desfeita, as votações não foram fechadas.
"""
votacoes_abertas = SessaoPlenaria.objects.filter(
Q(ordemdia__votacao_aberta=True) |
Q(expedientemateria__votacao_aberta=True)).distinct()
@ -152,18 +156,15 @@ def verifica_votacoes_abertas(request):
', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.')
msg = _('Já existem votações ou leituras abertas nas seguintes Sessões: ' +
', '.join(msg_abertas) + '. Estas votações ou leituras foram fechadas.')
messages.add_message(request, messages.INFO, msg)
mensagens.append(msg)
for sessao in votacoes_abertas:
ordens = sessao.ordemdia_set.filter(votacao_aberta=True)
expediente = sessao.expedientemateria_set.filter(
votacao_aberta=True)
for o in ordens:
o.votacao_aberta = False
o.save()
for e in expediente:
e.votacao_aberta = False
e.save()
# update() em vez de save(): só toca estes dois campos e reavalia o
# WHERE sob o lock da linha, sem regravar um resultado que um
# "Encerrar Votação" concorrente tenha acabado de salvar.
OrdemDia.objects.filter(votacao_aberta=True).update(
votacao_aberta=False, registro_aberto=False)
ExpedienteMateria.objects.filter(votacao_aberta=True).update(
votacao_aberta=False, registro_aberto=False)
return True
@ -208,7 +209,9 @@ def abrir_votacao(request, pk, spk):
is_leitura = materia_votacao.tipo_votacao == 4
is_expediente = model is ExpedienteMateria
opened = False
mensagens = []
try:
with transaction.atomic():
# select_for_update trava a linha da SessaoPlenaria durante toda a
# checagem+fechamento+abertura, para que dois "abrir votação"
@ -230,14 +233,25 @@ def abrir_votacao(request, pk, spk):
# votações abertas... foram fechadas") não faz sentido quando a
# única "conflitante" é ela mesma.
if (verifica_presenca(request, presenca_model, spk, is_leitura) and
(ja_aberta or verifica_votacoes_abertas(request)) and
(ja_aberta or verifica_votacoes_abertas(request, mensagens)) and
verifica_sessao_iniciada(request, spk, is_leitura)):
materia_votacao.votacao_aberta = True
if not ja_aberta:
materia_votacao.registro_aberto = False
sessao = SessaoPlenaria.objects.get(id=spk)
sessao.painel_aberto = True
sessao.save()
materia_votacao.save()
opened = True
for msg in mensagens:
messages.add_message(request, messages.INFO, msg)
except IntegrityError:
# O lock acima é por sessão, mas a unicidade de votacao_aberta é
# global: uma abertura simultânea em outra sessão cai no índice
# parcial.
opened = False
messages.add_message(request, messages.ERROR, _(
'Outra votação foi aberta simultaneamente. Tente novamente.'))
if opened:
broadcast_dados_painel(request, spk)
@ -298,7 +312,7 @@ def abrir_votacao(request, pk, spk):
return HttpResponseRedirect(success_url)
def customize_link_materia(context, pk, has_permission, is_expediente, request=None):
def customize_link_materia(context, pk, has_permission, is_expediente):
for i, row in enumerate(context['rows']):
materia = context['object_list'][i].materia
obj = context['object_list'][i]
@ -429,23 +443,13 @@ def customize_link_materia(context, pk, has_permission, is_expediente, request=N
if has_permission:
if obj.tipo_votacao != TipoVotacao.LEITURA:
# Nominal (Ordem do Dia e Expediente, ambos agora)
# aponta para votacaonominal_v2, que só faz GET —
# mesmo tratamento de Simbólica/Secreta. O POST
# especial que existia aqui só para Nominal de
# Expediente era necessário quando ela ainda
# apontava para VotacaoNominalAbstract/nominal.html
# (retirado desta unificação — ver plano).
metodo = ''
csrf_input = ''
btn_registrar = '''
<form action="%s"%s>
%s
<form action="%s">
<input type="submit" class="btn btn-primary"
value="Registrar Votação" />
%s
</form>''' % (
url, metodo, csrf_input, page_number)
url, page_number)
else:
btn_registrar = '''
<form action="%s">
@ -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()
# 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()
# 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)

4
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

7
sapl/templates/base.html

@ -40,6 +40,13 @@
<body>
<div class="page fadein">
{% if not request|has_iframe %}
<div class="info-atualizacao hidden-print" style="background-color:#0d2b45;color:#fff;text-align:center;padding:6px 12px;font-size:14px;font-weight:600;">
{% trans 'Informações foram atualizadas em' %}
{% if data_atualizacao_conteudo %}{{ data_atualizacao_conteudo|date:"d/m/Y" }}{% else %}{% now "d/m/Y" %}{% endif %}
</div>
{% endif %}
{% if not sapl_as_sapn or user.is_authenticated%}
{% if not request|has_iframe %}
{% block navigation %}

2
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);
</script>
{% endblock %}

2
sapl/templates/compilacao/dispositivo_form_edicao_basica.html

@ -31,6 +31,6 @@
});
}
initTextRichEditor();
initTextRichEditor(null, false, false, true);
</script>
{% endblock %}

6
sapl/templates/compilacao/text_edit.html

@ -17,10 +17,16 @@
<a href="{% url 'sapl.compilacao:ta_text_edit' object.pk %}?{% if object.editing_locked %}unlock{%else%}lock{% endif %}" class="btn {% if object.editing_locked %}btn-outline-danger{%else%}btn-outline-primary{% endif %}">{% if object.editing_locked %}{% trans 'Desbloquear Edição' %}{%else%}{% trans 'Publicar Texto' %}{% endif %}</a>
{% endif %}
</div>
<div class="d-flex">
<div class="actions btn-group mr-2" role="group">
<a class="btn btn-outline-primary" id="btn_font_menos" title="{% trans 'Diminuir tamanho da letra' %}">a</a>
<a class="btn btn-outline-primary" id="btn_font_mais" title="{% trans 'Aumentar tamanho da letra' %}">A</a>
</div>
<div class="actions btn-group dropleft" role="group">
{% include 'compilacao/textoarticulado_menu_config.html' %}
</div>
</div>
</div>
{% if object.dispositivos_set.count == 1 %}
{% with view.materia_da_norma_deste_texto_articulado as materia %}
{% if materia %}

2
sapl/templates/relatorios/relatorio_materia_tramitacao.html

@ -9,7 +9,7 @@
<h2>Histórico de Tramitações da Matéria: {{ numero }}/{{ ano }}</h2>
Tipo de matéria: {{ tipo }}<br />
Autor: {{ autor }}<br />
{% if autores|length > 1 %}Autores: {% else %}Autor: {% endif %}{{ autores|join:", " }}<br />
<table class="grayTable">
{% for tramitacao in object%}

7
sapl/templates/sessao/sessaoplenaria_filter.html

@ -53,6 +53,13 @@
{% if s.upload_ata %}
<strong><a href="{{s.upload_ata.url}}">Ata da Sessão</strong></a></br>
{% endif %}
{% if s.upload_pauta %}
<strong><a href="{{s.upload_pauta.url}}">Pauta da Sessão</strong></a></br>
{% endif %}
{% if s.upload_anexo %}
<strong><a href="{{s.upload_anexo.url}}">Anexo da Sessão</strong></a></br>
{% endif %}
<strong><a href="{% url 'sapl.sessao:resumo' s.id %}">Votações Nominais</strong></a></br>
</td>
</tr>
{% endfor %}

9
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 = (

Loading…
Cancel
Save