diff --git a/.gitignore b/.gitignore index dfdeb2b5c..c656b3b8f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ # Nodejs node_modules/ +yarn.lock # Distribution / packaging .Python diff --git a/.travis.yml b/.travis.yml index f54ee6d20..80532c789 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,6 @@ install: - pip install -r requirements/test-requirements.txt before_script: - - npm install -g bower - cp sapl/.env_test sapl/.env - psql -c "CREATE USER sapl WITH PASSWORD 'sapl'" -U postgres; - psql -c "CREATE DATABASE sapl OWNER sapl;" -U postgres @@ -18,7 +17,6 @@ before_script: script: - ./manage.py migrate - - ./manage.py bower install - py.test --create-db # - ./scripts/django/test_and_check_qa.sh diff --git a/Dockerfile b/Dockerfile index f4953de84..584370e50 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM alpine:3.8 ENV BUILD_PACKAGES postgresql-dev graphviz-dev graphviz build-base git pkgconfig \ python3-dev libxml2-dev jpeg-dev libressl-dev libffi-dev libxslt-dev \ - nodejs npm py3-lxml py3-magic postgresql-client poppler-utils antiword \ + nodejs py3-lxml py3-magic postgresql-client poppler-utils antiword \ curl jq openssh-client vim openssh-client bash RUN apk update --update-cache && apk upgrade @@ -17,9 +17,7 @@ RUN apk add --no-cache python3 nginx tzdata && \ rm -f /etc/nginx/conf.d/* RUN mkdir -p /var/interlegis/sapl && \ - apk add --update --no-cache $BUILD_PACKAGES && \ - npm install -g bower && \ - npm cache verify + apk add --update --no-cache $BUILD_PACKAGES WORKDIR /var/interlegis/sapl/ @@ -37,13 +35,6 @@ COPY config/env_dockerfile /var/interlegis/sapl/sapl/.env # Configura timezone para BRT # RUN cp /usr/share/zoneinfo/America/Sao_Paulo /etc/localtime && echo "America/Sao_Paulo" > /etc/timezone -# manage.py bower install bug: https://github.com/nvbn/django-bower/issues/51 - -# compilescss - Precompile all occurrences of your SASS/SCSS files for the whole project into css files - -RUN python3 manage.py bower_install --allow-root && \ - python3 manage.py compilescss - RUN python3 manage.py collectstatic --noinput --clear # Remove .env(fake) e sapl.db da imagem @@ -53,7 +44,8 @@ RUN rm -rf /var/interlegis/sapl/sapl/.env && \ RUN chmod +x /var/interlegis/sapl/start.sh && \ ln -sf /dev/stdout /var/log/nginx/access.log && \ ln -sf /dev/stderr /var/log/nginx/error.log && \ - mkdir /var/log/sapl/ + mkdir /var/log/sapl/ && touch /var/log/sapl/sapl.log && \ + ln -s /var/interlegis/sapl/sapl.log /var/log/sapl/sapl.log VOLUME ["/var/interlegis/sapl/data", "/var/interlegis/sapl/media"] diff --git a/docker-compose.yml b/docker-compose.yml index 5b0ee2315..ab841b87b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ sapldb: ports: - "5432:5432" sapl: - image: interlegis/sapl:3.1.142 + image: interlegis/sapl:3.1.143 restart: always environment: ADMIN_PASSWORD: interlegis diff --git a/docs/instalacao31.rst b/docs/instalacao31.rst index 6e9146ee4..a9752990f 100644 --- a/docs/instalacao31.rst +++ b/docs/instalacao31.rst @@ -28,15 +28,7 @@ Instalar as seguintes dependências do sistema:: pkg-config postgresql postgresql-contrib pgadmin3 python-psycopg2 \ software-properties-common build-essential libxml2-dev libjpeg-dev \ libmysqlclient-dev libssl-dev libffi-dev libxslt1-dev python3-setuptools \ - python3-pip curl poppler-utils antiword default-jre python3-venv - - sudo -i - curl -sL https://deb.nodesource.com/setup_8.x | bash - - exit - sudo apt-get install nodejs - - sudo npm install npm -g - sudo npm install -g bower + python3-pip poppler-utils antiword default-jre python3-venv Instalar o virtualenv usando python 3 para o projeto. ----------------------------------------------------- @@ -153,6 +145,8 @@ Criação da `SECRET_KEY =1.11,<2.0 -django-bootstrap3==11.0.0 +django>=1.11.19,<2.0 django-haystack==2.8.1 django-filter==2.0.0 djangorestframework==3.9.0 dj-database-url==0.5.0 -django-bower==5.2.0 django-braces==1.9.0 django-crispy-forms==1.7.2 django-floppyforms==1.7.0 django-extra-views==0.12.0 django-model-utils==3.1.2 -django-sass-processor==0.7.2 django-reversion==3.0.2 django-reversion-compare==0.8.6 django-speedinfo==1.4.0 django-extensions==2.1.4 django-image-cropping==1.2 +django-webpack-loader==0.6.0 +drf-yasg==1.13.0 easy-thumbnails==2.5 -libsass==0.17.0 python-decouple==3.1 psycopg2-binary==2.7.6.1 pyyaml==4.2b1 @@ -32,8 +30,7 @@ textract==1.5.0 pysolr==3.6.0 whoosh==2.7.4 -git+git://github.com/interlegis/trml2pdf.git -git+git://github.com/jasperlittle/django-rest-framework-docs -git+git://github.com/rubgombar1/django-admin-bootstrapped.git +pyoai==2.5.0 -django-compressor==2.2 \ No newline at end of file +git+git://github.com/interlegis/trml2pdf.git +git+git://github.com/interlegis/django-admin-bootstrapped diff --git a/sapl/api/deprecated.py b/sapl/api/deprecated.py new file mode 100644 index 000000000..e38ed3065 --- /dev/null +++ b/sapl/api/deprecated.py @@ -0,0 +1,444 @@ + +import logging + +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q +from django.http import Http404 +from django.utils.translation import ugettext_lazy as _ +from django_filters.rest_framework.backends import DjangoFilterBackend +from rest_framework import serializers +from rest_framework.generics import ListAPIView +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin +from rest_framework.permissions import (IsAuthenticated, + IsAuthenticatedOrReadOnly, AllowAny) +from rest_framework.viewsets import GenericViewSet + +from sapl.api.forms import (AutorChoiceFilterSet, AutoresPossiveisFilterSet, + AutorSearchForFieldFilterSet) +from sapl.api.serializers import ModelChoiceSerializer, AutorSerializer,\ + ChoiceSerializer +from sapl.base.models import TipoAutor, Autor, CasaLegislativa +from sapl.materia.models import MateriaLegislativa +from sapl.sessao.models import SessaoPlenaria, OrdemDia +from sapl.utils import SaplGenericRelation + + +class AutorChoiceSerializer(ModelChoiceSerializer): + + def get_text(self, obj): + return obj.nome + + class Meta: + model = Autor + fields = ['id', 'nome'] + + +class MateriaLegislativaOldSerializer(serializers.ModelSerializer): + + class Meta: + model = MateriaLegislativa + fields = '__all__' + + +class SessaoPlenariaOldSerializer(serializers.ModelSerializer): + + codReuniao = serializers.SerializerMethodField('get_pk_sessao') + codReuniaoPrincipal = serializers.SerializerMethodField('get_pk_sessao') + txtTituloReuniao = serializers.SerializerMethodField('get_name') + txtSiglaOrgao = serializers.SerializerMethodField('get_sigla_orgao') + txtApelido = serializers.SerializerMethodField('get_name') + txtNomeOrgao = serializers.SerializerMethodField('get_nome_orgao') + codEstadoReuniao = serializers.SerializerMethodField( + 'get_estadoSessaoPlenaria') + txtTipoReuniao = serializers.SerializerMethodField('get_tipo_sessao') + txtObjeto = serializers.SerializerMethodField('get_assunto_sessao') + txtLocal = serializers.SerializerMethodField('get_endereco_orgao') + bolReuniaoConjunta = serializers.SerializerMethodField( + 'get_reuniao_conjunta') + bolHabilitarEventoInterativo = serializers.SerializerMethodField( + 'get_iterativo') + idYoutube = serializers.SerializerMethodField('get_url') + codEstadoTransmissaoYoutube = serializers.SerializerMethodField( + 'get_estadoTransmissaoYoutube') + datReuniaoString = serializers.SerializerMethodField('get_date') + + # Constantes SessaoPlenaria (de 1-9) (apenas 3 serão usados) + SESSAO_FINALIZADA = 4 + SESSAO_EM_ANDAMENTO = 3 + SESSAO_CONVOCADA = 2 + + # Constantes EstadoTranmissaoYoutube (de 0 a 2) + TRANSMISSAO_ENCERRADA = 2 + TRANSMISSAO_EM_ANDAMENTO = 1 + SEM_TRANSMISSAO = 0 + + class Meta: + model = SessaoPlenaria + fields = ( + 'codReuniao', + 'codReuniaoPrincipal', + 'txtTituloReuniao', + 'txtSiglaOrgao', + 'txtApelido', + 'txtNomeOrgao', + 'codEstadoReuniao', + 'txtTipoReuniao', + 'txtObjeto', + 'txtLocal', + 'bolReuniaoConjunta', + 'bolHabilitarEventoInterativo', + 'idYoutube', + 'codEstadoTransmissaoYoutube', + 'datReuniaoString' + ) + + def __init__(self, *args, **kwargs): + super(SessaoPlenariaOldSerializer, self).__init__(args, kwargs) + + def get_pk_sessao(self, obj): + return obj.pk + + def get_name(self, obj): + return obj.__str__() + + def get_estadoSessaoPlenaria(self, obj): + if obj.finalizada: + return self.SESSAO_FINALIZADA + elif obj.iniciada: + return self.SESSAO_EM_ANDAMENTO + else: + return self.SESSAO_CONVOCADA + + def get_tipo_sessao(self, obj): + return obj.tipo.__str__() + + def get_url(self, obj): + return obj.url_video if obj.url_video else None + + def get_iterativo(self, obj): + return obj.interativa if obj.interativa else False + + def get_date(self, obj): + return "{} {}{}".format( + obj.data_inicio.strftime("%d/%m/%Y"), + obj.hora_inicio, + ":00" + ) + + def get_estadoTransmissaoYoutube(self, obj): + if obj.url_video: + if obj.finalizada: + return self.TRANSMISSAO_ENCERRADA + else: + return self.TRANSMISSAO_EM_ANDAMENTO + else: + return self.SEM_TRANSMISSAO + + def get_assunto_sessao(self, obj): + pauta_sessao = '' + ordem_dia = OrdemDia.objects.filter(sessao_plenaria=obj.pk) + pauta_sessao = ', '.join([i.materia.__str__() for i in ordem_dia]) + + return str(pauta_sessao) + + def get_endereco_orgao(self, obj): + return self.casa().endereco + + def get_reuniao_conjunta(self, obj): + return False + + def get_sigla_orgao(self, obj): + return self.casa().sigla + + def get_nome_orgao(self, obj): + return self.casa().nome + + def casa(self): + casa = CasaLegislativa.objects.first() + return casa + + +class ModelChoiceView(ListAPIView): + """ + Deprecated + + TODO Migrar para customização na api automática + + """ + + # FIXME aplicar permissão correta de usuário + permission_classes = (IsAuthenticated,) + serializer_class = ModelChoiceSerializer + + def get(self, request, *args, **kwargs): + self.model = ContentType.objects.get_for_id( + self.kwargs['content_type']).model_class() + + pagination = request.GET.get('pagination', '') + + if pagination == 'False': + self.pagination_class = None + + return ListAPIView.get(self, request, *args, **kwargs) + + def get_queryset(self): + return self.model.objects.all() + + +class AutorListView(ListAPIView): + """ + Deprecated + + TODO Migrar para customização na api automática + + Listagem de Autores com filtro para autores já cadastrados + e/ou possíveis autores. + + - tr - tipo do resultado + Prepera Lista de Autores para 2 cenários distintos + + - default = 1 + + = 1 -> para (value, text) usados geralmente + em combobox, radiobox, checkbox, etc com pesquisa básica + de Autores feita pelo django-filter + -> processo usado nas pesquisas, o mais usado. + + + = 3 -> Devolve instancias da classe Autor filtradas pelo + django-filter + + - tipo - chave primária do Tipo de Autor a ser filtrado + + - q - busca textual no nome do Autor ou em fields_search + declarados no field SaplGenericRelation das GenericFks + A busca textual acontece via django-filter com a + variável `tr` igual 1 ou 3. Em caso contrário, + o django-filter é desativado e a busca é feita + no model do ContentType associado ao tipo. + + - q_0 / q_1 - q_0 é opcional e quando usado, faz o código ignorar "q"... + + q_0 -> campos lookup a serem filtrados em qualquer Model + que implemente SaplGenericRelation + q_1 -> o valor que será pesquisado no lookup de q_0 + + q_0 e q_1 podem ser separados por ","... isso dará a + possibilidade de filtrar mais de um campo. + + + http://localhost:8000 + /api/autor?tr=1&q_0=parlamentar_set__ativo&q_1=False + /api/autor?tr=1&q_0=parlamentar_set__ativo&q_1=True + /api/autor?tr=3&q_0=parlamentar_set__ativo&q_1=False + /api/autor?tr=3&q_0=parlamentar_set__ativo&q_1=True + + http://localhost:8000 + /api/autor?tr=1 + &q_0=parlamentar_set__nome_parlamentar__icontains, + parlamentar_set__ativo + &q_1=Carvalho,False + /api/autor?tr=1 + &q_0=parlamentar_set__nome_parlamentar__icontains, + parlamentar_set__ativo + &q_1=Carvalho,True + /api/autor?tr=3 + &q_0=parlamentar_set__nome_parlamentar__icontains, + parlamentar_set__ativo + &q_1=Carvalho,False + /api/autor?tr=3 + &q_0=parlamentar_set__nome_parlamentar__icontains, + parlamentar_set__ativo + &q_1=Carvalho,True + + + não importa o campo que vc passe de qualquer dos Models + ligados... é possível ver que models são esses, + na ocasião do commit deste texto, executando: + In [6]: from sapl.utils import models_with_gr_for_model + + In [7]: models_with_gr_for_model(Autor) + Out[7]: + [sapl.parlamentares.models.Parlamentar, + sapl.parlamentares.models.Frente, + sapl.comissoes.models.Comissao, + sapl.materia.models.Orgao, + sapl.sessao.models.Bancada, + sapl.sessao.models.Bloco] + + qualquer atributo destes models podem ser passados + para busca + """ + logger = logging.getLogger(__name__) + + TR_AUTOR_CHOICE_SERIALIZER = 1 + TR_AUTOR_SERIALIZER = 3 + + permission_classes = (IsAuthenticatedOrReadOnly,) + queryset = Autor.objects.all() + model = Autor + + filter_class = AutorChoiceFilterSet + filter_backends = (DjangoFilterBackend, ) + serializer_class = AutorChoiceSerializer + + @property + def tr(self): + username = self.request.user.username + try: + tr = int(self.request.GET.get + ('tr', AutorListView.TR_AUTOR_CHOICE_SERIALIZER)) + + if tr not in (AutorListView.TR_AUTOR_CHOICE_SERIALIZER, + AutorListView.TR_AUTOR_SERIALIZER): + return AutorListView.TR_AUTOR_CHOICE_SERIALIZER + except Exception as e: + self.logger.error('user=' + username + '. ' + str(e)) + return AutorListView.TR_AUTOR_CHOICE_SERIALIZER + return tr + + def get(self, request, *args, **kwargs): + if self.tr == AutorListView.TR_AUTOR_SERIALIZER: + self.serializer_class = AutorSerializer + self.permission_classes = (IsAuthenticated,) + + if self.filter_class and 'q_0' in request.GET: + self.filter_class = AutorSearchForFieldFilterSet + + return ListAPIView.get(self, request, *args, **kwargs) + + +class AutoresProvaveisListView(ListAPIView): + """ + Deprecated + + TODO Migrar para customização na api automática + """ + + logger = logging.getLogger(__name__) + + permission_classes = (IsAuthenticatedOrReadOnly,) + queryset = Autor.objects.all() + model = Autor + + filter_class = None + filter_backends = [] + serializer_class = ChoiceSerializer + + def get_queryset(self): + + params = {'content_type__isnull': False} + username = self.request.user.username + tipo = '' + try: + tipo = int(self.request.GET.get('tipo', '')) + if tipo: + params['id'] = tipo + except Exception as e: + self.logger.error('user= ' + username + '. ' + str(e)) + pass + + tipos = TipoAutor.objects.filter(**params) + + if not tipos.exists() and tipo: + raise Http404() + + r = [] + for tipo in tipos: + q = self.request.GET.get('q', '').strip() + + model_class = tipo.content_type.model_class() + + fields = list(filter( + lambda field: isinstance(field, SaplGenericRelation) and + field.related_model == Autor, + model_class._meta.get_fields(include_hidden=True))) + + """ + fields - é um array de SaplGenericRelation que deve possuir o + atributo fields_search. Verifique na documentação da classe + a estrutura de fields_search. + """ + + assert len(fields) >= 1, (_( + 'Não foi encontrado em %(model)s um atributo do tipo ' + 'SaplGenericRelation que use o model %(model_autor)s') % { + 'model': model_class._meta.verbose_name, + 'model_autor': Autor._meta.verbose_name}) + + qs = model_class.objects.all() + + q_filter = Q() + if q: + for item in fields: + if item.related_model != Autor: + continue + q_fs = Q() + for field in item.fields_search: + q_fs = q_fs | Q(**{'%s%s' % ( + field[0], + field[1]): q}) + q_filter = q_filter & q_fs + + qs = qs.filter(q_filter).distinct( + fields[0].fields_search[0][0]).order_by( + fields[0].fields_search[0][0]) + else: + qs = qs.order_by(fields[0].fields_search[0][0]) + + qs = qs.values_list( + 'id', fields[0].fields_search[0][0]) + r += list(qs) + + if tipos.count() > 1: + r.sort(key=lambda x: x[1].upper()) + return r + + +class AutoresPossiveisListView(ListAPIView): + """ + Deprecated + + TODO Migrar para customização na api automática + """ + + permission_classes = (IsAuthenticatedOrReadOnly,) + queryset = Autor.objects.all() + model = Autor + + pagination_class = None + + filter_class = AutoresPossiveisFilterSet + serializer_class = AutorChoiceSerializer + + +class MateriaLegislativaViewSet(ListModelMixin, + RetrieveModelMixin, + GenericViewSet): + """ + Deprecated + + TODO Migrar para customização na api automática + """ + + permission_classes = (IsAuthenticated,) + serializer_class = MateriaLegislativaOldSerializer + queryset = MateriaLegislativa.objects.all() + filter_backends = (DjangoFilterBackend,) + filter_fields = ('numero', 'ano', 'tipo', ) + + +class SessaoPlenariaViewSet(ListModelMixin, + RetrieveModelMixin, + GenericViewSet): + """ + Deprecated + + TODO Migrar para customização na api automática + """ + + permission_classes = (AllowAny,) + serializer_class = SessaoPlenariaOldSerializer + queryset = SessaoPlenaria.objects.all() + filter_backends = (DjangoFilterBackend,) + filter_fields = ('data_inicio', 'data_fim', 'interativa') diff --git a/sapl/api/permissions.py b/sapl/api/permissions.py index 5e17d1fe1..b7df6c63a 100644 --- a/sapl/api/permissions.py +++ b/sapl/api/permissions.py @@ -1,7 +1,8 @@ from rest_framework.permissions import DjangoModelPermissions +from sapl.rules.map_rules import rules_patterns_public -class DjangoModelPermissions(DjangoModelPermissions): +class SaplModelPermissions(DjangoModelPermissions): perms_map = { 'GET': ['%(app_label)s.list_%(model_name)s', @@ -10,9 +11,43 @@ class DjangoModelPermissions(DjangoModelPermissions): '%(app_label)s.detail_%(model_name)s'], 'HEAD': ['%(app_label)s.list_%(model_name)s', '%(app_label)s.detail_%(model_name)s'], - 'POST': ['%(app_label)s.list_%(model_name)s'], + 'POST': ['%(app_label)s.add_%(model_name)s'], 'PUT': ['%(app_label)s.change_%(model_name)s'], 'PATCH': ['%(app_label)s.change_%(model_name)s'], 'DELETE': ['%(app_label)s.delete_%(model_name)s'], } + + def has_permission(self, request, view): + if getattr(view, '_ignore_model_permissions', False): + return True + + if hasattr(view, 'get_queryset'): + queryset = view.get_queryset() + else: + queryset = getattr(view, 'queryset', None) + + assert queryset is not None, ( + 'Cannot apply DjangoModelPermissions on a view that ' + 'does not set `.queryset` or have a `.get_queryset()` method.' + ) + + perms = self.get_required_permissions(request.method, queryset.model) + + key = '{}:{}'.format( + queryset.model._meta.app_label, + queryset.model._meta.model_name) + + if key in rules_patterns_public: + perms = set(perms) + perms_publicas = rules_patterns_public[key] + + private_perms = perms - perms_publicas + if not private_perms: + return True + + return ( + request.user and + (request.user.is_authenticated() or not self.authenticated_users_only) and + request.user.has_perms(perms) + ) diff --git a/sapl/api/serializers.py b/sapl/api/serializers.py index 9219bf0e4..f416517c5 100644 --- a/sapl/api/serializers.py +++ b/sapl/api/serializers.py @@ -1,8 +1,6 @@ from rest_framework import serializers -from sapl.base.models import Autor, CasaLegislativa -from sapl.materia.models import MateriaLegislativa -from sapl.sessao.models import OrdemDia, SessaoPlenaria +from sapl.base.models import Autor class ChoiceSerializer(serializers.Serializer): @@ -31,144 +29,12 @@ class ModelChoiceObjectRelatedField(serializers.RelatedField): return ModelChoiceSerializer(value).data -class AutorChoiceSerializer(ModelChoiceSerializer): - - def get_text(self, obj): - return obj.nome - - class Meta: - model = Autor - fields = ['id', 'nome'] - - class AutorSerializer(serializers.ModelSerializer): + # AutorSerializer sendo utilizado pelo gerador automático da api devidos aos + # critérios anotados em views.py + autor_related = ModelChoiceObjectRelatedField(read_only=True) class Meta: model = Autor fields = '__all__' - - -class MateriaLegislativaSerializer(serializers.ModelSerializer): - - class Meta: - model = MateriaLegislativa - fields = '__all__' - - -class SessaoPlenariaSerializer(serializers.ModelSerializer): - - codReuniao = serializers.SerializerMethodField('get_pk_sessao') - codReuniaoPrincipal = serializers.SerializerMethodField('get_pk_sessao') - txtTituloReuniao = serializers.SerializerMethodField('get_name') - txtSiglaOrgao = serializers.SerializerMethodField('get_sigla_orgao') - txtApelido = serializers.SerializerMethodField('get_name') - txtNomeOrgao = serializers.SerializerMethodField('get_nome_orgao') - codEstadoReuniao = serializers.SerializerMethodField( - 'get_estadoSessaoPlenaria') - txtTipoReuniao = serializers.SerializerMethodField('get_tipo_sessao') - txtObjeto = serializers.SerializerMethodField('get_assunto_sessao') - txtLocal = serializers.SerializerMethodField('get_endereco_orgao') - bolReuniaoConjunta = serializers.SerializerMethodField( - 'get_reuniao_conjunta') - bolHabilitarEventoInterativo = serializers.SerializerMethodField( - 'get_iterativo') - idYoutube = serializers.SerializerMethodField('get_url') - codEstadoTransmissaoYoutube = serializers.SerializerMethodField( - 'get_estadoTransmissaoYoutube') - datReuniaoString = serializers.SerializerMethodField('get_date') - - # Constantes SessaoPlenaria (de 1-9) (apenas 3 serão usados) - SESSAO_FINALIZADA = 4 - SESSAO_EM_ANDAMENTO = 3 - SESSAO_CONVOCADA = 2 - - # Constantes EstadoTranmissaoYoutube (de 0 a 2) - TRANSMISSAO_ENCERRADA = 2 - TRANSMISSAO_EM_ANDAMENTO = 1 - SEM_TRANSMISSAO = 0 - - class Meta: - model = SessaoPlenaria - fields = ( - 'codReuniao', - 'codReuniaoPrincipal', - 'txtTituloReuniao', - 'txtSiglaOrgao', - 'txtApelido', - 'txtNomeOrgao', - 'codEstadoReuniao', - 'txtTipoReuniao', - 'txtObjeto', - 'txtLocal', - 'bolReuniaoConjunta', - 'bolHabilitarEventoInterativo', - 'idYoutube', - 'codEstadoTransmissaoYoutube', - 'datReuniaoString' - ) - - def __init__(self, *args, **kwargs): - super(SessaoPlenariaSerializer, self).__init__(args, kwargs) - - def get_pk_sessao(self, obj): - return obj.pk - - def get_name(self, obj): - return obj.__str__() - - def get_estadoSessaoPlenaria(self, obj): - if obj.finalizada: - return self.SESSAO_FINALIZADA - elif obj.iniciada: - return self.SESSAO_EM_ANDAMENTO - else: - return self.SESSAO_CONVOCADA - - def get_tipo_sessao(self, obj): - return obj.tipo.__str__() - - def get_url(self, obj): - return obj.url_video if obj.url_video else None - - def get_iterativo(self, obj): - return obj.interativa if obj.interativa else False - - def get_date(self, obj): - return "{} {}{}".format( - obj.data_inicio.strftime("%d/%m/%Y"), - obj.hora_inicio, - ":00" - ) - - def get_estadoTransmissaoYoutube(self, obj): - if obj.url_video: - if obj.finalizada: - return self.TRANSMISSAO_ENCERRADA - else: - return self.TRANSMISSAO_EM_ANDAMENTO - else: - return self.SEM_TRANSMISSAO - - def get_assunto_sessao(self, obj): - pauta_sessao = '' - ordem_dia = OrdemDia.objects.filter(sessao_plenaria=obj.pk) - pauta_sessao = ', '.join([i.materia.__str__() for i in ordem_dia]) - - return str(pauta_sessao) - - def get_endereco_orgao(self, obj): - return self.casa().endereco - - def get_reuniao_conjunta(self, obj): - return False - - def get_sigla_orgao(self, obj): - return self.casa().sigla - - def get_nome_orgao(self, obj): - return self.casa().nome - - def casa(self): - casa = CasaLegislativa.objects.first() - return casa diff --git a/sapl/api/urls.py b/sapl/api/urls.py index 1f19e9d1f..bdcf0e372 100644 --- a/sapl/api/urls.py +++ b/sapl/api/urls.py @@ -1,23 +1,56 @@ from django.conf import settings from django.conf.urls import include, url +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import permissions from rest_framework.routers import DefaultRouter -from sapl.api.views import (AutoresPossiveisListView, AutoresProvaveisListView, - AutorListView, MateriaLegislativaViewSet, - ModelChoiceView, SessaoPlenariaViewSet) +from sapl.api.deprecated import MateriaLegislativaViewSet, SessaoPlenariaViewSet,\ + AutoresProvaveisListView, AutoresPossiveisListView, AutorListView,\ + ModelChoiceView +from sapl.api.views import SaplSetViews from .apps import AppConfig + app_name = AppConfig.name router = DefaultRouter() -router.register(r'materia', MateriaLegislativaViewSet) +router.register(r'materia$', MateriaLegislativaViewSet) router.register(r'sessao-plenaria', SessaoPlenariaViewSet) + + +for app, built_sets in SaplSetViews.items(): + for view_prefix, viewset in built_sets.items(): + router.register(app + '/' + view_prefix, viewset) + + urlpatterns_router = router.urls + +schema_view = get_schema_view( + openapi.Info( + title="Sapl API - docs", + default_version='v1', + description="Sapl API - Docs - Configuração Básica", + ), + url=settings.SITE_URL, + public=True, + permission_classes=(permissions.AllowAny,), +) + urlpatterns_api = [ + url(r'^docs/swagger(?P\.json|\.yaml)$', + schema_view.without_ui(cache_timeout=0), name='schema-json'), + url(r'^docs/swagger/$', + schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + url(r'^docs/redoc/$', + schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), +] +# TODO: refatorar para customização da api automática +deprecated_urlpatterns_api = [ url(r'^autor/provaveis', AutoresProvaveisListView.as_view(), name='autores_provaveis_list'), url(r'^autor/possiveis', @@ -27,14 +60,14 @@ urlpatterns_api = [ url(r'^model/(?P\d+)/(?P\d*)$', ModelChoiceView.as_view(), name='model_list'), - ] -if settings.DEBUG: - urlpatterns_api += [ - url(r'^docs', include('rest_framework_docs.urls')), ] - urlpatterns = [ + url(r'^api/', include(deprecated_urlpatterns_api)), url(r'^api/', include(urlpatterns_api)), - url(r'^api/', include(urlpatterns_router)) + url(r'^api/', include(urlpatterns_router)), + + # implementar caminho para autenticação + # https://www.django-rest-framework.org/tutorial/4-authentication-and-permissions/ + # url(r'^api/auth/', include('rest_framework.urls', namespace='rest_framework')), ] diff --git a/sapl/api/views.py b/sapl/api/views.py index 336bb23d7..4e88d76ca 100644 --- a/sapl/api/views.py +++ b/sapl/api/views.py @@ -1,279 +1,446 @@ import logging +from django import apps +from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db.models import Q -from django.http import Http404 +from django.db.models.fields.files import FileField +from django.utils.decorators import classonlymethod +from django.utils.text import capfirst from django.utils.translation import ugettext_lazy as _ +import django_filters from django_filters.rest_framework.backends import DjangoFilterBackend -from rest_framework.generics import ListAPIView -from rest_framework.mixins import ListModelMixin, RetrieveModelMixin -from rest_framework.permissions import (AllowAny, IsAuthenticated, - IsAuthenticatedOrReadOnly) -from rest_framework.viewsets import GenericViewSet +from django_filters.rest_framework.filterset import FilterSet +from django_filters.utils import resolve_field +from rest_framework import serializers as rest_serializers +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet -from sapl.api.forms import (AutorChoiceFilterSet, AutoresPossiveisFilterSet, - AutorSearchForFieldFilterSet) -from sapl.api.serializers import (AutorChoiceSerializer, AutorSerializer, - ChoiceSerializer, - MateriaLegislativaSerializer, - ModelChoiceSerializer, - SessaoPlenariaSerializer) -from sapl.base.models import Autor, TipoAutor -from sapl.materia.models import MateriaLegislativa -from sapl.sessao.models import SessaoPlenaria -from sapl.utils import SaplGenericRelation +from sapl.api.permissions import SaplModelPermissions +from sapl.base.models import Autor, AppConfig, DOC_ADM_OSTENSIVO +from sapl.materia.models import Proposicao +from sapl.parlamentares.models import Parlamentar +from sapl.utils import models_with_gr_for_model -class ModelChoiceView(ListAPIView): +class SaplApiViewSetConstrutor(ModelViewSet): - # FIXME aplicar permissão correta de usuário - permission_classes = (IsAuthenticated,) - serializer_class = ModelChoiceSerializer + filter_backends = (DjangoFilterBackend,) - def get(self, request, *args, **kwargs): - self.model = ContentType.objects.get_for_id( - self.kwargs['content_type']).model_class() + @classonlymethod + def build_class(cls): + import inspect + from sapl.api import serializers + + # Carrega todas as classes de sapl.api.serializers que possuam + # "Serializer" como Sufixo. + serializers_classes = inspect.getmembers(serializers) + serializers_classes = {i[0]: i[1] for i in filter( + lambda x: x[0].endswith('Serializer'), + serializers_classes + )} + + # Carrega todas as classes de sapl.api.forms que possuam + # "FilterSet" como Sufixo. + from sapl.api import forms + filters_classes = inspect.getmembers(forms) + filters_classes = {i[0]: i[1] for i in filter( + lambda x: x[0].endswith('FilterSet'), + filters_classes + )} + + built_sets = {} + + def build(_model): + object_name = _model._meta.object_name + + # Caso Exista, pega a classe sapl.api.serializers.{model}Serializer + serializer_name = '{model}Serializer'.format(model=object_name) + _serializer_class = serializers_classes.get(serializer_name, None) + + # Caso Exista, pega a classe sapl.api.forms.{model}FilterSet + filter_name = '{model}FilterSet'.format(model=object_name) + _filter_class = filters_classes.get(filter_name, None) + + def create_class(): + # Define uma classe padrão para serializer caso não tenha sido + # criada a classe sapl.api.serializers.{model}Serializer + class SaplSerializer(rest_serializers.ModelSerializer): + class Meta: + model = _model + fields = '__all__' + + # Define uma classe padrão para filtro caso não tenha sido + # criada a classe sapl.api.forms.{model}FilterSet + class SaplFilterSet(FilterSet): + class Meta: + model = _model + fields = '__all__' + filter_overrides = { + FileField: { + 'filter_class': django_filters.CharFilter, + 'extra': lambda f: { + 'lookup_expr': 'exact', + }, + }, + } + + @classmethod + def filter_for_field(cls, f, name, lookup_expr='exact'): + # Redefine método estático para ignorar filtro para + # fields que não possuam lookup_expr informado + f, lookup_type = resolve_field(f, lookup_expr) + + default = { + 'field_name': name, + 'label': capfirst(f.verbose_name), + 'lookup_expr': lookup_expr + } + + filter_class, params = cls.filter_for_lookup( + f, lookup_type) + default.update(params) + if filter_class is not None: + return filter_class(**default) + return None + + # Define uma classe padrão ModelViewSet de DRF + class ModelSaplViewSet(cls): + queryset = _model.objects.all() + + # Utiliza o filtro customizado pela classe + # sapl.api.forms.{model}FilterSet + # ou utiliza o trivial SaplFilterSet definido acima + filter_class = _filter_class \ + if _filter_class else SaplFilterSet + + # Utiliza o serializer customizado pela classe + # sapl.api.serializers.{model}Serializer + # ou utiliza o trivial SaplSerializer definido acima + serializer_class = _serializer_class \ + if _serializer_class else SaplSerializer + + return ModelSaplViewSet + + viewset = create_class() + viewset.__name__ = '%sModelSaplViewSet' % _model.__name__ + return viewset + + apps_sapl = [apps.apps.get_app_config( + n[5:]) for n in settings.SAPL_APPS] + for app in apps_sapl: + built_sets[app.label] = {} + for model in app.get_models(): + built_sets[app.label][model._meta.model_name] = build(model) + + return built_sets + + +""" +1. Constroi uma rest_framework.viewsets.ModelViewSet para + todos os models de todas as apps do sapl +2. Define DjangoFilterBackend como ferramenta de filtro dos campos +3. Define Serializer como a seguir: + 3.1 - Define um Serializer genérico para cada módel + 3.2 - Recupera Serializer customizado em sapl.api.serializers + 3.3 - Para todo model é opcional a existência de + sapl.api.serializers.{model}Serializer. + Caso não seja definido um Serializer customizado, utiliza-se o trivial +4. Define um FilterSet como a seguir: + 4.1 - Define um FilterSet genérico para cada módel + 4.2 - Recupera FilterSet customizado em sapl.api.forms + 4.3 - Para todo model é opcional a existência de + sapl.api.forms.{model}FilterSet. + Caso não seja definido um FilterSet customizado, utiliza-se o trivial + 4.4 - todos os campos que aceitam lookup 'exact' + podem ser filtrados por default + +5. SaplApiViewSetConstrutor não cria padrões e/ou exige conhecimento alem dos + exigidos pela DRF. + +6. As rotas são criadas seguindo nome da app e nome do model + http://localhost:9000/api/{applabel}/{model_name}/ + e seguem as variações definidas em: + https://www.django-rest-framework.org/api-guide/routers/#defaultrouter + +7. Todas as viewsets construídas por SaplApiViewSetConstrutor e suas rotas + (paginate list, detail, edit, create, delete) + bem como testes em ambiente de desenvolvimento podem ser conferidas em: + http://localhost:9000/api/ + desde que settings.DEBUG=True + +**SaplSetViews** é um dict de dicts de models conforme: + { + ... + + 'audiencia': { + 'tipoaudienciapublica': TipoAudienciaPublicaViewSet, + 'audienciapublica': AudienciaPublicaViewSet, + 'anexoaudienciapublica': AnexoAudienciaPublicaViewSet + + ... + + }, + + ... + + 'base': { + 'casalegislativa': CasaLegislativaViewSet, + 'appconfig': AppConfigViewSet, + + ... + + } + + ... + + } +""" + +SaplSetViews = SaplApiViewSetConstrutor.build_class() + +# Toda Classe construida acima, pode ser redefinida e aplicado quaisquer +# das possibilidades para uma classe normal criada a partir de +# rest_framework.viewsets.ModelViewSet conforme exemplo para a classe autor + + +# Customização para AutorViewSet com implementação de actions específicas +class _AutorViewSet(SaplSetViews['base']['autor']): + """ + Neste exemplo de customização do que foi criado em + SaplApiViewSetConstrutor além do ofertado por + rest_framework.viewsets.ModelViewSet, dentre outras customizações + possíveis, foi adicionado as rotas referentes aos relacionamentos genéricos + + * padrão de ModelViewSet + /api/base/autor/ POST - create + /api/base/autor/ GET - list + /api/base/autor/{pk}/ GET - detail + /api/base/autor/{pk}/ PUT - update + /api/base/autor/{pk}/ PATCH - partial_update + /api/base/autor/{pk}/ DELETE - destroy + + * rotas desta classe local: + /api/base/autor/parlamentar + devolve apenas autores que são parlamentares + /api/base/autor/comissao + devolve apenas autores que são comissões + /api/base/autor/bloco + devolve apenas autores que são blocos parlamentares + /api/base/autor/bancada + devolve apenas autores que são bancadas parlamentares + /api/base/autor/frente + devolve apenas autores que são Frene parlamentares + /api/base/autor/orgao + devolve apenas autores que são Órgãos - pagination = request.GET.get('pagination', '') - if pagination == 'False': - self.pagination_class = None + """ - return ListAPIView.get(self, request, *args, **kwargs) + def list_for_content_type(self, content_type): + qs = self.get_queryset() + qs = qs.filter(content_type=content_type) - def get_queryset(self): - return self.model.objects.all() + page = self.paginate_queryset(qs) + if page is not None: + serializer = self.serializer_class(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(page, many=True) + return Response(serializer.data) + + @classonlymethod + def build_class_with_actions(cls): + + models_with_gr_for_autor = models_with_gr_for_model(Autor) + + for _model in models_with_gr_for_autor: + + @action(detail=False, name=_model._meta.model_name) + def actionclass(self, request, *args, **kwargs): + model = getattr(self, self.action)._AutorViewSet__model + + content_type = ContentType.objects.get_for_model(model) + return self.list_for_content_type(content_type) + + func = actionclass + func.mapping['get'] = func.kwargs['name'] + func.url_name = func.kwargs['name'] + func.url_path = func.kwargs['name'] + func.__model = _model + + setattr(cls, _model._meta.model_name, func) + return cls + + +class _ParlamentarViewSet(SaplSetViews['parlamentares']['parlamentar']): + @action(detail=True) + def proposicoes(self, request, *args, **kwargs): + """ + Lista de proposições públicas de parlamentar específico + + :param int id: - Identificador do parlamentar que se quer recuperar as proposições + :return: uma lista de proposições + """ + # /api/parlamentares/parlamentar/{id}/proposicoes/ + # recupera proposições enviadas e incorporadas do parlamentar + # deve coincidir com + # /parlamentar/{pk}/proposicao + content_type = ContentType.objects.get_for_model(Parlamentar) + + qs = Proposicao.objects.filter( + data_envio__isnull=False, + data_recebimento__isnull=False, + cancelado=False, + autor__object_id=kwargs['pk'], + autor__content_type=content_type + ) + + page = self.paginate_queryset(qs) + if page is not None: + serializer = SaplSetViews[ + 'materia']['proposicao'].serializer_class(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(page, many=True) + return Response(serializer.data) -class AutorListView(ListAPIView): +class _ProposicaoViewSet(SaplSetViews['materia']['proposicao']): """ - Listagem de Autores com filtro para autores já cadastrados - e/ou possíveis autores. - - - tr - tipo do resultado - Prepera Lista de Autores para 3 cenários distintos - - - default = 1 - - = 1 -> para (value, text) usados geralmente - em combobox, radiobox, checkbox, etc com pesquisa básica - de Autores feita pelo django-filter - -> processo usado nas pesquisas, o mais usado. - - - = 3 -> Devolve instancias da classe Autor filtradas pelo - django-filter - - - tipo - chave primária do Tipo de Autor a ser filtrado - - - q - busca textual no nome do Autor ou em fields_search - declarados no field SaplGenericRelation das GenericFks - A busca textual acontece via django-filter com a - variável `tr` igual 1 ou 3. Em caso contrário, - o django-filter é desativado e a busca é feita - no model do ContentType associado ao tipo. - - - q_0 / q_1 - q_0 é opcional e quando usado, faz o código ignorar "q"... - - q_0 -> campos lookup a serem filtrados em qualquer Model - que implemente SaplGenericRelation - q_1 -> o valor que será pesquisado no lookup de q_0 - - q_0 e q_1 podem ser separados por ","... isso dará a - possibilidade de filtrar mais de um campo. - - - http://localhost:8000 - /api/autor?tr=1&q_0=parlamentar_set__ativo&q_1=False - /api/autor?tr=1&q_0=parlamentar_set__ativo&q_1=True - /api/autor?tr=3&q_0=parlamentar_set__ativo&q_1=False - /api/autor?tr=3&q_0=parlamentar_set__ativo&q_1=True - - http://localhost:8000 - /api/autor?tr=1 - &q_0=parlamentar_set__nome_parlamentar__icontains, - parlamentar_set__ativo - &q_1=Carvalho,False - /api/autor?tr=1 - &q_0=parlamentar_set__nome_parlamentar__icontains, - parlamentar_set__ativo - &q_1=Carvalho,True - /api/autor?tr=3 - &q_0=parlamentar_set__nome_parlamentar__icontains, - parlamentar_set__ativo - &q_1=Carvalho,False - /api/autor?tr=3 - &q_0=parlamentar_set__nome_parlamentar__icontains, - parlamentar_set__ativo - &q_1=Carvalho,True - - - não importa o campo que vc passe de qualquer dos Models - ligados... é possível ver que models são esses, - na ocasião do commit deste texto, executando: - In [6]: from sapl.utils import models_with_gr_for_model - - In [7]: models_with_gr_for_model(Autor) - Out[7]: - [sapl.parlamentares.models.Parlamentar, - sapl.parlamentares.models.Frente, - sapl.comissoes.models.Comissao, - sapl.materia.models.Orgao, - sapl.sessao.models.Bancada, - sapl.sessao.models.Bloco] - - qualquer atributo destes models podem ser passados - para busca + list: + Retorna lista de Proposições + + * Permissões: + + * Usuário Dono: + * Pode listar todas suas Proposições + + * Usuário Conectado ou Anônimo: + * Pode listar todas as Proposições incorporadas + + retrieve: + Retorna uma proposição passada pelo 'id' + + * Permissões: + + * Usuário Dono: + * Pode recuperar qualquer de suas Proposições + + * Usuário Conectado ou Anônimo: + * Pode recuperar qualquer das proposições incorporadas + """ - logger = logging.getLogger(__name__) + class ProposicaoPermission(SaplModelPermissions): + def has_permission(self, request, view): + if request.method == 'GET': + return True + # se a solicitação é list ou detail, libera o teste de permissão + # e deixa o get_queryset filtrar de acordo com a regra de + # visibilidade das proposições, ou seja: + # 1. proposição incorporada é proposição pública + # 2. não incorporada só o autor pode ver + else: + perm = super().has_permission(request, view) + return perm + # não é list ou detail, então passa pelas regras de permissão e, + # depois disso ainda passa pelo filtro de get_queryset - TR_AUTOR_CHOICE_SERIALIZER = 1 - TR_AUTOR_SERIALIZER = 3 + permission_classes = (ProposicaoPermission, ) - permission_classes = (IsAuthenticatedOrReadOnly,) - queryset = Autor.objects.all() - model = Autor + def get_queryset(self): + qs = super().get_queryset() + + q = Q(data_recebimento__isnull=False, object_id__isnull=False) + if not self.request.user.is_anonymous(): + q |= Q(autor__user=self.request.user) - filter_class = AutorChoiceFilterSet - filter_backends = (DjangoFilterBackend, ) - serializer_class = AutorChoiceSerializer + qs = qs.filter(q) + return qs - @property - def tr(self): - username = self.request.user.username - try: - tr = int(self.request.GET.get - ('tr', AutorListView.TR_AUTOR_CHOICE_SERIALIZER)) - if tr not in (AutorListView.TR_AUTOR_CHOICE_SERIALIZER, - AutorListView.TR_AUTOR_SERIALIZER): - return AutorListView.TR_AUTOR_CHOICE_SERIALIZER - except Exception as e: - self.logger.error('user=' + username + '. ' + str(e)) - return AutorListView.TR_AUTOR_CHOICE_SERIALIZER - return tr +class _DocumentoAdministrativoViewSet(SaplSetViews['protocoloadm']['documentoadministrativo']): - def get(self, request, *args, **kwargs): - if self.tr == AutorListView.TR_AUTOR_SERIALIZER: - self.serializer_class = AutorSerializer - self.permission_classes = (IsAuthenticated,) + class DocumentoAdministrativoPermission(SaplModelPermissions): + def has_permission(self, request, view): + if request.method == 'GET': + comportamento = AppConfig.attr('documentos_administrativos') + if comportamento == DOC_ADM_OSTENSIVO: + return True + """ + Diante da lógica implementada na manutenção de documentos + administrativos: + - Se o comportamento é doc adm ostensivo, deve passar pelo + teste de permissões sem avaliá-las + - se o comportamento é doc adm restritivo, deve passar pelo + teste de permissões avaliando-as + """ + return super().has_permission(request, view) - if self.filter_class and 'q_0' in request.GET: - self.filter_class = AutorSearchForFieldFilterSet + permission_classes = (DocumentoAdministrativoPermission, ) - return ListAPIView.get(self, request, *args, **kwargs) + def get_queryset(self): + """ + mesmo tendo passado pelo teste de permissões, deve ser filtrado, + pelo campo restrito. Sendo este igual a True, disponibilizar apenas + a um usuário conectado. Apenas isso, sem critérios outros de permissão, + conforme implementado em DocumentoAdministrativoCrud + """ + qs = super().get_queryset() + if self.request.user.is_anonymous(): + qs = qs.exclude(restrito=True) + return qs -class AutoresProvaveisListView(ListAPIView): - logger = logging.getLogger(__name__) - permission_classes = (IsAuthenticatedOrReadOnly,) - queryset = Autor.objects.all() - model = Autor +class _DocumentoAcessorioAdministrativoViewSet( + SaplSetViews['protocoloadm']['documentoacessorioadministrativo']): - filter_class = None - filter_backends = [] - serializer_class = ChoiceSerializer + permission_classes = ( + _DocumentoAdministrativoViewSet.DocumentoAdministrativoPermission, ) def get_queryset(self): + qs = super().get_queryset() - params = {'content_type__isnull': False} - username = self.request.user.username - tipo = '' - try: - tipo = int(self.request.GET.get('tipo', '')) - if tipo: - params['id'] = tipo - except Exception as e: - self.logger.error('user= ' + username + '. ' + str(e)) - pass - - tipos = TipoAutor.objects.filter(**params) - - if not tipos.exists() and tipo: - raise Http404() - - r = [] - for tipo in tipos: - q = self.request.GET.get('q', '').strip() - - model_class = tipo.content_type.model_class() - - fields = list(filter( - lambda field: isinstance(field, SaplGenericRelation) and - field.related_model == Autor, - model_class._meta.get_fields(include_hidden=True))) - - """ - fields - é um array de SaplGenericRelation que deve possuir o - atributo fields_search. Verifique na documentação da classe - a estrutura de fields_search. - """ - - assert len(fields) >= 1, (_( - 'Não foi encontrado em %(model)s um atributo do tipo ' - 'SaplGenericRelation que use o model %(model_autor)s') % { - 'model': model_class._meta.verbose_name, - 'model_autor': Autor._meta.verbose_name}) - - qs = model_class.objects.all() - - q_filter = Q() - if q: - for item in fields: - if item.related_model != Autor: - continue - q_fs = Q() - for field in item.fields_search: - q_fs = q_fs | Q(**{'%s%s' % ( - field[0], - field[1]): q}) - q_filter = q_filter & q_fs - - qs = qs.filter(q_filter).distinct( - fields[0].fields_search[0][0]).order_by( - fields[0].fields_search[0][0]) - else: - qs = qs.order_by(fields[0].fields_search[0][0]) + if self.request.user.is_anonymous(): + qs = qs.exclude(documento__restrito=True) + return qs - qs = qs.values_list( - 'id', fields[0].fields_search[0][0]) - r += list(qs) - if tipos.count() > 1: - r.sort(key=lambda x: x[1].upper()) - return r +class _TramitacaoAdministrativoViewSet( + SaplSetViews['protocoloadm']['tramitacaoadministrativo']): + # TODO: Implementar regras de manutenção das tramitações de docs adms + permission_classes = ( + _DocumentoAdministrativoViewSet.DocumentoAdministrativoPermission, ) -class AutoresPossiveisListView(ListAPIView): + def get_queryset(self): + qs = super().get_queryset() - permission_classes = (IsAuthenticatedOrReadOnly,) - queryset = Autor.objects.all() - model = Autor + if self.request.user.is_anonymous(): + qs = qs.exclude(documento__restrito=True) + return qs - pagination_class = None + def create(self, request, *args, **kwargs): + raise Exception(_("POST Create não implementado")) - filter_class = AutoresPossiveisFilterSet - serializer_class = AutorChoiceSerializer + def put(self, request, *args, **kwargs): + raise Exception(_("PUT Update não implementado")) + def patch(self, request, *args, **kwargs): + raise Exception(_("PATCH Partial Update não implementado")) -class MateriaLegislativaViewSet(ListModelMixin, - RetrieveModelMixin, - GenericViewSet): + def delete(self, request, *args, **kwargs): + raise Exception(_("DELETE Delete não implementado")) - permission_classes = (IsAuthenticated,) - serializer_class = MateriaLegislativaSerializer - queryset = MateriaLegislativa.objects.all() - filter_backends = (DjangoFilterBackend,) - filter_fields = ('numero', 'ano', 'tipo', ) +SaplSetViews['base']['autor'] = _AutorViewSet.build_class_with_actions() -class SessaoPlenariaViewSet(ListModelMixin, - RetrieveModelMixin, - GenericViewSet): +SaplSetViews['materia']['proposicao'] = _ProposicaoViewSet - permission_classes = (AllowAny,) - serializer_class = SessaoPlenariaSerializer - queryset = SessaoPlenaria.objects.all() - filter_backends = (DjangoFilterBackend,) - filter_fields = ('data_inicio', 'data_fim', 'interativa') +SaplSetViews['parlamentares']['parlamentar'] = _ParlamentarViewSet + +SaplSetViews['protocoloadm']['documentoadministrativo'] = _DocumentoAdministrativoViewSet +SaplSetViews['protocoloadm']['documentoacessorioadministrativo'] = _DocumentoAcessorioAdministrativoViewSet +SaplSetViews['protocoloadm']['tramitacaoadministrativo'] = _TramitacaoAdministrativoViewSet diff --git a/sapl/audiencia/forms.py b/sapl/audiencia/forms.py index ab2f2a6e7..2912ab58d 100755 --- a/sapl/audiencia/forms.py +++ b/sapl/audiencia/forms.py @@ -7,7 +7,7 @@ from django.utils.translation import ugettext_lazy as _ from sapl.audiencia.models import AudienciaPublica, TipoAudienciaPublica, AnexoAudienciaPublica from crispy_forms.layout import HTML, Button, Column, Fieldset, Layout -from crispy_forms.helper import FormHelper +from sapl.crispy_layout_mixin import SaplFormHelper from sapl.crispy_layout_mixin import SaplFormLayout, form_actions, to_row from sapl.materia.models import MateriaLegislativa, TipoMateriaLegislativa from sapl.utils import timezone @@ -134,7 +134,7 @@ class AnexoAudienciaPublicaForm(forms.ModelForm): row2 = to_row( [('assunto', 12)]) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = SaplFormLayout( Fieldset(_('Identificação Básica'), row1, row2)) diff --git a/sapl/base/forms.py b/sapl/base/forms.py index f24868ff6..2e54f82be 100644 --- a/sapl/base/forms.py +++ b/sapl/base/forms.py @@ -1,7 +1,7 @@ import logging from crispy_forms.bootstrap import FieldWithButtons, InlineRadios, StrictButton -from crispy_forms.helper import FormHelper +from sapl.crispy_layout_mixin import SaplFormHelper from crispy_forms.layout import HTML, Button, Div, Field, Fieldset, Layout, Row from django import forms from django.conf import settings @@ -114,7 +114,7 @@ class UsuarioCreateForm(ModelForm): row4 = to_row([(form_actions(label='Confirmar'), 6)]) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = Layout( row0, row1, @@ -154,7 +154,7 @@ class UsuarioEditForm(ModelForm): row3 = to_row([(form_actions(label='Salvar Alterações'), 6)]) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = Layout( row1, row2, @@ -394,7 +394,7 @@ class AutorForm(ModelForm): placeholder=_('Pesquisar por possíveis autores para ' 'o Tipo de Autor selecionado.')), StrictButton( - _('Filtrar'), css_class='btn-default btn-filtrar-autor', + _('Filtrar'), css_class='btn-outline-primary btn-filtrar-autor', type='button')), css_class='hidden', data_action='create', @@ -402,9 +402,9 @@ class AutorForm(ModelForm): data_field='autor_related') autor_select = Row(to_column(('tipo', 3)), - Div(to_column(('nome', 5)), - to_column(('cargo', 4)), - css_class="div_nome_cargo"), + Div(to_column(('nome', 7)), + to_column(('cargo', 5)), + css_class="div_nome_cargo row col"), to_column((autor_related, 9)), to_column((Div( Field('autor_related'), @@ -432,7 +432,7 @@ class AutorForm(ModelForm): controle_acesso = Fieldset(_('Controle de Acesso do Autor'), *controle_acesso) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = SaplFormLayout(autor_select, controle_acesso) super(AutorForm, self).__init__(*args, **kwargs) @@ -697,7 +697,7 @@ class RelatorioAtasFilterSet(django_filters.FilterSet): row1 = to_row([('data_inicio', 12)]) - self.form.helper = FormHelper() + self.form.helper = SaplFormHelper() self.form.helper.form_method = 'GET' self.form.helper.layout = Layout( Fieldset(_('Atas das Sessões Plenárias'), @@ -733,7 +733,7 @@ class RelatorioNormasMesFilterSet(django_filters.FilterSet): row1 = to_row([('ano', 12)]) - self.form.helper = FormHelper() + self.form.helper = SaplFormHelper() self.form.helper.form_method = 'GET' self.form.helper.layout = Layout( Fieldset(_('Normas por mês do ano.'), @@ -762,7 +762,7 @@ class EstatisticasAcessoNormasForm(Form): row1 = to_row([('ano', 12)]) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.form_method = 'GET' self.helper.layout = Layout( Fieldset(_('Normas por acessos nos meses do ano.'), @@ -800,7 +800,7 @@ class RelatorioNormasVigenciaFilterSet(django_filters.FilterSet): row1 = to_row([('ano', 12)]) row2 = to_row([('vigencia', 12)]) - self.form.helper = FormHelper() + self.form.helper = SaplFormHelper() self.form.helper.form_method = 'GET' self.form.helper.layout = Layout( Fieldset(_('Normas por vigência.'), @@ -828,7 +828,7 @@ class RelatorioPresencaSessaoFilterSet(django_filters.FilterSet): row1 = to_row([('data_inicio', 12)]) - self.form.helper = FormHelper() + self.form.helper = SaplFormHelper() self.form.helper.form_method = 'GET' self.form.helper.layout = Layout( Fieldset(_('Presença dos parlamentares nas sessões plenárias'), @@ -868,7 +868,7 @@ class RelatorioHistoricoTramitacaoFilterSet(django_filters.FilterSet): ('tramitacao__unidade_tramitacao_local', 4), ('tramitacao__status', 4)]) - self.form.helper = FormHelper() + self.form.helper = SaplFormHelper() self.form.helper.form_method = 'GET' self.form.helper.layout = Layout( Fieldset(_('Histórico de Tramitação'), @@ -901,7 +901,7 @@ class RelatorioDataFimPrazoTramitacaoFilterSet(django_filters.FilterSet): ('tramitacao__unidade_tramitacao_local', 4), ('tramitacao__status', 4)]) - self.form.helper = FormHelper() + self.form.helper = SaplFormHelper() self.form.helper.form_method = 'GET' self.form.helper.layout = Layout( Fieldset(_('Tramitações por fim de prazo'), @@ -932,7 +932,7 @@ class RelatorioReuniaoFilterSet(django_filters.FilterSet): ('nome', 4), ('tema', 4)]) - self.form.helper = FormHelper() + self.form.helper = SaplFormHelper() self.form.helper.form_method = 'GET' self.form.helper.layout = Layout( Fieldset(_('Reunião de Comissão'), @@ -962,7 +962,7 @@ class RelatorioAudienciaFilterSet(django_filters.FilterSet): [('tipo', 4), ('nome', 4)]) - self.form.helper = FormHelper() + self.form.helper = SaplFormHelper() self.form.helper.form_method = 'GET' self.form.helper.layout = Layout( Fieldset(_('Audiência Pública'), @@ -1006,7 +1006,7 @@ class RelatorioMateriasTramitacaoilterSet(django_filters.FilterSet): row3 = to_row([('tramitacao__unidade_tramitacao_destino', 12)]) row4 = to_row([('tramitacao__status', 12)]) - self.form.helper = FormHelper() + self.form.helper = SaplFormHelper() self.form.helper.form_method = 'GET' self.form.helper.layout = Layout( Fieldset(_('Pesquisa de Matéria em Tramitação'), @@ -1032,7 +1032,7 @@ class RelatorioMateriasPorAnoAutorTipoFilterSet(django_filters.FilterSet): row1 = to_row( [('ano', 12)]) - self.form.helper = FormHelper() + self.form.helper = SaplFormHelper() self.form.helper.form_method = 'GET' self.form.helper.layout = Layout( Fieldset(_('Pesquisar'), @@ -1074,7 +1074,7 @@ class RelatorioMateriasPorAutorFilterSet(django_filters.FilterSet): 'limpar Autor', css_class='btn btn-primary btn-sm'), 10)]) - self.form.helper = FormHelper() + self.form.helper = SaplFormHelper() self.form.helper.form_method = 'GET' self.form.helper.layout = Layout( Fieldset(_('Pesquisar'), @@ -1196,7 +1196,7 @@ class RecuperarSenhaForm(PasswordResetForm): def __init__(self, *args, **kwargs): row1 = to_row( [('email', 12)]) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = Layout( Fieldset(_('Insira o e-mail cadastrado com a sua conta'), row1, @@ -1233,7 +1233,7 @@ class NovaSenhaForm(SetPasswordForm): [('new_password1', 6), ('new_password2', 6)]) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = Layout( row1, form_actions(label='Enviar')) @@ -1266,7 +1266,7 @@ class AlterarSenhaForm(Form): [('new_password1', 6), ('new_password2', 6)]) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = Layout( row1, row2, diff --git a/sapl/base/models.py b/sapl/base/models.py index 343a8db9b..55792b147 100644 --- a/sapl/base/models.py +++ b/sapl/base/models.py @@ -1,16 +1,19 @@ -import reversion from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models.signals import post_migrate from django.db.utils import DEFAULT_DB_ALIAS from django.utils.translation import ugettext_lazy as _ -#from model_utils import Choices +import reversion + from sapl.utils import (LISTA_DE_UFS, YES_NO_CHOICES, get_settings_auth_user_model, models_with_gr_for_model) -TIPO_DOCUMENTO_ADMINISTRATIVO = (('O', _('Ostensiva')), - ('R', _('Restritiva'))) +DOC_ADM_OSTENSIVO = 'O' +DOC_ADM_RESTRITIVO = 'R' + +TIPO_DOCUMENTO_ADMINISTRATIVO = ((DOC_ADM_OSTENSIVO, _('Ostensiva')), + (DOC_ADM_RESTRITIVO, _('Restritiva'))) RELATORIO_ATOS_ACESSADOS = (('S', _('Sim')), ('N', _('Não'))) @@ -20,9 +23,9 @@ SEQUENCIA_NUMERACAO = (('A', _('Sequencial por ano')), ('U', _('Sequencial único'))) ESFERA_FEDERACAO_CHOICES = (('M', _('Municipal')), - ('E', _('Estadual')), - ('F', _('Federal')), -) + ('E', _('Estadual')), + ('F', _('Federal')), + ) ASSINATURA_ATA_CHOICES = ( ('M', _('Mesa Diretora da Sessão')), @@ -100,7 +103,7 @@ class AppConfig(models.Model): esfera_federacao = models.CharField( max_length=1, blank=True, - default = "", + default="", verbose_name=_('Esfera Federação'), choices=ESFERA_FEDERACAO_CHOICES) diff --git a/sapl/base/templatetags/common_tags.py b/sapl/base/templatetags/common_tags.py index 884a6968f..3c72b1654 100644 --- a/sapl/base/templatetags/common_tags.py +++ b/sapl/base/templatetags/common_tags.py @@ -1,5 +1,7 @@ from django import template from django.template.defaultfilters import stringfilter +from django.utils.safestring import mark_safe +from webpack_loader import utils from sapl.base.models import AppConfig from sapl.materia.models import DocumentoAcessorio, MateriaLegislativa, Proposicao @@ -274,3 +276,13 @@ def filiacao_data_filter(parlamentar, data_inicio): @register.filter def filiacao_intervalo_filter(parlamentar, date_range): return filiacao_data(parlamentar, date_range[0], date_range[1]) + + +@register.simple_tag +def render_chunk_vendors(extension=None): + try: + tags = utils.get_as_tags( + 'chunk-vendors', extension=extension, config='DEFAULT', attrs='') + return mark_safe('\n'.join(tags)) + except: + return '' diff --git a/sapl/base/tests/test_login.py b/sapl/base/tests/test_login.py index 91665ea8d..7fe7672e5 100755 --- a/sapl/base/tests/test_login.py +++ b/sapl/base/tests/test_login.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- -import pytest from django.contrib.auth import get_user_model +import pytest + pytestmark = pytest.mark.django_db @@ -12,14 +13,15 @@ def user(): def test_login_aparece_na_barra_para_usuario_nao_logado(client): response = client.get('/') - assert '' in str( + assert '' in str( response.content) def test_username_do_usuario_logado_aparece_na_barra(client, user): assert client.login(username='jfirmino', password='123') response = client.get('/') - assert 'Login' not in str(response.content) + assert 'Login' not in str( + response.content) assert 'jfirmino' in str(response.content) assert 'Sair' in str(response.content) diff --git a/sapl/base/views.py b/sapl/base/views.py index 04f3c8b18..59f787e83 100644 --- a/sapl/base/views.py +++ b/sapl/base/views.py @@ -33,16 +33,16 @@ from sapl.base.forms import AutorForm, AutorFormForAdmin, TipoAutorForm from sapl.base.models import Autor, TipoAutor from sapl.comissoes.models import Reuniao, Comissao from sapl.crud.base import CrudAux, make_pagination -from sapl.materia.models import (Autoria, MateriaLegislativa, +from sapl.materia.models import (Autoria, MateriaLegislativa, Proposicao, TipoMateriaLegislativa, StatusTramitacao, UnidadeTramitacao) from sapl.norma.models import (NormaJuridica, NormaEstatisticas) from sapl.parlamentares.models import Parlamentar, Legislatura, Mandato from sapl.protocoloadm.models import Protocolo from sapl.sessao.models import (PresencaOrdemDia, SessaoPlenaria, SessaoPlenariaPresenca, Bancada) -from sapl.utils import (parlamentares_ativos, +from sapl.utils import (parlamentares_ativos, gerar_hash_arquivo, SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, mail_service_configured, - intervalos_tem_intersecao) + intervalos_tem_intersecao,) from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, RelatorioAtasFilterSet, @@ -382,11 +382,14 @@ class RelatorioPresencaSessaoView(FilterView): for i, p in enumerate(parlamentares_qs): m = p.mandato_set.filter(Q(data_inicio_mandato__lte=_range[0], data_fim_mandato__gte=_range[1]) | Q(data_inicio_mandato__lte=_range[0], data_fim_mandato__isnull=True) | + Q(data_inicio_mandato__gte=_range[0], data_fim_mandato__lte=_range[1]) | + # mandato suplente Q(data_inicio_mandato__gte=_range[0], data_fim_mandato__lte=_range[1])) + m = m.last() parlamentares_presencas.append({ 'parlamentar': p, - 'titular': m.titular, + 'titular': m.titular if m else False, 'sessao_porc': 0, 'ordemdia_porc': 0 }) @@ -1426,10 +1429,28 @@ class AppConfigCrud(CrudAux): class BaseMixin(CrudAux.BaseMixin): form_class = ConfiguracoesAppForm - list_url = '' create_url = '' + def form_valid(self, form): + recibo_prop_atual = AppConfig.objects.last().receber_recibo_proposicao + recibo_prop_novo = self.request.POST['receber_recibo_proposicao'] + if recibo_prop_novo == 'False' and recibo_prop_atual: + props = Proposicao.objects.filter(hash_code='') + for prop in props: + self.gerar_hash(prop) + return super().form_valid(form) + + def gerar_hash(self, inst): + inst.save() + if inst.texto_original: + inst.hash_code = gerar_hash_arquivo( + inst.texto_original.path, str(inst.pk)) + elif inst.texto_articulado.exists(): + ta = inst.texto_articulado.first() + inst.hash_code = 'P' + ta.hash() + SEPARADOR_HASH_PROPOSICAO + str(inst.pk) + inst.save() + class CreateView(CrudAux.CreateView): def get(self, request, *args, **kwargs): diff --git a/sapl/comissoes/forms.py b/sapl/comissoes/forms.py index 14d42f53e..cef967102 100644 --- a/sapl/comissoes/forms.py +++ b/sapl/comissoes/forms.py @@ -13,10 +13,13 @@ from sapl.comissoes.models import (Comissao, Composicao, DocumentoAcessorio, Participacao, Reuniao, Periodo) from sapl.parlamentares.models import Legislatura, Mandato, Parlamentar + class ComposicaoForm(forms.ModelForm): - comissao = forms.CharField(required=False, label='Comissao', widget=forms.HiddenInput()) + comissao = forms.CharField( + required=False, label='Comissao', widget=forms.HiddenInput()) logger = logging.getLogger(__name__) + class Meta: model = Composicao exclude = [] @@ -43,8 +46,8 @@ class ComposicaoForm(forms.ModelForm): if intersecao_periodo: self.logger.error('O período informado ({} a {})' - 'choca com períodos já ' - 'cadastrados para esta comissão'.format(periodo.data_inicio, periodo.data_fim)) + 'choca com períodos já ' + 'cadastrados para esta comissão'.format(periodo.data_inicio, periodo.data_fim)) raise ValidationError('O período informado ' 'choca com períodos já ' 'cadastrados para esta comissão') @@ -55,6 +58,7 @@ class ComposicaoForm(forms.ModelForm): class PeriodoForm(forms.ModelForm): logger = logging.getLogger(__name__) + class Meta: model = Periodo exclude = [] @@ -70,31 +74,29 @@ class PeriodoForm(forms.ModelForm): if data_fim and data_fim < data_inicio: self.logger.error('A Data Final ({}) é menor que ' - 'a Data Inicial({}).'.format(data_fim, data_inicio)) + 'a Data Inicial({}).'.format(data_fim, data_inicio)) raise ValidationError('A Data Final não pode ser menor que ' - 'a Data Inicial') + 'a Data Inicial') # Evita NoneType exception se não preenchida a data_fim if not data_fim: data_fim = data_inicio legislatura = Legislatura.objects.filter(data_inicio__lte=data_inicio, - data_fim__gte=data_fim, - ) + data_fim__gte=data_fim, + ) if not legislatura: self.logger.error('O período informado ({} a {})' - 'não está contido em uma única ' - 'legislatura existente'.format(data_inicio, data_fim)) + 'não está contido em uma única ' + 'legislatura existente'.format(data_inicio, data_fim)) raise ValidationError('O período informado ' 'deve estar contido em uma única ' 'legislatura existente') - return cleaned_data - class ParticipacaoCreateForm(forms.ModelForm): logger = logging.getLogger(__name__) @@ -122,9 +124,9 @@ class ParticipacaoCreateForm(forms.ModelForm): parlamentares = Mandato.objects.filter(qs, parlamentar__ativo=True ).prefetch_related('parlamentar').\ - values_list('parlamentar', - flat=True - ).distinct() + values_list('parlamentar', + flat=True + ).distinct() qs = Parlamentar.objects.filter(id__in=parlamentares).distinct().\ exclude(id__in=id_part) @@ -137,7 +139,6 @@ class ParticipacaoCreateForm(forms.ModelForm): qs = Parlamentar.objects.filter(id__in=ids) self.fields['parlamentar'].queryset = qs - def clean(self): cleaned_data = super(ParticipacaoCreateForm, self).clean() @@ -148,22 +149,23 @@ class ParticipacaoCreateForm(forms.ModelForm): data_desligamento = cleaned_data['data_desligamento'] if data_desligamento and \ - data_designacao > data_desligamento: + data_designacao > data_desligamento: self.logger.error('Data de designação ({}) superior ' - 'à data de desligamento ({})'.format(data_designacao, data_desligamento)) + 'à data de desligamento ({})'.format(data_designacao, data_desligamento)) raise ValidationError(_('Data de designação não pode ser superior ' - 'à data de desligamento')) + 'à data de desligamento')) composicao = Composicao.objects.get(id=self.initial['parent_pk']) - cargos_unicos = [c.cargo.nome for c in composicao.participacao_set.filter(cargo__unico=True)] + cargos_unicos = [ + c.cargo.nome for c in composicao.participacao_set.filter(cargo__unico=True)] if cleaned_data['cargo'].nome in cargos_unicos: msg = _('Este cargo é único para esta Comissão.') - self.logger.error('Este cargo ({}) é único para esta Comissão.'.format(cleaned_data['cargo'].nome)) + self.logger.error('Este cargo ({}) é único para esta Comissão.'.format( + cleaned_data['cargo'].nome)) raise ValidationError(msg) return cleaned_data - def create_participacao(self): composicao = Composicao.objects.get(id=self.initial['parent_pk']) data_inicio_comissao = composicao.periodo.data_inicio @@ -237,9 +239,9 @@ class ParticipacaoEditForm(forms.ModelForm): if data_desligamento and \ data_designacao > data_desligamento: self.logger.error('Data de designação ({}) superior ' - 'à data de desligamento ({})'.format(data_designacao, data_desligamento)) + 'à data de desligamento ({})'.format(data_designacao, data_desligamento)) raise ValidationError(_('Data de designação não pode ser superior ' - 'à data de desligamento')) + 'à data de desligamento')) composicao_id = self.instance.composicao_id @@ -250,7 +252,7 @@ class ParticipacaoEditForm(forms.ModelForm): if cleaned_data['cargo'].nome in cargos_unicos: msg = _('Este cargo é único para esta Comissão.') self.logger.error('Este cargo ({}) é único para esta Comissão (id={}).' - .format(cleaned_data['cargo'].nome, composicao_id)) + .format(cleaned_data['cargo'].nome, composicao_id)) raise ValidationError(msg) return cleaned_data @@ -259,6 +261,7 @@ class ParticipacaoEditForm(forms.ModelForm): class ComissaoForm(forms.ModelForm): logger = logging.getLogger(__name__) + class Meta: model = Comissao fields = '__all__' @@ -274,8 +277,6 @@ class ComissaoForm(forms.ModelForm): self.fields['data_prorrogada_temp'].widget.attrs['disabled'] = 'disabled' self.fields['data_fim_comissao'].widget.attrs['disabled'] = 'disabled' - - def clean(self): super(ComissaoForm, self).clean() @@ -283,51 +284,54 @@ class ComissaoForm(forms.ModelForm): return self.cleaned_data if len(self.cleaned_data['nome']) > 100: - msg = _('Nome da Comissão informado ({}) tem mais de 50 caracteres.'.format(self.cleaned_data['nome'])) - self.logger.error('Nome da Comissão deve ter no máximo 50 caracteres.') + msg = _('Nome da Comissão informado ({}) tem mais de 50 caracteres.'.format( + self.cleaned_data['nome'])) + self.logger.error( + 'Nome da Comissão deve ter no máximo 50 caracteres.') raise ValidationError(msg) if (self.cleaned_data['data_extincao'] and self.cleaned_data['data_extincao'] < self.cleaned_data['data_criacao']): - msg = _('Data de extinção não pode ser menor que a de criação') - self.logger.error('Data de extinção ({}) não pode ser menor que a de criação ({}).' - .format(self.cleaned_data['data_extincao'],self.cleaned_data['data_criacao'])) - raise ValidationError(msg) + msg = _('Data de extinção não pode ser menor que a de criação') + self.logger.error('Data de extinção ({}) não pode ser menor que a de criação ({}).' + .format(self.cleaned_data['data_extincao'], self.cleaned_data['data_criacao'])) + raise ValidationError(msg) if (self.cleaned_data['data_final_prevista_temp'] and self.cleaned_data['data_final_prevista_temp'] < self.cleaned_data['data_criacao']): - msg = _('Data Prevista para Término não pode ser menor que a de criação') - self.logger.error('Data Prevista para Término ({}) não pode ser menor que a de criação ({}).' - .format(self.cleaned_data['data_final_prevista_temp'], self.cleaned_data['data_criacao'])) - raise ValidationError(msg) + msg = _('Data Prevista para Término não pode ser menor que a de criação') + self.logger.error('Data Prevista para Término ({}) não pode ser menor que a de criação ({}).' + .format(self.cleaned_data['data_final_prevista_temp'], self.cleaned_data['data_criacao'])) + raise ValidationError(msg) if (self.cleaned_data['data_prorrogada_temp'] and self.cleaned_data['data_prorrogada_temp'] < self.cleaned_data['data_criacao']): - msg = _('Data Novo Prazo não pode ser menor que a de criação') - self.logger.error('Data Novo Prazo ({}) não pode ser menor que a de criação ({}).' - .format(self.cleaned_data['data_prorrogada_temp'], self.cleaned_data['data_criacao'])) - raise ValidationError(msg) + msg = _('Data Novo Prazo não pode ser menor que a de criação') + self.logger.error('Data Novo Prazo ({}) não pode ser menor que a de criação ({}).' + .format(self.cleaned_data['data_prorrogada_temp'], self.cleaned_data['data_criacao'])) + raise ValidationError(msg) if (self.cleaned_data['data_instalacao_temp'] and self.cleaned_data['data_instalacao_temp'] < self.cleaned_data['data_criacao']): - msg = _('Data de Instalação não pode ser menor que a de criação') - self.logger.error('Data de Instalação ({}) não pode ser menor que a de criação ({}).' - .format(self.cleaned_data['data_instalacao_temp'], self.cleaned_data['data_criacao'])) - raise ValidationError(msg) + msg = _('Data de Instalação não pode ser menor que a de criação') + self.logger.error('Data de Instalação ({}) não pode ser menor que a de criação ({}).' + .format(self.cleaned_data['data_instalacao_temp'], self.cleaned_data['data_criacao'])) + raise ValidationError(msg) if (self.cleaned_data['data_final_prevista_temp'] and self.cleaned_data['data_instalacao_temp'] and self.cleaned_data['data_final_prevista_temp'] < self.cleaned_data['data_instalacao_temp']): - msg = _('Data Prevista para Término não pode ser menor que a de Instalação.') - self.logger.error('Data Prevista para Término ({}) não pode ser menor que a de Instalação ({}).' - .format(self.cleaned_data['data_final_prevista_temp'], self.cleaned_data['data_instalacao_temp'])) - raise ValidationError(msg) + msg = _( + 'Data Prevista para Término não pode ser menor que a de Instalação.') + self.logger.error('Data Prevista para Término ({}) não pode ser menor que a de Instalação ({}).' + .format(self.cleaned_data['data_final_prevista_temp'], self.cleaned_data['data_instalacao_temp'])) + raise ValidationError(msg) if (self.cleaned_data['data_prorrogada_temp'] and self.cleaned_data['data_instalacao_temp'] and self.cleaned_data['data_prorrogada_temp'] < self.cleaned_data['data_instalacao_temp']): - msg = _('Data Novo Prazo não pode ser menor que a de Instalação.') - self.logger.error('Data Novo Prazo ({}) não pode ser menor que a de Instalação ({}).' - .format(self.cleaned_data['data_prorrogada_temp'], self.cleaned_data['data_instalacao_temp'])) - raise ValidationError(msg) + msg = _('Data Novo Prazo não pode ser menor que a de Instalação.') + self.logger.error('Data Novo Prazo ({}) não pode ser menor que a de Instalação ({}).' + .format(self.cleaned_data['data_prorrogada_temp'], self.cleaned_data['data_instalacao_temp'])) + raise ValidationError(msg) return self.cleaned_data @transaction.atomic @@ -337,7 +341,7 @@ class ComissaoForm(forms.ModelForm): comissao = super(ComissaoForm, self).save(commit) content_type = ContentType.objects.get_for_model(Comissao) object_id = comissao.pk - tipo = TipoAutor.objects.get(descricao__icontains='Comiss') + tipo = TipoAutor.objects.get(content_type=content_type) nome = comissao.sigla + ' - ' + comissao.nome Autor.objects.create( content_type=content_type, @@ -363,7 +367,6 @@ class ReuniaoForm(ModelForm): def clean(self): super(ReuniaoForm, self).clean() - if not self.is_valid(): return self.cleaned_data @@ -371,12 +374,14 @@ class ReuniaoForm(ModelForm): if self.cleaned_data['hora_fim']: if (self.cleaned_data['hora_fim'] < self.cleaned_data['hora_inicio']): - msg = _('A hora de término da reunião não pode ser menor que a de início') + msg = _( + 'A hora de término da reunião não pode ser menor que a de início') self.logger.error("A hora de término da reunião ({}) não pode ser menor que a de início ({})." - .format(self.cleaned_data['hora_fim'], self.cleaned_data['hora_inicio'])) + .format(self.cleaned_data['hora_fim'], self.cleaned_data['hora_inicio'])) raise ValidationError(msg) return self.cleaned_data + class DocumentoAcessorioCreateForm(forms.ModelForm): parent_pk = forms.CharField(required=False) # widget=forms.HiddenInput()) @@ -395,7 +400,6 @@ class DocumentoAcessorioCreateForm(forms.ModelForm): documentos = reuniao.documentoacessorio_set.all() return self.create_documentoacessorio() - def create_documentoacessorio(self): reuniao = Reuniao.objects.get(id=self.initial['parent_pk']) diff --git a/sapl/compilacao/forms.py b/sapl/compilacao/forms.py index 3c4275bee..71d6ad7e5 100644 --- a/sapl/compilacao/forms.py +++ b/sapl/compilacao/forms.py @@ -3,7 +3,7 @@ from datetime import timedelta from crispy_forms.bootstrap import (Alert, FieldWithButtons, FormActions, InlineCheckboxes, InlineRadios, StrictButton) -from crispy_forms.helper import FormHelper +from sapl.crispy_layout_mixin import SaplFormHelper from crispy_forms.layout import (HTML, Button, Column, Div, Field, Fieldset, Layout, Row, Submit) from django import forms @@ -23,7 +23,8 @@ from sapl.compilacao.models import (NOTAS_PUBLICIDADE_CHOICES, TipoTextoArticulado, TipoVide, VeiculoPublicacao, Vide) from sapl.compilacao.utils import DISPOSITIVO_SELECT_RELATED -from sapl.crispy_layout_mixin import SaplFormLayout, to_column, to_row +from sapl.crispy_layout_mixin import SaplFormLayout, to_column, to_row,\ + form_actions from sapl.utils import YES_NO_CHOICES error_messages = { @@ -83,7 +84,7 @@ class TipoTaForm(ModelForm): ('perfis', 12), ]) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = SaplFormLayout( Fieldset(_('Identificação Básica'), row1, css_class="col-md-12"), @@ -152,7 +153,7 @@ class TaForm(ModelForm): ('participacao_social', 3), ]) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = SaplFormLayout( Fieldset(_('Identificação Básica'), row1, css_class="col-md-12"), Fieldset( @@ -256,19 +257,22 @@ class NotaForm(ModelForm): ]) buttons = FormActions( - HTML('' - '%s' % _('Cancelar')), + *[ + HTML('%s' % _('Cancelar')) + ], Button( 'submit-form', 'Salvar', - css_class='btn btn-primary pull-right') + css_class='btn btn-primary float-right'), + css_class='form-group row justify-content-between mr-1 ml-1' ) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = Layout( Div( - Div(HTML(_('Notas')), css_class='panel-heading'), + Div(HTML(_('Notas')), css_class='card-header bg-light'), Div( row1, to_row([(Field( @@ -279,9 +283,9 @@ class NotaForm(ModelForm): placeholder=_('URL Externa (opcional)')), 12)]), row3, to_row([(buttons, 12)]), - css_class="panel-body" + css_class="card-body" ), - css_class="panel panel-primary" + css_class="card" ) ) @@ -328,12 +332,15 @@ class VideForm(ModelForm): def __init__(self, *args, **kwargs): buttons = FormActions( - HTML('' - '%s' % _('Cancelar')), + *[ + HTML('%s' % _('Cancelar')) + ], Button( 'submit-form', 'Salvar', - css_class='btn-primary pull-right') + css_class='btn btn-primary float-right'), + css_class='form-group row justify-content-between mr-1 ml-1' ) dispositivo_ref = Field( @@ -356,18 +363,18 @@ class VideForm(ModelForm): 'texto', placeholder=_('Texto Adicional ao Vide')), 12))))) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = Layout( Div( - Div(HTML(_('Vides')), css_class='car-header'), + Div(HTML(_('Vides')), css_class='card-header bg-light'), Div( to_column((fields_form[0], 6)), to_column((fields_form[1], 6)), to_column(('dispositivo_base', 0)), to_column(('pk', 0)), - css_class="card-body" + css_class="card-body row" ), - css_class="card bg-light" + css_class="card" ) ) @@ -464,7 +471,7 @@ class PublicacaoForm(ModelForm): ('url_externa', 8), ]) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = SaplFormLayout( Fieldset(Publicacao._meta.verbose_name, row1, row2, row3, css_class="col-md-12")) @@ -652,7 +659,7 @@ class DispositivoEdicaoBasicaForm(ModelForm): for f in fields: self.base_fields.update({f: getattr(self, f)}) - self.helper = FormHelper() + self.helper = SaplFormHelper() if not editor_type: cancel_label = _('Ir para o Editor Sequencial') @@ -671,7 +678,7 @@ class DispositivoEdicaoBasicaForm(ModelForm): cancel_label = _('Fechar') more = [ - HTML('%s' % + HTML('%s' % cancel_label), ] @@ -680,7 +687,7 @@ class DispositivoEdicaoBasicaForm(ModelForm): if not (inst.tipo_dispositivo.dispositivo_de_alteracao and inst.tipo_dispositivo.dispositivo_de_articulacao): btns_excluir = [ - HTML('%s' % + HTML('%s' % _('Cancelar')), ] - more.append(Submit('salvar', _('Salvar'), css_class='pull-right')) + more.append(Submit('salvar', _('Salvar'), css_class='float-right')) buttons = FormActions(*more, css_class='form-group') - _fields = [Div(*layout, css_class="row-fluid")] + \ + _fields = [Div(*layout, css_class="row")] + \ [to_row([(buttons, 12)])] - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = Layout(*_fields) super(DispositivoRegistroAlteracaoForm, self).__init__(*args, **kwargs) @@ -1414,17 +1421,17 @@ class DispositivoRegistroRevogacaoForm(Form): layout.append(Field('dispositivo_search_form')) more = [ - HTML('%s' % + HTML('%s' % _('Cancelar')), ] - more.append(Submit('salvar', _('Salvar'), css_class='pull-right')) + more.append(Submit('salvar', _('Salvar'), css_class='float-right')) buttons = FormActions(*more, css_class='form-group') - _fields = [Div(*layout, css_class="row-fluid")] + \ + _fields = [Div(*layout, css_class="row")] + \ [to_row([(buttons, 12)])] - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = Layout(*_fields) super(DispositivoRegistroRevogacaoForm, self).__init__(*args, **kwargs) @@ -1464,17 +1471,17 @@ class DispositivoRegistroInclusaoForm(Form): layout.append(Div(css_class="allowed_inserts col-md-12")) more = [ - HTML('%s' % + HTML('%s' % _('Cancelar')), ] - # more.append(Submit('salvar', _('Salvar'), css_class='pull-right')) + # more.append(Submit('salvar', _('Salvar'), css_class='float-right')) buttons = FormActions(*more, css_class='form-group') - _fields = [Div(*layout, css_class="row-fluid")] + \ + _fields = [Div(*layout, css_class="row")] + \ [to_row([(buttons, 12)])] - self.helper = FormHelper() + self.helper = SaplFormHelper() self.helper.layout = Layout(*_fields) super(DispositivoRegistroInclusaoForm, self).__init__(*args, **kwargs) diff --git a/sapl/compilacao/urls.py b/sapl/compilacao/urls.py index 781a6d80e..9364985aa 100644 --- a/sapl/compilacao/urls.py +++ b/sapl/compilacao/urls.py @@ -3,7 +3,8 @@ from django.conf.urls import include, url from sapl.compilacao import views from sapl.compilacao.views import (TipoDispositivoCrud, TipoNotaCrud, TipoPublicacaoCrud, TipoVideCrud, - VeiculoPublicacaoCrud) + VeiculoPublicacaoCrud, + TipoTextoArticuladoCrud) from .apps import AppConfig @@ -113,14 +114,7 @@ urlpatterns = [ include(TipoPublicacaoCrud.get_urls())), url(r'^sistema/ta/config/veiculo-publicacao/', include(VeiculoPublicacaoCrud.get_urls())), - url(r'^sistema/ta/config/tipo-textoarticulado$', - views.TipoTaListView.as_view(), name='tipo_ta_list'), - url(r'^sistema/ta/config/tipo-textoarticulado/create$', - views.TipoTaCreateView.as_view(), name='tipo_ta_create'), - url(r'^sistema/ta/config/tipo-textoarticulado/(?P[0-9]+)$', - views.TipoTaDetailView.as_view(), name='tipo_ta_detail'), - url(r'^sistema/ta/config/tipo-textoarticulado/(?P[0-9]+)/edit$', - views.TipoTaUpdateView.as_view(), name='tipo_ta_edit'), - url(r'^sistema/ta/config/tipo-textoarticulado/(?P[0-9]+)/delete$', - views.TipoTaDeleteView.as_view(), name='tipo_ta_delete'), + url(r'^sistema/ta/config/tipo/', + include(TipoTextoArticuladoCrud.get_urls())), + ] diff --git a/sapl/compilacao/views.py b/sapl/compilacao/views.py index 897e18cd6..f09ed8a4f 100644 --- a/sapl/compilacao/views.py +++ b/sapl/compilacao/views.py @@ -50,7 +50,8 @@ from sapl.compilacao.models import (STATUS_TA_EDITION, STATUS_TA_PRIVATE, from sapl.compilacao.utils import (DISPOSITIVO_SELECT_RELATED, DISPOSITIVO_SELECT_RELATED_EDIT, get_integrations_view_names) -from sapl.crud.base import Crud, CrudAux, CrudListView, make_pagination +from sapl.crud.base import RP_DETAIL, RP_LIST, Crud, CrudAux, CrudListView,\ + make_pagination from sapl.settings import BASE_DIR @@ -431,88 +432,32 @@ class CompMixin(PermissionRequiredMixin): return rr -class TipoTaListView(CompMixin, ListView): +class TipoTextoArticuladoCrud(CrudAux): model = TipoTextoArticulado - paginate_by = 10 - verbose_name = model._meta.verbose_name - permission_required = 'compilacao.list_tipotextoarticulado' - - @property - def title(self): - return self.model._meta.verbose_name_plural - - @property - def create_url(self): - return reverse_lazy('sapl.compilacao:tipo_ta_create') - - -class TipoTaCreateView(CompMixin, FormMessagesMixin, CreateView): - model = TipoTextoArticulado - form_class = TipoTaForm - template_name = "crud/form.html" - form_valid_message = _('Registro criado com sucesso!') - form_invalid_message = _('O registro não foi criado.') - permission_required = 'compilacao.add_tipotextoarticulado' - - def get(self, request, *args, **kwargs): - self.object = None - form = self.get_form() - form.fields['content_type'] = forms.ChoiceField( - choices=choice_models_in_extenal_views(), - label=_('Modelo Integrado'), required=False) - - return self.render_to_response(self.get_context_data(form=form)) - - def get_success_url(self): - return reverse_lazy('sapl.compilacao:tipo_ta_detail', - kwargs={'pk': self.object.id}) - - @property - def cancel_url(self): - return reverse_lazy('sapl.compilacao:tipo_ta_list') - - -class TipoTaDetailView(CompMixin, DetailView): - model = TipoTextoArticulado - permission_required = 'compilacao.detail_tipotextoarticulado' - - -class TipoTaUpdateView(CompMixin, UpdateView): - model = TipoTextoArticulado - form_class = TipoTaForm - template_name = "crud/form.html" - permission_required = 'compilacao.change_tipotextoarticulado' - - def get(self, request, *args, **kwargs): - self.object = self.get_object() - form = self.get_form() - form.fields['content_type'] = forms.ChoiceField( - choices=choice_models_in_extenal_views(), - label=_('Modelo Integrado'), required=False) - return self.render_to_response(self.get_context_data(form=form)) - - def get_success_url(self): - return reverse_lazy('sapl.compilacao:tipo_ta_detail', - kwargs={'pk': self.kwargs['pk']}) - - @property - def cancel_url(self): - return reverse_lazy('sapl.compilacao:tipo_ta_detail', - kwargs={'pk': self.kwargs['pk']}) - - -class TipoTaDeleteView(CompMixin, DeleteView): - model = TipoTextoArticulado - template_name = "crud/confirm_delete.html" - permission_required = 'compilacao.delete_tipotextoarticulado' - - @property - def detail_url(self): - return reverse_lazy('sapl.compilacao:tipo_ta_detail', - kwargs={'pk': self.kwargs['pk']}) - - def get_success_url(self): - return reverse_lazy('sapl.compilacao:tipo_ta_list') + public = [RP_LIST, RP_DETAIL, ] + + class CreateView(CrudAux.CreateView): + form_class = TipoTaForm + + def get(self, request, *args, **kwargs): + self.object = None + form = self.get_form() + form.fields['content_type'] = forms.ChoiceField( + choices=choice_models_in_extenal_views(), + label=_('Modelo Integrado'), required=False) + + return self.render_to_response(self.get_context_data(form=form)) + + class UpdateView(CrudAux.UpdateView): + form_class = TipoTaForm + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + form = self.get_form() + form.fields['content_type'] = forms.ChoiceField( + choices=choice_models_in_extenal_views(), + label=_('Modelo Integrado'), required=False) + return self.render_to_response(self.get_context_data(form=form)) class TaListView(CompMixin, ListView): diff --git a/sapl/crispy_layout_mixin.py b/sapl/crispy_layout_mixin.py index 9b2f0867b..200f24db1 100644 --- a/sapl/crispy_layout_mixin.py +++ b/sapl/crispy_layout_mixin.py @@ -1,6 +1,5 @@ from math import ceil -import rtyaml from crispy_forms.bootstrap import FormActions from crispy_forms.helper import FormHelper from crispy_forms.layout import HTML, Div, Fieldset, Layout, Submit @@ -8,6 +7,7 @@ from django import template from django.core.urlresolvers import reverse, reverse_lazy from django.utils import formats from django.utils.translation import ugettext as _ +import rtyaml def heads_and_tails(list_of_lists): @@ -21,7 +21,7 @@ def to_column(name_span): def to_row(names_spans): - return Div(*map(to_column, names_spans), css_class='row-fluid') + return Div(*map(to_column, names_spans), css_class='row') def to_fieldsets(fields): @@ -35,7 +35,8 @@ def to_fieldsets(fields): def form_actions(more=[Div(css_class='clearfix')], - label=_('Salvar'), name='salvar', css_class='pull-right', disabled=True): + label=_('Salvar'), name='salvar', + css_class='float-right', disabled=True): if disabled: doubleclick = 'this.form.submit();this.disabled=true;' @@ -43,10 +44,41 @@ def form_actions(more=[Div(css_class='clearfix')], doubleclick = 'return true;' return FormActions( + *more, Submit(name, label, css_class=css_class, # para impedir resubmissão do form onclick=doubleclick), - *more) + css_class='form-group row justify-content-between' + ) + + +class SaplFormHelper(FormHelper): + render_hidden_fields = True # default = False + """ + até a release 1.6.1 do django-crispy-forms, os fields em Meta.Fields eram + renderizados mesmo se não mencionados no helper. + Com esta mudança (https://github.com/django-crispy-forms/django-crispy-forms/commit/6b93e8a362422db8fe54aa731319c7cbc39990ba) + render_hidden_fields foi adicionado uma condição em que a cada + instância do Helper, fosse decidido se os fields não mencionados serião ou + não renderizados... + O Sapl até este commit: https://github.com/interlegis/sapl/commit/22b87f36ebc8659a6ecaf8831ab0f425206b0993 + utilizou o django-crispy-forms na versão 1.6.1, ou seja, + sem a condição render_hidden_fields o que fazia o FormHelper, na 1.6.1 + set comportar como se, agora, na 1.7.2 o default fosse True. + Como todos os Forms do Sapl foram construídos assumindo que fields + não incluídos explicitamente no Helper, o helper o incluiria implicitamente, + e assim o era, de acordo com commit acima do django-crispy-forms, então + cria-se essa classe: + + class SaplFormHelper(FormHelper): + render_hidden_fields = True + + onde torna o default, antes False, agora = True, o esperado pelos forms do sapl, + e substituí-se todos os FormHelper por SaplFormHelper dentro do projeto Sapl + + + esta explicação ficará aqui dentro do código, via commit, e na issue #2456. + """ class SaplFormLayout(Layout): @@ -58,7 +90,7 @@ class SaplFormLayout(Layout): if not buttons: buttons = form_actions(label=save_label, more=[ HTML('%s' % cancel_label) + ' class="btn btn-dark">%s' % cancel_label) if cancel_label else None]) _fields = list(to_fieldsets(fields)) @@ -185,8 +217,11 @@ class CrispyLayoutFormMixin: pass else: if self.layout_key: - form.helper = FormHelper() - form.helper.layout = SaplFormLayout(*self.get_layout()) + form.helper = SaplFormHelper() + layout = self.get_layout() + + form.helper.layout = SaplFormLayout(*layout) + return form @property diff --git a/sapl/crud/base.py b/sapl/crud/base.py index faa0e1287..5fa4f70e5 100644 --- a/sapl/crud/base.py +++ b/sapl/crud/base.py @@ -2,7 +2,7 @@ import logging from braces.views import FormMessagesMixin from crispy_forms.bootstrap import FieldWithButtons, StrictButton -from crispy_forms.helper import FormHelper +from sapl.crispy_layout_mixin import SaplFormHelper from crispy_forms.layout import Field, Layout from django import forms from django.conf.urls import url @@ -150,7 +150,7 @@ class ListWithSearchForm(forms.Form): def __init__(self, *args, **kwargs): super(ListWithSearchForm, self).__init__(*args, **kwargs) - self.helper = FormHelper() + self.helper = SaplFormHelper() self.form_class = 'form-inline' self.helper.form_method = 'GET' self.helper.layout = Layout( @@ -160,7 +160,7 @@ class ListWithSearchForm(forms.Form): placeholder=_('Filtrar Lista'), css_class='input-lg'), StrictButton( - _('Filtrar'), css_class='btn-default btn-lg', + _('Filtrar'), css_class='btn-outline-primary btn-lg', type='submit')) ) diff --git a/sapl/crud/tests/stub_app/templates/base.html b/sapl/crud/tests/stub_app/templates/base.html index 8a9dc002b..fcf92e5eb 100644 --- a/sapl/crud/tests/stub_app/templates/base.html +++ b/sapl/crud/tests/stub_app/templates/base.html @@ -12,9 +12,9 @@ {# Feedback messages #} {% for message in messages %} -