diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b3a1102eb..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,145 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -SAPL (Sistema de Apoio ao Processo Legislativo) is a Django-based legislative management system used by Brazilian municipal and state legislative houses. It manages bills, parliamentary sessions, committees, norms, protocols, and related legislative workflows. - -## Commands - -### Development - -```bash -# Run dev server -python manage.py runserver - -# Docker (dev, without bundled DB) -docker-compose -f docker/docker-compose-dev.yml up - -# Docker (dev, with PostgreSQL container) -docker-compose -f docker/docker-compose-dev-db.yml up -``` - -### Database Setup (local PostgreSQL) - -```bash -sudo -u postgres psql -c "CREATE ROLE sapl LOGIN ENCRYPTED PASSWORD 'sapl' NOSUPERUSER INHERIT CREATEDB NOCREATEROLE NOREPLICATION;" -sudo -u postgres psql -c "CREATE DATABASE sapl WITH OWNER=sapl ENCODING='UTF8' LC_COLLATE='pt_BR.UTF-8' LC_CTYPE='pt_BR.UTF-8' CONNECTION LIMIT=-1 TEMPLATE template0;" -python manage.py migrate -``` - -### Testing - -```bash -# All tests (reuses DB by default for speed) -pytest - -# Single test file or test function -pytest sapl/materia/tests/test_materia.py -pytest sapl/materia/tests/test_materia.py::test_function_name - -# Force DB recreation -pytest --create-db - -# With coverage -pytest --cov=sapl -``` - -Tests require `DJANGO_SETTINGS_MODULE=sapl.settings` (set in `pytest.ini`). All tests must be marked with `@pytest.mark.django_db`. The `conftest.py` root fixture provides an `app` fixture (WebTest `DjangoTestApp`). - -### Linting / Formatting - -```bash -flake8 . -isort . -autopep8 --in-place -``` - -### Restore Database from Backup - -```bash -./scripts/restore_db.sh -f /path/to/dump -./scripts/restore_db.sh -f /path/to/dump -p 5433 # Docker port -``` - -## Architecture - -### Django Apps - -Apps are under `sapl/` and follow domain boundaries: - -| App | Domain | -|-----|--------| -| `base` | `CasaLegislativa` (legislative house config), `AppConfig`, `Autor` (authorship) | -| `parliamentary` | `Parlamentar`, `Legislatura`, `SessaoLegislativa`, `Coligacao` | -| `materia` | Bills (`MateriaLegislativa`), types, tracking, annexes | -| `norma` | Laws/norms (`NormaJuridica`) and hierarchies | -| `sessao` | Plenary sessions, agenda, attendance, voting | -| `comissoes` | Committees (`Comissao`) and meetings (`Reuniao`) | -| `protocoloadm` | Administrative protocols and document intake | -| `compilacao` | Structured/articulated texts (LexML-like tree structure) | -| `lexml` | LexML XML standard integration | -| `audiencia` | Public hearings | -| `painel` | Real-time session display panel | -| `relatorios` | PDF report generation | -| `api` | REST API entry point (auto-generated ViewSets) | -| `crud` | Generic CRUD base views | -| `rules` | Business rules and permission definitions | - -### REST API - -The API uses a custom `drfautoapi` package (`drfautoapi/drfautoapi.py`) that auto-generates DRF ViewSets, Serializers, and FilterSets from Django models. Authentication is Token + Session. Permissions use a custom `SaplModelPermissions` class that maps HTTP methods to Django model permissions. - -OpenAPI 3.0 docs are generated by drf-spectacular. - -### Caching - -- **Default:** File-based (`/var/tmp/django_cache`) -- **Production:** Redis via django-redis; configured at startup by `configure_redis_cache()` in `sapl/settings.py` -- **Cache key prefix:** `cache:{POD_NAMESPACE}:` (namespace-isolated for multi-tenant k8s) -- **Rate limiter state** is shared via Redis keys - -### Feature Flags - -django-waffle is used for feature flags. Switches (global on/off) can be toggled via: - -```bash -python manage.py waffle_switch on|off -``` - -### Key Environment Variables - -| Variable | Purpose | -|----------|---------| -| `DATABASE_URL` | PostgreSQL connection string | -| `SECRET_KEY` | Django secret key | -| `DEBUG` | Debug mode | -| `REDIS_URL` | Redis host:port | -| `CACHE_BACKEND` | `file` or `redis` | -| `POD_NAMESPACE` | K8s namespace (used in cache key prefix) | -| `USE_SOLR` | Enable Haystack/Solr full-text search | -| `SOLR_URL` / `SOLR_COLLECTION` | Solr connection | - -### Docker Build - -The production build requires a MaxMind GeoLite2-ASN license key (for nginx ASN-based bot blocking): - -```bash -docker build --secret id=maxmind_key,src=.env -f docker/Dockerfile -t sapl:local . -``` - -Optional build args: `WITH_NGINX`, `WITH_GRAPHVIZ`, `WITH_POPPLER`, `WITH_PSQL_CLIENT`. - -### Key File Locations - -| File | Purpose | -|------|---------| -| `sapl/settings.py` | All Django settings, including cache/rate-limit setup | -| `pytest.ini` | Test configuration (DJANGO_SETTINGS_MODULE, addopts) | -| `conftest.py` | Root pytest fixtures | -| `drfautoapi/drfautoapi.py` | Auto-API generation logic | -| `docker/startup_scripts/start.sh` | Container entrypoint (migrations, waffle, gunicorn) | -| `requirements/requirements.txt` | Production deps | -| `requirements/test-requirements.txt` | Test deps | -| `requirements/dev-requirements.txt` | Dev/lint deps | diff --git a/frontend/src/__apps/compilacao/js/old/compilacao.js b/frontend/src/__apps/compilacao/js/old/compilacao.js index f7176c473..8853d6425 100644 --- a/frontend/src/__apps/compilacao/js/old/compilacao.js +++ b/frontend/src/__apps/compilacao/js/old/compilacao.js @@ -279,6 +279,69 @@ function DispositivoSearch (opts) { }) } +// Escala de leitura do texto articulado, preferência de cada navegador. +// Aplicada via variável CSS (--cp-font-scale) para não alterar o conteúdo +// dos dispositivos e para valer também nos que são recarregados via ajax. +const EscalaFonte = Object.freeze({ + CHAVE: 'compilacao_escala_fonte', + PADRAO: 1, + PASSO: 0.1, + MIN: 0.8, + MAX: 2.5 +}) +let escalaFonteAtual = null + +// Limita num ao intervalo [lower, upper]. +function clamp (num, lower, upper) { + return Math.max(lower, Math.min(num, upper)) +} + +function lerEscalaFonte () { + if (escalaFonteAtual !== null) { + return escalaFonteAtual + } + let valor = null + try { + valor = window.localStorage.getItem(EscalaFonte.CHAVE) + } catch (e) { + // localStorage indisponível (ex.: navegação privada bloqueada) + console.warn('Escala de fonte: não foi possível ler o localStorage.', e) + return EscalaFonte.PADRAO + } + if (valor === null) { + return EscalaFonte.PADRAO + } + const escala = parseFloat(valor) + if (!Number.isFinite(escala)) { + console.warn('Escala de fonte: valor inválido no localStorage, usando o padrão.', valor) + return EscalaFonte.PADRAO + } + return clamp(escala, EscalaFonte.MIN, EscalaFonte.MAX) +} + +function aplicarEscalaFonte (escala) { + document.documentElement.style.setProperty('--cp-font-scale', escala) + if (window.tinymce) { + window.tinymce.get().forEach(function (editor) { + if (editor.getBody()) { + editor.getBody().style.fontSize = (escala * 100) + '%' + } + }) + } +} + +function salvarEscalaFonte (escala) { + escala = Math.round(clamp(escala, EscalaFonte.MIN, EscalaFonte.MAX) * 10) / 10 + try { + window.localStorage.setItem(EscalaFonte.CHAVE, escala) + } catch (e) { + // localStorage indisponível: a escala vale apenas até recarregar a página + console.warn('Escala de fonte: não foi possível salvar no localStorage.', escala, e) + } + escalaFonteAtual = escala + aplicarEscalaFonte(escala) +} + function InitViewTAs () { setTimeout(function () { var href = location.href.split('#') @@ -297,11 +360,12 @@ function InitViewTAs () { } }, 100) + aplicarEscalaFonte(lerEscalaFonte()) $('#btn_font_menos').click(function () { - $('.dpt').css('font-size', '-=1') + salvarEscalaFonte(lerEscalaFonte() - EscalaFonte.PASSO) }) $('#btn_font_mais').click(function () { - $('.dpt').css('font-size', '+=1') + salvarEscalaFonte(lerEscalaFonte() + EscalaFonte.PASSO) }) $('.dpt.bloco_alteracao .dpt').each(function () { @@ -327,5 +391,7 @@ export default { ReadCookie, insertWaitAjax, InitViewTAs, + lerEscalaFonte, + aplicarEscalaFonte, DispositivoSearch } diff --git a/frontend/src/__apps/compilacao/js/old/compilacao_edit.js b/frontend/src/__apps/compilacao/js/old/compilacao_edit.js index ba651188b..27060135b 100644 --- a/frontend/src/__apps/compilacao/js/old/compilacao_edit.js +++ b/frontend/src/__apps/compilacao/js/old/compilacao_edit.js @@ -244,7 +244,9 @@ window.DispositivoEdit = function () { if (editortype !== 'construct') { dpt_form.html(data) if (editortype === 'tinymce') { - window.initTextRichEditor(null, false, false) + window.initTextRichEditor(null, false, false).then(function () { + window.aplicarEscalaFonte(window.lerEscalaFonte()) + }) } // OptionalCustomFrontEnd().init() } diff --git a/frontend/src/__apps/compilacao/scss/compilacao.scss b/frontend/src/__apps/compilacao/scss/compilacao.scss index 82c52f7f6..842436b24 100644 --- a/frontend/src/__apps/compilacao/scss/compilacao.scss +++ b/frontend/src/__apps/compilacao/scss/compilacao.scss @@ -164,12 +164,21 @@ a:link:after, a:visited:after { } .cp { + /* --cp-font-scale: escala de leitura escolhida pelo usuário (a/A) */ + font-size: calc(100% * var(--cp-font-scale, 1)); + + .dpt-form textarea.form-control { + font-size: calc(1rem * var(--cp-font-scale, 1)); + } + .vigencia-active { margin-top: 30px; display: block; } .cp-linha-vigencias { + /* navegação entre vigências não acompanha a escala de leitura */ + font-size: calc(100% / var(--cp-font-scale, 1)); display: flex; flex-direction: row; list-style: none; diff --git a/frontend/src/__global/js/tinymce/index.js b/frontend/src/__global/js/tinymce/index.js index 51eed1efa..ee20a7089 100644 --- a/frontend/src/__global/js/tinymce/index.js +++ b/frontend/src/__global/js/tinymce/index.js @@ -14,7 +14,23 @@ import 'tinymce/plugins/table' import './langs/pt_BR.js' window.tinymce = tinymce -window.initTextRichEditor = function (elements, readonly = false, paste_as_text = false) { +// Títulos (h1-h6) vindos de estilos de título do Word ou de páginas web +// chegam em negrito e não podem ser desfeitos pelo botão de negrito nem +// por "remover formatação". Converte-os em parágrafos, mantendo o alinhamento. +function colarTitulosComoParagrafo (editor, args) { + args.node.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(function (titulo) { + const paragrafo = document.createElement('p') + if (titulo.style.textAlign) { + paragrafo.style.textAlign = titulo.style.textAlign + } + while (titulo.firstChild) { + paragrafo.appendChild(titulo.firstChild) + } + titulo.replaceWith(paragrafo) + }) +} + +window.initTextRichEditor = function (elements, readonly = false, paste_as_text = false, paste_titulos_como_paragrafo = false) { const configTinymce = { selector: elements === null || elements === undefined ? 'textarea' : elements, language: 'pt_BR', @@ -22,14 +38,17 @@ window.initTextRichEditor = function (elements, readonly = false, paste_as_text forced_root_block: 'p', paste_as_text, plugins: 'table lists advlist link code', - toolbar: 'undo redo | styleselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | code | removeformat ', + toolbar: 'undo redo | blocks | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link | code | removeformat ', menubar: 'file edit view insert format table', license_key: 'gpl' } + if (paste_titulos_como_paragrafo) { + configTinymce.paste_postprocess = colarTitulosComoParagrafo + } if (readonly) { configTinymce.readonly = 1 configTinymce.menubar = false configTinymce.toolbar = false } - window.tinymce.init(configTinymce) + return window.tinymce.init(configTinymce) } 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/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/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/materia/migrations/0088_fix_view_materiaemtramitacao.py b/sapl/materia/migrations/0088_fix_view_materiaemtramitacao.py index 019d2f788..fc965fcc3 100644 --- a/sapl/materia/migrations/0088_fix_view_materiaemtramitacao.py +++ b/sapl/materia/migrations/0088_fix_view_materiaemtramitacao.py @@ -44,6 +44,27 @@ class Migration(migrations.Migration): migrations.RunSQL(sql=_NEW_VIEW, reverse_sql=_OLD_VIEW), migrations.SeparateDatabaseAndState( database_operations=[ + # Um CREATE INDEX CONCURRENTLY interrompido deixa o índice + # criado porém inválido, e o IF NOT EXISTS abaixo o pularia + # na próxima execução. DROP INDEX sem CONCURRENTLY porque + # este não roda dentro de um bloco DO. + migrations.RunSQL( + sql=""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_index i + JOIN pg_class c ON c.oid = i.indexrelid + WHERE c.relname = 'tram_materia_id_desc' + AND NOT i.indisvalid + ) THEN + DROP INDEX tram_materia_id_desc; + END IF; + END $$; + """, + reverse_sql=migrations.RunSQL.noop, + ), migrations.RunSQL( sql=""" CREATE INDEX CONCURRENTLY IF NOT EXISTS 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/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 e6ac99e4b..3fbb889b4 100755 --- a/sapl/relatorios/views.py +++ b/sapl/relatorios/views.py @@ -49,8 +49,7 @@ from sapl.sessao.views import (get_identificacao_basica, get_mesa_diretora, get_oradores_ordemdia, get_oradores_explicacoes_pessoais, get_consideracoes_finais, get_ocorrencias_da_sessao, get_assinaturas, - get_correspondencias, get_conteudo_multimidia, - get_votos_nominais) + get_correspondencias) from sapl.settings import MEDIA_URL, RATE_LIMITER_RATE from sapl.settings import STATIC_ROOT from sapl.utils import LISTA_DE_UFS, TrocaTag, filiacao_data, create_barcode, show_results_filter_set, \ @@ -561,9 +560,9 @@ def get_sessao_plenaria(sessao, casa, user): .order_by('cargo_id'): # Antes usava Filiacao...first() (filiação mais recente), enquanto a # tela usava .last() (a mais antiga) — e as listas de presença deste - # mesmo PDF já usavam filiacao_data. Unifica no critério correto: o - # partido vigente na data da sessão. - sigla = filiacao_data(composicao.parlamentar, data_sessao) + # mesmo PDF já usavam filiacao_data. Unifica no critério das listas + # de presença e do Resumo: o partido vigente em data_inicio. + sigla = filiacao_data(composicao.parlamentar, sessao.data_inicio) lst_mesa.append({ 'nom_parlamentar': composicao.parlamentar.nome_parlamentar, 'sgl_partido': sigla, @@ -719,7 +718,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 = [] @@ -744,7 +743,7 @@ def get_sessao_plenaria(sessao, casa, user): "num_ordem": orador_expediente.numero_ordem, "nom_parlamentar": parlamentar.nome_parlamentar, "observacao": orador_expediente.observacao, - "sgl_partido": filiacao_data(parlamentar, data_sessao) + "sgl_partido": filiacao_data(parlamentar, sessao.data_inicio) }) # Lista presença na ordem do dia @@ -830,7 +829,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 = [] @@ -860,7 +859,7 @@ def get_sessao_plenaria(sessao, casa, user): "num_ordem": orador_ordemdia.numero_ordem, "nome_parlamentar": parlamentar_orador.nome_parlamentar, "observacao": orador_ordemdia.observacao, - "sigla": filiacao_data(parlamentar_orador, data_sessao) + "sigla": filiacao_data(parlamentar_orador, sessao.data_inicio) }) # Lista dos oradores nas Explicações Pessoais @@ -871,7 +870,7 @@ def get_sessao_plenaria(sessao, casa, user): "num_ordem": orador.numero_ordem, "nom_parlamentar": parlamentar.nome_parlamentar, "observacao": orador.observacao, - "sgl_partido": filiacao_data(parlamentar, data_sessao) + "sgl_partido": filiacao_data(parlamentar, sessao.data_inicio) }) # Ocorrências da Sessão @@ -1447,9 +1446,6 @@ def resumo_ata_pdf(request, pk): context = {} context.update(get_identificacao_basica(sessao_plenaria)) - # Sem cont_mult / votos nominais, esses blocos saíam vazios no PDF quando - # a ResumoOrdenacao os incluía, divergindo da ata exibida em tela. - context.update(get_conteudo_multimidia(sessao_plenaria)) context.update(get_mesa_diretora(sessao_plenaria)) context.update(get_presenca_sessao(sessao_plenaria)) context.update(get_correspondencias(sessao_plenaria, request.user)) @@ -1466,10 +1462,6 @@ def resumo_ata_pdf(request, pk): sessao_plenaria, mesa=context['mesa'], presenca_ordem=context['presenca_ordem'])) - context.update({'votos_nominais_materia_expediente': get_votos_nominais( - sessao_plenaria.id, ExpedienteMateria, 'expediente')}) - context.update({'votos_nominais_materia_ordem_dia': get_votos_nominais( - sessao_plenaria.id, OrdemDia, 'ordem')}) context.update({'object': sessao_plenaria}) context.update({'data': dt.today().strftime('%d/%m/%Y')}) context.update({'rodape': rodape}) @@ -1842,7 +1834,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'), diff --git a/sapl/sessao/models.py b/sapl/sessao/models.py index fc418b08f..f86f2f414 100644 --- a/sapl/sessao/models.py +++ b/sapl/sessao/models.py @@ -157,8 +157,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 @@ -170,7 +174,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): diff --git a/sapl/sessao/tests/test_sessao_view.py b/sapl/sessao/tests/test_sessao_view.py index aef74c2ae..9e68953a6 100644 --- a/sapl/sessao/tests/test_sessao_view.py +++ b/sapl/sessao/tests/test_sessao_view.py @@ -1,9 +1,10 @@ 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 @@ -155,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()) @@ -172,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()) @@ -185,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'] @@ -200,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'] @@ -211,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})) @@ -221,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})) diff --git a/sapl/sessao/views.py b/sapl/sessao/views.py index b4abad2f4..7ca376d55 100755 --- a/sapl/sessao/views.py +++ b/sapl/sessao/views.py @@ -260,8 +260,6 @@ def customize_link_materia(context, pk, has_permission, is_expediente): turno = t[1] break - materia_em_tramitacao = materia._met_prefetch[0] if materia._met_prefetch else None - # idUnica para cada materia idAutor = "autor" + str(i) idAutores = "autores" + str(i) @@ -287,9 +285,17 @@ def customize_link_materia(context, pk, has_permission, is_expediente): # url em toda a string de title_materia context['rows'][i][1] = (title_materia, None) - exist_resultado = bool(obj._votacao_prefetch) - exist_retirada = bool(obj._retirada_prefetch) - exist_leitura = bool(obj._leitura_prefetch) + # A matéria do item pode ter sido trocada depois de votada: só contam + # os registros da matéria atual. + votacoes = [r for r in obj._votacao_prefetch + if r.materia_id == obj.materia_id] + retiradas = [r for r in obj._retirada_prefetch + if r.materia_id == obj.materia_id] + leituras = [r for r in obj._leitura_prefetch + if r.materia_id == obj.materia_id] + exist_resultado = bool(votacoes) + exist_retirada = bool(retiradas) + exist_leitura = bool(leituras) if (obj.tipo_votacao != LEITURA and not exist_resultado and not exist_retirada) or \ (obj.tipo_votacao == LEITURA and not exist_leitura): @@ -411,7 +417,7 @@ def customize_link_materia(context, pk, has_permission, is_expediente): resultado = '''Não há resultado''' elif exist_retirada: - retirada = obj._retirada_prefetch[-1] + retirada = retiradas[-1] retirada_descricao = retirada.tipo_de_retirada.descricao retirada_observacao = retirada.observacao url = reverse('sapl.sessao:retiradapauta_detail', @@ -423,11 +429,11 @@ def customize_link_materia(context, pk, has_permission, is_expediente): else: if obj.tipo_votacao == LEITURA: - resultado = obj._leitura_prefetch[-1] + resultado = leituras[-1] resultado_descricao = "Matéria lida" resultado_observacao = resultado.observacao else: - resultado = obj._votacao_prefetch[-1] + resultado = votacoes[-1] resultado_descricao = resultado.tipo_resultado_votacao.nome resultado_observacao = resultado.observacao @@ -833,57 +839,19 @@ class MateriaOrdemDiaCrud(MasterDetailCrud): layout_key = 'OrdemDiaDetail' class ListView(MasterDetailCrud.ListView): - paginate_by = 100 + paginate_by = None ordering = ['numero_ordem', 'materia', 'resultado'] def get_context_data(self, **kwargs): + self.paginate_by = 50 if self.object_list.count() > 500 else None 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) def get_queryset(self): - return super().get_queryset().select_related( - 'materia', 'materia__tipo', 'sessao_plenaria', - ).prefetch_related( - Prefetch( - 'materia__materiaemtramitacao_set', - to_attr='_met_prefetch', - ), - Prefetch( - 'materia__numeracao_set', - to_attr='_numeracao_prefetch', - ), - Prefetch( - 'materia__autoria_set', - queryset=Autoria.objects.select_related('autor'), - to_attr='_autoria_prefetch', - ), - Prefetch( - 'materia__tramitacao_set', - queryset=Tramitacao.objects.filter( - turno__isnull=False, - ).exclude(turno='').order_by('-data_tramitacao', '-id'), - to_attr='_tramitacao_prefetch', - ), - Prefetch( - 'registrovotacao_set', - queryset=RegistroVotacao.objects.select_related( - 'tipo_resultado_votacao', - ), - to_attr='_votacao_prefetch', - ), - Prefetch( - 'retiradapauta_set', - queryset=RetiradaPauta.objects.select_related( - 'tipo_de_retirada', - ), - to_attr='_retirada_prefetch', - ), - Prefetch( - 'registroleitura_set', - to_attr='_leitura_prefetch', - ), - ) + return prefetch_materias_sessao( + super().get_queryset().select_related('sessao_plenaria'), + com_situacao=False) def recuperar_materia(request): @@ -943,10 +911,11 @@ class ExpedienteMateriaCrud(MasterDetailCrud): 'resultado'] class ListView(MasterDetailCrud.ListView): - paginate_by = 100 + paginate_by = None ordering = ['numero_ordem', 'materia', 'resultado'] def get_context_data(self, **kwargs): + self.paginate_by = 50 if self.object_list.count() > 500 else None context = super().get_context_data(**kwargs) if self.request.GET.get('page'): context['page'] = self.request.GET.get('page') @@ -954,48 +923,9 @@ class ExpedienteMateriaCrud(MasterDetailCrud): return customize_link_materia(context, self.kwargs['pk'], has_permition, True) def get_queryset(self): - return super().get_queryset().select_related( - 'materia', 'materia__tipo', 'sessao_plenaria', - ).prefetch_related( - Prefetch( - 'materia__materiaemtramitacao_set', - to_attr='_met_prefetch', - ), - Prefetch( - 'materia__numeracao_set', - to_attr='_numeracao_prefetch', - ), - Prefetch( - 'materia__autoria_set', - queryset=Autoria.objects.select_related('autor'), - to_attr='_autoria_prefetch', - ), - Prefetch( - 'materia__tramitacao_set', - queryset=Tramitacao.objects.filter( - turno__isnull=False, - ).exclude(turno='').order_by('-data_tramitacao', '-id'), - to_attr='_tramitacao_prefetch', - ), - Prefetch( - 'registrovotacao_set', - queryset=RegistroVotacao.objects.select_related( - 'tipo_resultado_votacao', - ), - to_attr='_votacao_prefetch', - ), - Prefetch( - 'retiradapauta_set', - queryset=RetiradaPauta.objects.select_related( - 'tipo_de_retirada', - ), - to_attr='_retirada_prefetch', - ), - Prefetch( - 'registroleitura_set', - to_attr='_leitura_prefetch', - ), - ) + return prefetch_materias_sessao( + super().get_queryset().select_related('sessao_plenaria'), + com_situacao=False) class CreateView(MasterDetailCrud.CreateView): form_class = ExpedienteMateriaForm @@ -2147,25 +2077,24 @@ def get_expedientes(sessao_plenaria): return ({'expedientes': expedientes}) -def prefetch_materias_sessao(qs): - """Prefetches compartilhados por `get_materias_expediente` e - `get_materias_ordem_do_dia`. +def prefetch_materias_sessao(qs, com_situacao=True): + """Prefetches compartilhados por `get_materias_expediente`, + `get_materias_ordem_do_dia` e pelas ListViews de OrdemDia e + ExpedienteMateria. Os querysets dos `Prefetch` não recebem `order_by` explícito justamente para herdarem o `Meta.ordering` de cada model — é dele que dependem os `.first()` / `.last()` que este código substitui por indexação de lista. + + `com_situacao=False` dispensa o prefetch de `MateriaEmTramitacao`, que + as ListViews não usam. """ - return qs.select_related('materia', 'materia__tipo').prefetch_related( + prefetches = [ Prefetch( 'materia__tramitacao_set', queryset=Tramitacao.objects.exclude(turno='').order_by( '-data_tramitacao', '-id'), to_attr='_tramitacao_prefetch'), - Prefetch( - 'materia__materiaemtramitacao_set', - queryset=MateriaEmTramitacao.objects.select_related( - 'tramitacao', 'tramitacao__status'), - to_attr='_met_prefetch'), Prefetch('materia__numeracao_set', to_attr='_numeracao_prefetch'), Prefetch( 'materia__autoria_set', @@ -2181,7 +2110,15 @@ def prefetch_materias_sessao(qs): queryset=RetiradaPauta.objects.select_related('tipo_de_retirada'), to_attr='_retirada_prefetch'), Prefetch('registroleitura_set', to_attr='_leitura_prefetch'), - ) + ] + if com_situacao: + prefetches.append(Prefetch( + 'materia__materiaemtramitacao_set', + queryset=MateriaEmTramitacao.objects.select_related( + 'tramitacao', 'tramitacao__status'), + to_attr='_met_prefetch')) + return qs.select_related('materia', 'materia__tipo').prefetch_related( + *prefetches) def agrupa_votos_por_materia(campo, ids): @@ -2521,7 +2458,7 @@ def get_votos_nominais(sessao_plenaria_id, model, campo): """ materias = list(model.objects.filter( sessao_plenaria_id=sessao_plenaria_id, tipo_votacao=2 - ).select_related('materia').order_by('-materia')) + ).select_related('materia').order_by('numero_ordem')) votos_por_materia = defaultdict(list) if materias: 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 ea58d1eb2..67f4f4e6b 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 = (