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 @@ import django_filters 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 @@ class AutorFilterSet(django_filters.FilterSet): 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 sapl.settings import MEDIA_URL, LOGOUT_REDIRECT_URL 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 @@ urlpatterns = [ 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 ratelimit.decorators import ratelimit 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 @@ class SaplSearchView(SearchView): 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/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/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 %}