diff --git a/docker/start.sh b/docker/start.sh index 57b83b484..558b7d7b5 100755 --- a/docker/start.sh +++ b/docker/start.sh @@ -114,6 +114,9 @@ if [ $lack_pwd -eq 0 ]; then # return -1 fi +# Backfilling AuditLog's JSON field +time ./manage.py backfill_auditlog & + echo "-------------------------------------" echo "| ███████╗ █████╗ ██████╗ ██╗ |" echo "| ██╔════╝██╔══██╗██╔══██╗██║ |" diff --git a/sapl/base/forms.py b/sapl/base/forms.py index f6475e269..044e1dcad 100644 --- a/sapl/base/forms.py +++ b/sapl/base/forms.py @@ -21,7 +21,7 @@ from haystack.forms import ModelSearchForm from sapl.audiencia.models import AudienciaPublica -from sapl.base.models import Autor, TipoAutor, OperadorAutor +from sapl.base.models import Autor, AuditLog, TipoAutor, OperadorAutor from sapl.comissoes.models import Reuniao from sapl.crispy_layout_mixin import (form_actions, to_column, to_row, SaplFormHelper, SaplFormLayout) @@ -741,6 +741,48 @@ def __init__(self, *args, **kwargs): form_actions(label='Pesquisar'))) +def get_username(): + return [(u, u) for u in get_user_model().objects.all().order_by('username').values_list('username', flat=True)] + + +def get_models(): + return [(m, m) for m in AuditLog.objects.distinct('model_name').order_by('model_name').values_list('model_name', flat=True)] + + +class AuditLogFilterSet(django_filters.FilterSet): + OPERATION_CHOICES = ( + ('U', 'Atualizado'), + ('C', 'Criado'), + ('D', 'Excluído'), + ) + + username = django_filters.ChoiceFilter(choices=get_username(), label=_('Usuário')) + object_id = django_filters.NumberFilter(label=_('Id')) + operation = django_filters.ChoiceFilter(choices=OPERATION_CHOICES, label=_('Operação')) + model_name = django_filters.ChoiceFilter(choices=get_models, label=_('Tipo de Registro')) + timestamp = django_filters.DateRangeFilter(label=_('Período')) + + class Meta: + model = AuditLog + fields = ['username', 'operation', 'model_name', 'timestamp', 'object_id'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + row0 = to_row([('username', 2), + ('operation', 2), + ('model_name', 4), + ('object_id', 2), + ('timestamp', 2)]) + + self.form.helper = SaplFormHelper() + self.form.helper.form_method = 'GET' + self.form.helper.layout = Layout( + Fieldset(_('Filtros'), + row0, + form_actions(label='Aplicar Filtro'))) + + class OperadorAutorForm(ModelForm): class Meta: diff --git a/sapl/base/management/commands/backfill_auditlog.py b/sapl/base/management/commands/backfill_auditlog.py new file mode 100644 index 000000000..ba8bb6037 --- /dev/null +++ b/sapl/base/management/commands/backfill_auditlog.py @@ -0,0 +1,38 @@ +import json +import logging + +from django.core.management.base import BaseCommand +from sapl.base.models import AuditLog + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + def handle(self, **options): + print("Backfilling AuditLog JSON Field...") + logs = AuditLog.objects.filter(data__isnull=True) + error_counter = 0 + if logs: + update_list = [] + for log in logs: + try: + obj = log.object[1:-1] \ + if log.object.startswith('[') else log.object + data = json.loads(obj) + log.data = data + except Exception as e: + error_counter += 1 + logging.error(e) + log.data = None + else: + update_list.append(log) + if len(update_list) == 1000: + AuditLog.objects.bulk_update(update_list, ['data']) + update_list = [] + if update_list: + AuditLog.objects.bulk_update(update_list, ['data']) + print(f"Logs backfilled: {len(logs) - error_counter}") + print(f"Logs with errors: {error_counter}") + print("Finished backfilling") + + diff --git a/sapl/base/migrations/0056_auto_20221118_1330.py b/sapl/base/migrations/0056_auto_20221118_1330.py new file mode 100644 index 000000000..d1d6d0036 --- /dev/null +++ b/sapl/base/migrations/0056_auto_20221118_1330.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.28 on 2022-11-18 16:30 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0055_appconfig_mostrar_voto'), + ] + + operations = [ + migrations.AlterModelOptions( + name='auditlog', + options={'ordering': ('-id', '-timestamp'), 'verbose_name': 'AuditLog', 'verbose_name_plural': 'AuditLogs'}, + ), + migrations.AddField( + model_name='auditlog', + name='data', + field=django.contrib.postgres.fields.jsonb.JSONField(null=True, verbose_name='data'), + ), + ] diff --git a/sapl/base/models.py b/sapl/base/models.py index 9b8f79faa..e60ad589d 100644 --- a/sapl/base/models.py +++ b/sapl/base/models.py @@ -419,12 +419,15 @@ class AuditLog(models.Model): db_index=True) timestamp = models.DateTimeField(verbose_name=_('timestamp'), db_index=True) + # DEPRECATED FIELD! TO BE REMOVED (EVENTUALLY) object = models.CharField(max_length=MAX_DATA_LENGTH, blank=True, verbose_name=_('object')) + data = JSONField(null=True, verbose_name=_('data')) object_id = models.PositiveIntegerField(verbose_name=_('object_id'), db_index=True) - model_name = models.CharField(max_length=100, verbose_name=_('model'), + model_name = models.CharField(max_length=100, + verbose_name=_('model'), db_index=True) app_name = models.CharField(max_length=100, verbose_name=_('app'), @@ -433,7 +436,7 @@ class AuditLog(models.Model): class Meta: verbose_name = _('AuditLog') verbose_name_plural = _('AuditLogs') - ordering = ('-id',) + ordering = ('-id', '-timestamp') def __str__(self): return "[%s] %s %s.%s %s" % (self.timestamp, diff --git a/sapl/base/receivers.py b/sapl/base/receivers.py index 0813ccf9a..d7577ac04 100644 --- a/sapl/base/receivers.py +++ b/sapl/base/receivers.py @@ -120,10 +120,15 @@ def audit_log_function(sender, **kwargs): model_name = instance.__class__.__name__ app_name = instance._meta.app_label object_id = instance.id - data = serializers.serialize('json', [instance]) - - if len(data) > AuditLog.MAX_DATA_LENGTH: - data = data[:AuditLog.MAX_DATA_LENGTH] + try: + import json + # [1:-1] below removes the surrounding square brackets + str_data = serializers.serialize('json', [instance])[1:-1] + data = json.loads(str_data) + except: + # old version capped string at AuditLog.MAX_DATA_LENGTH + # so there can be invalid json fields in Prod. + data = None if user: username = user.username @@ -136,7 +141,8 @@ def audit_log_function(sender, **kwargs): app_name=app_name, timestamp=timezone.now(), object_id=object_id, - object=data) + object='', + data=data) except Exception as e: logger.error('Error saving auditing log object') logger.error(e) diff --git a/sapl/base/templatetags/common_tags.py b/sapl/base/templatetags/common_tags.py index f5b9c2c36..b1d804cc0 100644 --- a/sapl/base/templatetags/common_tags.py +++ b/sapl/base/templatetags/common_tags.py @@ -29,6 +29,17 @@ def define(arg): return arg +@register.simple_tag +def describe_operation(value): + if value == "C": + return "Criar" + elif value == "D": + return "Apagar" + elif value == "U": + return "Atualizar" + return "" + + @register.simple_tag def field_verbose_name(instance, field_name): return instance._meta.get_field(field_name).verbose_name @@ -51,6 +62,25 @@ def model_verbose_name_plural(class_name): model = get_class(class_name) return model._meta.verbose_name_plural + +@register.filter +def obfuscate_value(value, key): + if key in ["hash", "google_recaptcha_secret_key", "password", "google_recaptcha_site_key", "hash_code"]: + return "***************" + return value + + +@register.filter +def desc_operation(value): + if value == "C": + return "Criado" + elif value == "D": + return "Excluido" + elif value == "U": + return "Atualizado" + return "" + + @register.filter def format_user(user): if user.first_name: diff --git a/sapl/base/urls.py b/sapl/base/urls.py index 058264049..7a527f102 100644 --- a/sapl/base/urls.py +++ b/sapl/base/urls.py @@ -15,7 +15,7 @@ from .apps import AppConfig from .forms import LoginForm from .views import (LoginSapl, AlterarSenha, AppConfigCrud, CasaLegislativaCrud, - HelpTopicView, LogotipoView, RelatorioAtasView, + HelpTopicView, LogotipoView, RelatorioAtasView, PesquisarAuditLogView, RelatorioAudienciaView, RelatorioDataFimPrazoTramitacaoView, RelatorioHistoricoTramitacaoView, RelatorioMateriasPorAnoAutorTipoView, RelatorioMateriasPorAutorView, RelatorioMateriasTramitacaoView, RelatorioPresencaSessaoView, RelatorioReuniaoView, SaplSearchView, @@ -179,6 +179,8 @@ url(r'^sistema/search/', SaplSearchView(), name='haystack_search'), + url(r'^sistema/auditlog/$', PesquisarAuditLogView.as_view(), name='pesquisar_auditlog'), + # Folhas XSLT e extras referenciadas por documentos migrados do sapl 2.5 url(r'^(sapl/)?XSLT/HTML/(?P.*)$', RedirectView.as_view( url=os.path.join(MEDIA_URL, 'sapl/public/XSLT/HTML/%(path)s'), diff --git a/sapl/base/views.py b/sapl/base/views.py index 2c549f355..3bbe7d6ac 100644 --- a/sapl/base/views.py +++ b/sapl/base/views.py @@ -39,9 +39,9 @@ from sapl import settings from sapl.audiencia.models import AudienciaPublica, TipoAudienciaPublica from sapl.base.forms import (AutorForm, TipoAutorForm, AutorFilterSet, RecuperarSenhaForm, - NovaSenhaForm, UserAdminForm, + NovaSenhaForm, UserAdminForm, AuditLogFilterSet, OperadorAutorForm, LoginForm, SaplSearchForm) -from sapl.base.models import Autor, TipoAutor, OperadorAutor +from sapl.base.models import AuditLog, Autor, TipoAutor, OperadorAutor from sapl.comissoes.models import Comissao, Reuniao from sapl.crud.base import CrudAux, make_pagination, Crud,\ ListWithSearchForm, MasterDetailCrud @@ -2256,6 +2256,77 @@ def get_context(self): return context +class PesquisarAuditLogView(FilterView): + model = AuditLog + filterset_class = AuditLogFilterSet + paginate_by = 20 + + permission_required = ('base.list_appconfig',) + + def get_filterset_kwargs(self, filterset_class): + super(PesquisarAuditLogView, self).get_filterset_kwargs( + filterset_class + ) + + return ({ + "data": self.request.GET or None, + "queryset": self.get_queryset().order_by("-id") + }) + + def get_context_data(self, **kwargs): + context = super(PesquisarAuditLogView, self).get_context_data( + **kwargs + ) + + paginator = context["paginator"] + page_obj = context["page_obj"] + + qr = self.request.GET.copy() + if 'page' in qr: + del qr['page'] + context['filter_url'] = ('&' + qr.urlencode()) if len(qr) > 0 else '' + context['show_results'] = show_results_filter_set(qr) + + context.update({ + "page_range": make_pagination( + page_obj.number, paginator.num_pages + ), + "NO_ENTRIES_MSG": "Nenhum registro de log encontrado!", + "title": _("Pesquisar Logs de Auditoria") + }) + + return context + + def get(self, request, *args, **kwargs): + super(PesquisarAuditLogView, self).get(request) + + data = self.filterset.data + + url = '' + + if data: + url = '&' + str(self.request.META["QUERY_STRING"]) + if url.startswith("&page"): + url = '' + + resultados = self.object_list + # if 'page' in self.request.META['QUERY_STRING']: + # resultados = self.object_list + # else: + # resultados = [] + + context = self.get_context_data(filter=self.filterset, + object_list=resultados, + filter_url=url, + numero_res=len(resultados) + ) + + context['show_results'] = show_results_filter_set( + self.request.GET.copy()) + + return self.render_to_response(context) + + class AlterarSenha(FormView): from sapl.settings import LOGIN_URL diff --git a/sapl/relatorios/urls.py b/sapl/relatorios/urls.py index 41b1de4f3..03db4c6e0 100644 --- a/sapl/relatorios/urls.py +++ b/sapl/relatorios/urls.py @@ -6,7 +6,8 @@ relatorio_etiqueta_protocolo, relatorio_materia, relatorio_ordem_dia, relatorio_pauta_sessao, relatorio_protocolo, relatorio_sessao_plenaria, - resumo_ata_pdf, relatorio_sessao_plenaria_pdf, etiqueta_materia_legislativa) + resumo_ata_pdf, relatorio_sessao_plenaria_pdf, etiqueta_materia_legislativa, + relatorio_materia_tramitacao) app_name = AppConfig.name @@ -41,4 +42,7 @@ relatorio_sessao_plenaria_pdf, name='relatorio_sessao_plenaria_pdf'), url(r'^relatorios/(?P\d+)/etiqueta-materia-legislativa$', etiqueta_materia_legislativa, name='etiqueta_materia_legislativa'), + + url(r'^relatorios/(?P\d+)/materia-tramitacao$', + relatorio_materia_tramitacao, name='relatorio_materia_tramitacao'), ] diff --git a/sapl/relatorios/views.py b/sapl/relatorios/views.py index 2ad08eea6..0bda27f45 100755 --- a/sapl/relatorios/views.py +++ b/sapl/relatorios/views.py @@ -113,7 +113,7 @@ def get_materias(mats): for materia in mats: dic = {} dic['titulo'] = materia.tipo.sigla + " " + materia.tipo.descricao \ - + " " + str(materia.numero) + "/" + str(materia.ano) + + " " + str(materia.numero) + "/" + str(materia.ano) dic['txt_ementa'] = materia.ementa dic['nom_autor'] = ', '.join( @@ -533,9 +533,9 @@ def get_sessao_plenaria(sessao, casa, user): # Lista da composicao da mesa diretora lst_mesa = [] - for composicao in IntegranteMesa.objects.select_related('parlamentar', 'cargo')\ - .filter(sessao_plenaria=sessao)\ - .order_by('cargo_id'): + for composicao in IntegranteMesa.objects.select_related('parlamentar', 'cargo') \ + .filter(sessao_plenaria=sessao) \ + .order_by('cargo_id'): partido_sigla = Filiacao.objects.filter( parlamentar=composicao.parlamentar).first() sigla = '' if not partido_sigla else partido_sigla.partido.sigla @@ -647,7 +647,7 @@ def get_sessao_plenaria(sessao, casa, user): materia=expediente_materia.materia).first() if numeracao: dic_expediente_materia["des_numeracao"] = ( - str(numeracao.numero_materia) + '/' + str(numeracao.ano_materia)) + str(numeracao.numero_materia) + '/' + str(numeracao.ano_materia)) autoria = materia.autoria_set.all() dic_expediente_materia['num_autores'] = 'Autores' if len( @@ -691,8 +691,8 @@ def get_sessao_plenaria(sessao, casa, user): # Lista dos votos nominais das matérias do Expediente lst_expediente_materia_vot_nom = [] - materias_expediente_votacao_nominal = ExpedienteMateria.objects.filter(sessao_plenaria=sessao, tipo_votacao=2)\ - .order_by('-materia') + materias_expediente_votacao_nominal = ExpedienteMateria.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \ + .order_by('-materia') for mevn in materias_expediente_votacao_nominal: votos_materia = [] @@ -724,8 +724,8 @@ def get_sessao_plenaria(sessao, casa, user): # Lista presença na ordem do dia lst_presenca_ordem_dia = [] - presenca_ordem_dia = PresencaOrdemDia.objects.filter(sessao_plenaria=sessao)\ - .order_by('parlamentar__nome_parlamentar') + presenca_ordem_dia = PresencaOrdemDia.objects.filter(sessao_plenaria=sessao) \ + .order_by('parlamentar__nome_parlamentar') for parlamentar in [p.parlamentar for p in presenca_ordem_dia]: lst_presenca_ordem_dia.append({ "nom_parlamentar": parlamentar.nome_parlamentar, @@ -741,17 +741,17 @@ def get_sessao_plenaria(sessao, casa, user): "nom_resultado": '', "num_ordem": votacao.numero_ordem, "id_materia": ( - materia.tipo.sigla + ' ' + - materia.tipo.descricao + ' ' + - str(materia.numero) + '/' + - str(materia.ano)), + materia.tipo.sigla + ' ' + + materia.tipo.descricao + ' ' + + str(materia.numero) + '/' + + str(materia.ano)), "des_numeracao": ' ' } numeracao = materia.numeracao_set.first() if numeracao: dic_votacao["des_numeracao"] = ( - str(numeracao.numero_materia) + '/' + str(numeracao.ano_materia)) + str(numeracao.numero_materia) + '/' + str(numeracao.ano_materia)) materia_em_tramitacao = materia.materiaemtramitacao_set.first() dic_votacao.update({ @@ -804,8 +804,8 @@ def get_sessao_plenaria(sessao, casa, user): # Lista dos votos nominais das matérias da Ordem do Dia lst_votacao_vot_nom = [] - materias_ordem_dia_votacao_nominal = OrdemDia.objects.filter(sessao_plenaria=sessao, tipo_votacao=2)\ - .order_by('-materia') + materias_ordem_dia_votacao_nominal = OrdemDia.objects.filter(sessao_plenaria=sessao, tipo_votacao=2) \ + .order_by('-materia') for modvn in materias_ordem_dia_votacao_nominal: votos_materia_od = [] @@ -1015,10 +1015,10 @@ def get_protocolos(prots): ts = timezone.localtime(protocolo.timestamp) if protocolo.timestamp: dic['data'] = ts.strftime("%d/%m/%Y") + ' - Horário:' + \ - ts.strftime("%H:%m") + ts.strftime("%H:%m") else: dic['data'] = protocolo.data.strftime("%d/%m/%Y") + ' - Horário:' \ - + protocolo.hora.strftime("%H:%m") + + protocolo.hora.strftime("%H:%m") dic['txt_assunto'] = protocolo.assunto_ementa @@ -1146,7 +1146,7 @@ def get_etiqueta_protocolos(prots): for materia in MateriaLegislativa.objects.filter( numero_protocolo=p.numero, ano=p.ano): dic['num_materia'] = materia.tipo.sigla + ' ' + \ - str(materia.numero) + '/' + str(materia.ano) + str(materia.numero) + '/' + str(materia.ano) dic['natureza'] = '' if p.tipo_processo == 0: @@ -1158,7 +1158,7 @@ def get_etiqueta_protocolos(prots): for documento in DocumentoAdministrativo.objects.filter( protocolo=p): dic['num_documento'] = documento.tipo.sigla + ' ' + \ - str(documento.numero) + '/' + str(documento.ano) + str(documento.numero) + '/' + str(documento.ano) dic['ident_processo'] = dic['num_materia'] or dic['num_documento'] @@ -1224,7 +1224,7 @@ def get_pauta_sessao(sessao, casa): dic_expediente_materia = {} dic_expediente_materia["tipo_materia"] = materia.tipo.sigla + \ - ' - ' + materia.tipo.descricao + ' - ' + materia.tipo.descricao dic_expediente_materia["num_ordem"] = str( expediente_materia.numero_ordem) dic_expediente_materia["id_materia"] = str( @@ -1268,7 +1268,7 @@ def get_pauta_sessao(sessao, casa): id=votacao.materia.id).first() dic_votacao = {} dic_votacao["tipo_materia"] = materia.tipo.sigla + \ - ' - ' + materia.tipo.descricao + ' - ' + materia.tipo.descricao dic_votacao["num_ordem"] = votacao.numero_ordem dic_votacao["id_materia"] = str( materia.numero) + "/" + str(materia.ano) @@ -1696,3 +1696,32 @@ def etiqueta_materia_legislativa(request, pk): response.write(pdf_file) return response + + +def relatorio_materia_tramitacao(request, pk): + base_url = request.build_absolute_uri() + materia_legislativa = MateriaLegislativa.objects.get(pk=pk) + tramitacoes = Tramitacao.objects.filter(materia=materia_legislativa) + casa = CasaLegislativa.objects.first() + rodape = ' '.join(get_rodape(casa)) + + context = {} + context.update({'object': materia_legislativa}) + context.update({'data': dt.today().strftime('%d/%m/%Y')}) + context.update({'rodape': rodape}) + header_context = {"casa": casa, + 'logotipo': casa.logotipo, 'MEDIA_URL': MEDIA_URL} + + html_template = render_to_string('crud/list.html', context) + html_header = render_to_string( + 'relatorios/header_ata.html', header_context) + + pdf_file = make_pdf( + base_url=base_url, main_template=html_template, header_template=html_header) + + response = HttpResponse(content_type='application/pdf;') + response['Content-Disposition'] = 'inline; filename=relatorio.pdf' + response['Content-Transfer-Encoding'] = 'binary' + response.write(pdf_file) + + return response diff --git a/sapl/templates/base/auditlog_filter.html b/sapl/templates/base/auditlog_filter.html new file mode 100644 index 000000000..ef4b7ef77 --- /dev/null +++ b/sapl/templates/base/auditlog_filter.html @@ -0,0 +1,88 @@ +{% extends "crud/list.html" %} +{% load i18n common_tags %} +{% load tz %} +{% load crispy_forms_tags staticfiles %} + +{% block head_extra_css %} +created { +background-color: green; + color: #FFF; +} + +deleted { + background-color: red; + color: #FFF; +} +{% endblock head_extra_css %} + +{% block base_content %} + {% crispy filter.form %} +
+ {% if numero_res > 0 %} + {% if numero_res == 1 %} +

