Browse Source

Merge branch '3.1.x' into pauta_multiformat

pull/3744/head
Edward 3 months ago
committed by GitHub
parent
commit
d6ac5e830a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 22
      sapl/base/forms.py
  2. 21
      sapl/base/views.py
  3. 2
      sapl/materia/views.py
  4. 21
      sapl/middleware.py
  5. 2
      sapl/norma/views.py
  6. 2
      sapl/protocoloadm/views.py
  7. 1
      sapl/settings.py
  8. 55
      sapl/templates/base/alterar_senha.html
  9. 49
      sapl/templates/base/login.html
  10. 86
      sapl/utils.py

22
sapl/base/forms.py

@ -40,7 +40,7 @@ from sapl.utils import (autor_label, autor_modal, ChoiceWithoutValidationField,
FilterOverridesMetaMixin, FileFieldCheckMixin, FilterOverridesMetaMixin, FileFieldCheckMixin,
ImageThumbnailFileInput, qs_override_django_filter, ImageThumbnailFileInput, qs_override_django_filter,
RANGE_ANOS, YES_NO_CHOICES, choice_tipos_normas, RANGE_ANOS, YES_NO_CHOICES, choice_tipos_normas,
GoogleRecapthaMixin, parlamentares_ativos, RANGE_MESES) GoogleRecapthaMixin, parlamentares_ativos, RANGE_MESES, is_weak_password)
from .models import AppConfig, CasaLegislativa from .models import AppConfig, CasaLegislativa
@ -288,8 +288,13 @@ class UserAdminForm(ModelForm):
) )
else: else:
if new_password1 and new_password2: if new_password1 and new_password2:
if is_weak_password(new_password1):
raise forms.ValidationError(_(
'A senha deve ter pelo menos 8 caracteres e incluir uma combinação '
'de letras maiúsculas e minúsculas, números e caracteres especiais.'
))
password_validation.validate_password( password_validation.validate_password(
new_password2, self.instance) new_password1, self.instance)
parlamentar = data.get('parlamentar', None) parlamentar = data.get('parlamentar', None)
if parlamentar and parlamentar.votante_set.exists() and \ if parlamentar and parlamentar.votante_set.exists() and \
@ -926,12 +931,12 @@ class CasaLegislativaForm(FileFieldCheckMixin, ModelForm):
class LoginForm(AuthenticationForm): class LoginForm(AuthenticationForm):
username = forms.CharField( username = forms.CharField(
label="Username", max_length=30, label="Usuário", max_length=30,
widget=forms.TextInput( widget=forms.TextInput(
attrs={ attrs={
'class': 'form-control', 'name': 'username'})) 'class': 'form-control', 'name': 'username'}))
password = forms.CharField( password = forms.CharField(
label="Password", max_length=30, label="Senha", max_length=30,
widget=forms.PasswordInput( widget=forms.PasswordInput(
attrs={ attrs={
'class': 'form-control', 'name': 'password'})) 'class': 'form-control', 'name': 'password'}))
@ -1139,12 +1144,15 @@ class AlterarSenhaForm(Form):
# TODO: caracteres alfanuméricos, maiúsculas (?), # TODO: caracteres alfanuméricos, maiúsculas (?),
# TODO: senha atual igual a senha anterior, etc # TODO: senha atual igual a senha anterior, etc
if len(new_password1) < 6: if is_weak_password(new_password1):
self.logger.warning( self.logger.warning(
'A senha informada não tem o mínimo de 6 caracteres.' 'A senha deve ter pelo menos 8 caracteres e incluir uma combinação '
'de letras maiúsculas e minúsculas, números e caracteres especiais.'
) )
raise ValidationError( raise ValidationError(
"A senha informada deve ter no mínimo 6 caracteres") 'A senha deve ter pelo menos 8 caracteres e incluir uma combinação '
'de letras maiúsculas e minúsculas, números e caracteres especiais.'
)
username = data['username'] username = data['username']
old_password = data['old_password'] old_password = data['old_password']

21
sapl/base/views.py

@ -6,7 +6,7 @@ import os
from django.apps.registry import apps from django.apps.registry import apps
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import get_user_model, views from django.contrib.auth import authenticate, login, get_user_model, views
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.tokens import default_token_generator from django.contrib.auth.tokens import default_token_generator
@ -51,7 +51,7 @@ from sapl.sessao.models import (Bancada, SessaoPlenaria)
from sapl.settings import EMAIL_SEND_USER from sapl.settings import EMAIL_SEND_USER
from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_service_configured, from sapl.utils import (gerar_hash_arquivo, intervalos_tem_intersecao, mail_service_configured,
SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, google_recaptcha_configured, SEPARADOR_HASH_PROPOSICAO, show_results_filter_set, google_recaptcha_configured,
get_client_ip, sapn_is_enabled) get_client_ip, sapn_is_enabled, is_weak_password)
from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, EstatisticasAcessoNormasForm) from .forms import (AlterarSenhaForm, CasaLegislativaForm, ConfiguracoesAppForm, EstatisticasAcessoNormasForm)
from .models import AppConfig, CasaLegislativa from .models import AppConfig, CasaLegislativa
@ -75,6 +75,21 @@ class LoginSapl(views.LoginView):
template_name = 'base/login.html' template_name = 'base/login.html'
authentication_form = LoginForm authentication_form = LoginForm
def form_valid(self, form):
"""Override do comportamento padrão para verificar senha fraca"""
username = form.cleaned_data.get('username')
password = form.cleaned_data.get('password')
user = authenticate(self.request, username=username, password=password)
if user is not None:
login(self.request, user)
if is_weak_password(password):
self.request.session['weak_password'] = True
return redirect(self.get_success_url())
# Fallback se falhar a autenticação (tecnicamente não devia chegar aqui)
return super().form_invalid(form)
class ConfirmarEmailView(TemplateView): class ConfirmarEmailView(TemplateView):
template_name = "email/confirma.html" template_name = "email/confirma.html"
@ -1481,6 +1496,8 @@ class AlterarSenha(FormView):
user.set_password(new_password) user.set_password(new_password)
user.save() user.save()
self.request.session.pop('weak_password', None)
return super().form_valid(form) return super().form_valid(form)

2
sapl/materia/views.py

@ -2985,7 +2985,7 @@ def create_pdf_docacessorios(materia):
materia.pk, materia.pk,
time.mktime(datetime.now().timetuple())) time.mktime(datetime.now().timetuple()))
merger = PdfFileMerger() merger = PdfFileMerger(strict=False)
for f in docs_path: for f in docs_path:
merger.append(fileobj=f) merger.append(fileobj=f)

21
sapl/middleware.py

@ -0,0 +1,21 @@
import logging
from django.shortcuts import redirect
from django.urls import reverse
class CheckWeakPasswordMiddleware:
logger = logging.getLogger(__name__)
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated and \
request.session.get('weak_password', False) and \
request.path != reverse('sapl.base:alterar_senha') and \
request.path != reverse('sapl.base:logout'):
logging.warning(f"Usuário {request.user.username} possui senha fraca.")
return redirect('sapl.base:alterar_senha')
return self.get_response(request)

2
sapl/norma/views.py

@ -166,7 +166,7 @@ class NormaPesquisaView(MultiFormatOutputMixin, FilterView):
texto_integral = obj.texto_integral if not isinstance( texto_integral = obj.texto_integral if not isinstance(
obj, dict) else obj["texto_integral"] obj, dict) else obj["texto_integral"]
return f'{url}/{texto_integral}' return f'{url}/media/{texto_integral}'
def get_queryset(self): def get_queryset(self):
qs = super().get_queryset() qs = super().get_queryset()

2
sapl/protocoloadm/views.py

@ -174,7 +174,7 @@ def create_pdf_docacessorios(docadministrativo):
logger.info("Gerando compilado PDF de documentos acessorios com {} documentos" logger.info("Gerando compilado PDF de documentos acessorios com {} documentos"
.format(docs_path)) .format(docs_path))
merger = PdfFileMerger() merger = PdfFileMerger(strict=False)
for f in docs_path: for f in docs_path:
merger.append(fileobj=f) merger.append(fileobj=f)

1
sapl/settings.py

@ -141,6 +141,7 @@ MIDDLEWARE = [
'whitenoise.middleware.WhiteNoiseMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware',
'waffle.middleware.WaffleMiddleware', 'waffle.middleware.WaffleMiddleware',
'sapl.middleware.CheckWeakPasswordMiddleware',
] ]
if DEBUG: if DEBUG:
INSTALLED_APPS += ('debug_toolbar',) INSTALLED_APPS += ('debug_toolbar',)

55
sapl/templates/base/alterar_senha.html

@ -3,6 +3,18 @@
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% block actions %}{% endblock %} {% block actions %}{% endblock %}
{% block detail_content %} {% block detail_content %}
<head>
<!-- TODO: Hack. Atualizar vue-bootstrap libs so we can call this in base.html and get the glyphs -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
</head>
{%if request.session.weak_password %}
<div class="alert alert-danger" role="alert">
A senha utilizada pelo usuário <strong>{{request.user.username}}</strong> é fraca!
Uma senha forte deve ter pelo menos 8 caracteres e incluir uma combinação de letras maiúsculas
e minúsculas, números e caracteres especiais (por exemplo, !, $, #, @). Evitem reutilizar
senhas antigas ou senhas utilizadas em outros serviços.
</div>
{% endif %}
<h1>Alterar Senha</h1> <h1>Alterar Senha</h1>
{% crispy form %} {% crispy form %}
</br> </br>
@ -10,3 +22,46 @@
Favor entrar novamente com a nova senha após a mudança com sucesso. Favor entrar novamente com a nova senha após a mudança com sucesso.
{% endblock detail_content %} {% endblock detail_content %}
{% block extra_js %}
<script>
$(document).ready(function () {
$("input[type='password']").each(function () {
const $input = $(this);
// Avoid re-wrapping
if ($input.parent().hasClass("password-container")) return;
// Wrap in relative container
const $wrapper = $('<div class="password-container position-relative mb-3"></div>');
$input.wrap($wrapper);
// Style input to have padding-right for icon
$input.css("padding-right", "2.5rem");
// Create the eye icon
const $eyeIcon = $(`
<i class="bi bi-eye password-toggle-icon"
style="
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: #6c757d;
font-size: 1.2rem;
"></i>
`);
// Insert icon after input
$input.after($eyeIcon);
// Toggle logic
$eyeIcon.on("click", function () {
const isPassword = $input.attr("type") === "password";
$input.attr("type", isPassword ? "text" : "password");
$(this).toggleClass("bi-eye bi-eye-slash");
});
});
});
</script>
{% endblock %}

49
sapl/templates/base/login.html

@ -13,6 +13,11 @@
{% endif %} --> {% endif %} -->
<head>
<!-- TODO: Hack. Atualizar vue-bootstrap libs so we can call this in base.html and get the glyphs -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
</head>
<div class="container mb-3"> <div class="container mb-3">
<div class="row"> <div class="row">
<div class="col-lg-4 offset-lg-4 col-8 offset-2"> <div class="col-lg-4 offset-lg-4 col-8 offset-2">
@ -32,11 +37,11 @@
{% endif %} {% endif %}
<tr> <tr>
<p><b><center>Usuário</center></b></p> <p><b><center>{{form.username.label}}</center></b></p>
{{ form.username }} {{ form.username }}
</tr> </tr>
<tr> <tr>
<p><b><center>Senha</center></b></p> <p><b><center>{{form.password.label}}</center></b></p>
{{ form.password }} {{ form.password }}
</tr> </tr>
</table> </table>
@ -67,5 +72,45 @@
{% if not user.is_authenticated %} {% if not user.is_authenticated %}
$("#autenticacao") .css("display","none"); $("#autenticacao") .css("display","none");
{% endif %} {% endif %}
$(document).ready(function () {
$("input[type='password']").each(function () {
const $input = $(this);
// Avoid re-wrapping
if ($input.parent().hasClass("password-container")) return;
// Wrap in relative container
const $wrapper = $('<div class="password-container position-relative mb-3"></div>');
$input.wrap($wrapper);
// Style input to have padding-right for icon
$input.css("padding-right", "2.5rem");
// Create the eye icon
const $eyeIcon = $(`
<i class="bi bi-eye password-toggle-icon"
style="
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
color: #6c757d;
font-size: 1.2rem;
"></i>
`);
// Insert icon after input
$input.after($eyeIcon);
// Toggle logic
$eyeIcon.on("click", function () {
const isPassword = $input.attr("type") === "password";
$input.attr("type", isPassword ? "text" : "password");
$(this).toggleClass("bi-eye bi-eye-slash");
});
});
});
</script> </script>
{% endblock %} {% endblock %}

86
sapl/utils.py

@ -1,4 +1,5 @@
import csv import csv
import string
from functools import wraps from functools import wraps
import hashlib import hashlib
import io import io
@ -24,7 +25,7 @@ from django.contrib.contenttypes.fields import (GenericForeignKey, GenericRel,
GenericRelation) GenericRelation)
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile,\ from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile, \
TemporaryUploadedFile TemporaryUploadedFile
from django.core.mail import get_connection from django.core.mail import get_connection
from django.db import models from django.db import models
@ -47,7 +48,6 @@ from sapl.crispy_layout_mixin import (form_actions, SaplFormHelper,
SaplFormLayout, to_row) SaplFormLayout, to_row)
from sapl.settings import MAX_DOC_UPLOAD_SIZE from sapl.settings import MAX_DOC_UPLOAD_SIZE
# (26/10/2018): O separador foi mudador de '/' para 'K' # (26/10/2018): O separador foi mudador de '/' para 'K'
# por conta dos leitores de códigos de barra, que trocavam # por conta dos leitores de códigos de barra, que trocavam
# a '/' por '&' ou ';' # a '/' por '&' ou ';'
@ -55,6 +55,28 @@ SEPARADOR_HASH_PROPOSICAO = 'K'
TIME_PATTERN = '^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$' TIME_PATTERN = '^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$'
MIN_PASSWORD_LENGTH = 8
def is_weak_password(password):
pwd_has_lowercase = False
pwd_has_uppercase = False
pwd_has_number = False
pwd_has_special_char = False
for c in password:
if c.isdigit():
pwd_has_number = True
elif c.islower():
pwd_has_lowercase = True
elif c.isupper():
pwd_has_uppercase = True
elif c in list(string.punctuation):
pwd_has_special_char = True
return len(password) < MIN_PASSWORD_LENGTH or not (pwd_has_lowercase and pwd_has_uppercase
and pwd_has_number and pwd_has_special_char)
def groups_remove_user(user, groups_name): def groups_remove_user(user, groups_name):
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
@ -92,9 +114,11 @@ def num_materias_por_tipo(qs, attr_tipo='tipo'):
qtdes = {} qtdes = {}
if attr_tipo == 'tipo': if attr_tipo == 'tipo':
def sort_function(m): return m.tipo def sort_function(m):
return m.tipo
else: else:
def sort_function(m): return m.materia.tipo def sort_function(m):
return m.materia.tipo
# select_related eh importante por questoes de desempenho, pois caso # select_related eh importante por questoes de desempenho, pois caso
# contrario ele realizara uma consulta ao banco para cada iteracao, # contrario ele realizara uma consulta ao banco para cada iteracao,
@ -113,12 +137,12 @@ def validar_arquivo(arquivo, nome_campo):
raise ValidationError( raise ValidationError(
"Certifique-se de que o nome do arquivo no " "Certifique-se de que o nome do arquivo no "
"campo '" + nome_campo + "' tenha no máximo 200 caracteres " "campo '" + nome_campo + "' tenha no máximo 200 caracteres "
"(ele possui {})".format(len(arquivo.name)) "(ele possui {})".format(len(arquivo.name))
) )
if arquivo.size > MAX_DOC_UPLOAD_SIZE: if arquivo.size > MAX_DOC_UPLOAD_SIZE:
raise ValidationError( raise ValidationError(
"O arquivo " + nome_campo + " deve ser menor que " "O arquivo " + nome_campo + " deve ser menor que "
"{0:.1f} mb, o tamanho atual desse arquivo é {1:.1f} mb".format( "{0:.1f} mb, o tamanho atual desse arquivo é {1:.1f} mb".format(
(MAX_DOC_UPLOAD_SIZE / 1024) / 1024, (MAX_DOC_UPLOAD_SIZE / 1024) / 1024,
(arquivo.size / 1024) / 1024 (arquivo.size / 1024) / 1024
) )
@ -148,7 +172,6 @@ def dont_break_out(value, max_part=50):
def clear_thumbnails_cache(queryset, field): def clear_thumbnails_cache(queryset, field):
for r in queryset: for r in queryset:
assert hasattr(r, field), _( assert hasattr(r, field), _(
'Objeto da listagem não possui o campo informado') 'Objeto da listagem não possui o campo informado')
@ -213,6 +236,7 @@ def montar_row_autor(name):
return autor_row return autor_row
# TODO: Esta função é utilizada? # TODO: Esta função é utilizada?
@ -284,7 +308,6 @@ class SaplGenericRelation(GenericRelation):
""" """
def __init__(self, to, fields_search=(), **kwargs): def __init__(self, to, fields_search=(), **kwargs):
assert 'related_query_name' in kwargs, _( assert 'related_query_name' in kwargs, _(
'SaplGenericRelation não pode ser instanciada sem ' 'SaplGenericRelation não pode ser instanciada sem '
'related_query_name.') 'related_query_name.')
@ -337,8 +360,8 @@ class RangeWidgetOverride(forms.MultiWidget):
) )
) )
html = '<div class="col-sm-6">%s</div><div class="col-sm-6">%s</div>'\ html = '<div class="col-sm-6">%s</div><div class="col-sm-6">%s</div>' \
% tuple(rendered_widgets) % tuple(rendered_widgets)
return '<div class="row">%s</div>' % html return '<div class="row">%s</div>' % html
@ -355,8 +378,8 @@ class CustomSplitDateTimeWidget(SplitDateTimeWidget):
) )
) )
html = '<div class="col-6">%s</div><div class="col-6">%s</div>'\ html = '<div class="col-6">%s</div><div class="col-6">%s</div>' \
% tuple(rendered_widgets) % tuple(rendered_widgets)
return '<div class="row">%s</div>' % html return '<div class="row">%s</div>' % html
@ -417,7 +440,6 @@ YES_NO_CHOICES = [(True, _('Sim')), (False, _('Não'))]
def listify(function): def listify(function):
@wraps(function) @wraps(function)
def f(*args, **kwargs): def f(*args, **kwargs):
return list(function(*args, **kwargs)) return list(function(*args, **kwargs))
@ -613,7 +635,6 @@ TIPOS_IMG_PERMITIDOS = (
def fabrica_validador_de_tipos_de_arquivo(lista, nome): def fabrica_validador_de_tipos_de_arquivo(lista, nome):
def restringe_tipos_de_arquivo(value): def restringe_tipos_de_arquivo(value):
filename = value.name if type(value) in ( filename = value.name if type(value) in (
@ -649,7 +670,6 @@ def intervalos_tem_intersecao(a_inicio, a_fim, b_inicio, b_fim):
class MateriaPesquisaOrderingFilter(django_filters.OrderingFilter): class MateriaPesquisaOrderingFilter(django_filters.OrderingFilter):
choices = ( choices = (
('', 'Selecione'), ('', 'Selecione'),
('dataC', 'Data, Tipo, Ano, Numero - Ordem Crescente'), ('dataC', 'Data, Tipo, Ano, Numero - Ordem Crescente'),
@ -675,7 +695,6 @@ class MateriaPesquisaOrderingFilter(django_filters.OrderingFilter):
class NormaPesquisaOrderingFilter(django_filters.OrderingFilter): class NormaPesquisaOrderingFilter(django_filters.OrderingFilter):
choices = ( choices = (
('', 'Selecione'), ('', 'Selecione'),
('dataC', 'Data, Tipo, Ano, Numero - Ordem Crescente'), ('dataC', 'Data, Tipo, Ano, Numero - Ordem Crescente'),
@ -733,7 +752,6 @@ class FileFieldCheckMixin(BaseForm):
class AnoNumeroOrderingFilter(django_filters.OrderingFilter): class AnoNumeroOrderingFilter(django_filters.OrderingFilter):
choices = (('DEC', 'Ordem Decrescente'), choices = (('DEC', 'Ordem Decrescente'),
('CRE', 'Ordem Crescente'),) ('CRE', 'Ordem Crescente'),)
order_by_mapping = { order_by_mapping = {
@ -774,8 +792,8 @@ def models_with_gr_for_model(model):
lambda x: x.related_model, lambda x: x.related_model,
filter( filter(
lambda obj: obj.is_relation and lambda obj: obj.is_relation and
hasattr(obj, 'field') and hasattr(obj, 'field') and
isinstance(obj, GenericRel), isinstance(obj, GenericRel),
model._meta.get_fields(include_hidden=True)) model._meta.get_fields(include_hidden=True))
)) ))
@ -802,9 +820,9 @@ def generic_relations_for_model(model):
lambda x: (x, lambda x: (x,
list(filter( list(filter(
lambda field: ( lambda field: (
isinstance( isinstance(
field, SaplGenericRelation) and field, SaplGenericRelation) and
field.related_model == model), field.related_model == model),
x._meta.get_fields(include_hidden=True)))), x._meta.get_fields(include_hidden=True)))),
models_with_gr_for_model(model) models_with_gr_for_model(model)
)) ))
@ -852,13 +870,13 @@ def texto_upload_path(instance, filename, subpath='', pk_first=False):
subpath = '_' subpath = '_'
path = str_path % \ path = str_path % \
{ {
'prefix': prefix, 'prefix': prefix,
'model_name': instance._meta.model_name, 'model_name': instance._meta.model_name,
'pk': instance.pk, 'pk': instance.pk,
'subpath': subpath, 'subpath': subpath,
'filename': filename 'filename': filename
} }
return path return path
@ -1040,7 +1058,6 @@ def remover_acentos(string):
def mail_service_configured(request=None): def mail_service_configured(request=None):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
if settings.EMAIL_RUNNING is None: if settings.EMAIL_RUNNING is None:
@ -1079,6 +1096,7 @@ def timing(f):
logger.info('funcao:%r args:[%r, %r] took: %2.4f sec' % logger.info('funcao:%r args:[%r, %r] took: %2.4f sec' %
(f.__name__, args, kw, te - ts)) (f.__name__, args, kw, te - ts))
return result return result
return wrap return wrap
@ -1150,7 +1168,6 @@ def get_tempfile_dir():
class GoogleRecapthaMixin: class GoogleRecapthaMixin:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -1163,9 +1180,9 @@ class GoogleRecapthaMixin:
row1 = to_row( row1 = to_row(
[ [
(Div( (Div(
css_class="g-recaptcha float-right", # if not settings.DEBUG else '', css_class="g-recaptcha float-right", # if not settings.DEBUG else '',
data_sitekey=AppConfig.attr('google_recaptcha_site_key') data_sitekey=AppConfig.attr('google_recaptcha_site_key')
), 5), ), 5),
('email', 7), ('email', 7),
] ]
@ -1296,7 +1313,6 @@ def get_path_to_name_report_map():
class MultiFormatOutputMixin: class MultiFormatOutputMixin:
formats_impl = 'csv', 'xlsx', 'json' formats_impl = 'csv', 'xlsx', 'json'
queryset_values_for_formats = True queryset_values_for_formats = True

Loading…
Cancel
Save