Foi encontrado {{ numero_res }} resultado

+ {% else %} +

Foram encontrados {{ numero_res }} resultados

+ {% endif %} + + + + + + + + + + + + + {% for obj in page_obj %} + + + + + + + + + {% endfor %} + +
Data/HoraUsuárioOperaçãoRegistroId 
{{ obj.timestamp|localtime|date:"d/m/Y, H:i:s" }}{{ obj.username|default:"Não informado" }}{{ obj.operation|desc_operation }}{{ obj.model_name }}{{obj.data.pk}} + Atributos ({{obj.data.fields|length}})
+
+
    + {% for key, value in obj.data.fields.items %} + {% if forloop.counter == 11 %} + + + {% endif %} + {% endfor %} +
+
+ {% else %} +

{{ NO_ENTRIES_MSG }}

+ {% endif %} +
+ {% include 'paginacao.html'%} +


+{% endblock base_content %} +{% block extra_js %} + +{% endblock extra_js %} diff --git a/sapl/templates/materia/tramitacao_list.html b/sapl/templates/materia/tramitacao_list.html new file mode 100644 index 000000000..1f4593430 --- /dev/null +++ b/sapl/templates/materia/tramitacao_list.html @@ -0,0 +1,14 @@ +{% extends "crud/list.html" %} +{% load i18n %} +{% load common_tags %} + + +{% block more_buttons %} + +{% if perms|get_add_perm:view %} + + {% trans "Imprimir" %} + +{% endif %} + +{% endblock more_buttons %} diff --git a/sapl/templates/navbar.yaml b/sapl/templates/navbar.yaml index 58a83d126..bca7a7007 100644 --- a/sapl/templates/navbar.yaml +++ b/sapl/templates/navbar.yaml @@ -97,6 +97,9 @@ - title: {% trans 'Inconsistências de Dados' %} url: {% url 'sapl.base:lista_inconsistencias' %} check_permission: user.is_superuser + - title: {% trans 'Logs de Auditoria' %} + url: {% url 'sapl.base:pesquisar_auditlog' %} + check_permission: user.is_superuser {% comment %} diff --git a/sapl/utils.py b/sapl/utils.py index 88eba024c..0770b4e15 100644 --- a/sapl/utils.py +++ b/sapl/utils.py @@ -22,7 +22,8 @@ GenericRelation) from django.core.exceptions import ValidationError from django.core.files.storage import FileSystemStorage -from django.core.files.uploadedfile import UploadedFile +from django.core.files.uploadedfile import UploadedFile, InMemoryUploadedFile,\ + TemporaryUploadedFile from django.core.mail import get_connection from django.db import models from django.db.models import Q @@ -611,7 +612,12 @@ class FilterOverridesMetaMixin: def fabrica_validador_de_tipos_de_arquivo(lista, nome): def restringe_tipos_de_arquivo(value): - if not os.path.splitext(value.path)[1][:1]: + + filename = value.name if type(value) in ( + InMemoryUploadedFile, TemporaryUploadedFile + ) else value.path + + if not os.path.splitext(filename)[1][:1]: raise ValidationError(_( 'Não é possível fazer upload de arquivos sem extensão.')) try: