From ca03a2556dbd66119e2967bf60e55ee2776de5a2 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 23 Jul 2026 17:35:18 +0200 Subject: [PATCH] Log view: New filter form (#6412) * Rework filter form for event log view * Log view: New filter form * isort * Update src/pretix/control/forms/filter.py Co-authored-by: luelista --------- Co-authored-by: luelista --- src/pretix/control/forms/filter.py | 91 ++++++++++++++++++- .../templates/pretixcontrol/event/logs.html | 35 +------ .../fragment_log_filter_form.html | 38 ++++++++ .../pretixcontrol/organizers/logs.html | 19 +--- src/pretix/control/views/event.py | 29 ++---- src/pretix/control/views/organizer.py | 16 +++- 6 files changed, 152 insertions(+), 76 deletions(-) create mode 100644 src/pretix/control/templates/pretixcontrol/fragment_log_filter_form.html diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 23288003d9..fc2b7ec3a9 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -39,6 +39,7 @@ from urllib.parse import urlencode from django import forms from django.apps import apps from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.db.models import ( Count, Exists, F, Max, Model, OrderBy, OuterRef, Q, QuerySet, ) @@ -59,7 +60,7 @@ from pretix.base.models import ( Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition, OrderRefund, Organizer, OutgoingMail, Question, QuestionAnswer, Quota, SalesChannel, SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, - Voucher, + User, Voucher, ) from pretix.base.signals import register_payment_providers from pretix.base.timeframes import ( @@ -3007,3 +3008,91 @@ class OutgoingMailFilterForm(FilterForm): qs = qs.order_by("-created", "-pk") return qs + + +class LogFilterForm(FilterForm): + source = forms.ChoiceField( + label=_('Source'), + choices=[ + ('', _('All sources')), + ('team', _('Team actions')), + ('customer', _('Customer actions')), + ('device', _('Device actions')), + ], + required=False + ) + device = SafeModelChoiceField( + label=_('Device'), + empty_label=_('All devices'), + queryset=Device.objects.none(), + required=False + ) + user_email = forms.EmailField( + label=_('User email'), + widget=forms.EmailInput( + attrs={"placeholder": _('All users')} + ), + required=False + ) + action_type = forms.CharField( + widget=forms.HiddenInput, + required=False, + ) + content_type = forms.ModelChoiceField( + queryset=ContentType.objects.all(), + widget=forms.HiddenInput, + required=False, + ) + object = forms.IntegerField( + widget=forms.HiddenInput, + required=False + ) + + def __init__(self, *args, **kwargs): + self.organizer = kwargs.pop('organizer') + super().__init__(*args, **kwargs) + + self.fields['device'].queryset = self.organizer.devices.all().order_by('device_id') + self.fields['device'].widget = Select2( + attrs={ + 'data-model-select2': 'generic', + 'data-select2-url': reverse('control:organizer.devices.select2', kwargs={ + 'organizer': self.organizer.slug, + }), + 'data-placeholder': _('All devices'), + } + ) + self.fields['device'].widget.choices = self.fields['device'].choices + self.fields['device'].label = _('Device') + + def filter_qs(self, qs): + fdata = self.cleaned_data + if fdata.get('source') == 'team': + qs = qs.filter(user__isnull=False) + elif fdata.get('source') == 'device': + qs = qs.filter(device__isnull=False) + elif fdata.get('source') == 'customer': + qs = qs.filter(user__isnull=True, device__isnull=True) + + if fdata.get('device'): + qs = qs.filter(device_id=fdata['device'].pk) + + if fdata.get('action_type'): + qs = qs.filter(action_type__in=fdata['action_type'].split(',')) + + if fdata.get('user_email'): + try: + user = User.objects.get(email=fdata['user_email'].lower()) + qs = qs.filter(user=user) + except User.DoesNotExist: + # Do not actively leak info that this user does not exist. Yes, this will likely have a + # timing attack possibility, but with the magic that database query planners do, + # it's probably impossible to avoid that. + qs = qs.none() + + if fdata.get('content_type'): + qs = qs.filter(content_type=fdata.get('content_type')) + + if fdata.get('object'): + qs = qs.filter(object_id=fdata.get('object')) + return qs diff --git a/src/pretix/control/templates/pretixcontrol/event/logs.html b/src/pretix/control/templates/pretixcontrol/event/logs.html index 7fae7b6f4b..cad5301095 100644 --- a/src/pretix/control/templates/pretixcontrol/event/logs.html +++ b/src/pretix/control/templates/pretixcontrol/event/logs.html @@ -1,41 +1,12 @@ {% extends "pretixcontrol/items/base.html" %} {% load i18n %} {% load static %} +{% load bootstrap3 %} +{% load icon %} {% block title %}{% trans "Event logs" %}{% endblock %} {% block inside %}

{% trans "Event logs" %}

-
- - -

- - -

-
+ {% include "pretixcontrol/fragment_log_filter_form.html" %}
    {% for log in logs %}
  • diff --git a/src/pretix/control/templates/pretixcontrol/fragment_log_filter_form.html b/src/pretix/control/templates/pretixcontrol/fragment_log_filter_form.html new file mode 100644 index 0000000000..aab4522485 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/fragment_log_filter_form.html @@ -0,0 +1,38 @@ +{% load i18n %} +{% load bootstrap3 %} +{% load icon %} +
    +
    +

    + {% trans "Filter" %} +

    +
    +
    +
    +
    + {% bootstrap_field filter_form.source %} +
    +
    + {% bootstrap_field filter_form.device %} +
    +
    + {% bootstrap_field filter_form.user_email %} +
    +
    + {{ filter_form.action_type }} + {{ filter_form.content_type }} + {{ filter_form.object }} + {% if filter_form.is_valid and filter_form.cleaned_data.action_type %} + {% icon "filter" %} {{ filter_form.cleaned_data.action_type }} + {% endif %} + {% if filter_form.is_valid and filter_form.cleaned_data.content_type and filter_form.cleaned_data.object %} + {% icon "filter" %} {% trans "Specific object selected" %} + {% endif %} +
    + +
    +
    +
    diff --git a/src/pretix/control/templates/pretixcontrol/organizers/logs.html b/src/pretix/control/templates/pretixcontrol/organizers/logs.html index aa3f32a631..258fee9521 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/logs.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/logs.html @@ -4,24 +4,7 @@ {% block title %}{% trans "Organizer logs" %}{% endblock %} {% block inside %}

    {% trans "Organizer logs" %}

    -
    - - -

    - - -

    -
    + {% include "pretixcontrol/fragment_log_filter_form.html" %}
      {% for log in logs %}
    • diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 90e685232c..632880da6e 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -60,7 +60,7 @@ from django.http import ( Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, JsonResponse, ) -from django.shortcuts import get_object_or_404, redirect +from django.shortcuts import redirect from django.urls import NoReverseMatch, reverse from django.utils.functional import cached_property from django.utils.html import conditional_escape, format_html @@ -119,6 +119,7 @@ from ...helpers.compat import CompatDeleteView from ...helpers.format import ( PlainHtmlAlternativeString, SafeFormatter, format_map, ) +from ..forms.filter import LogFilterForm from ..logdisplay import OVERVIEW_BANLIST from . import CreateView, PaginationMixin, UpdateView @@ -1282,32 +1283,20 @@ class EventLog(EventPermissionRequiredMixin, PaginationMixin, ListView): ] qs = qs.filter(content_type__in=allowed_types) - if self.request.GET.get('user') == 'yes': - qs = qs.filter(user__isnull=False) - elif self.request.GET.get('user') == 'no': - qs = qs.filter(user__isnull=True) - elif self.request.GET.get('user', '').startswith('d-'): - qs = qs.filter(device_id=self.request.GET.get('user')[2:]) - elif self.request.GET.get('user'): - qs = qs.filter(user_id=self.request.GET.get('user')) - - if self.request.GET.get('action_type'): - qs = qs.filter(action_type=self.request.GET['action_type']) - - if self.request.GET.get('content_type'): - qs = qs.filter(content_type=get_object_or_404(ContentType, pk=self.request.GET.get('content_type'))) - - if self.request.GET.get('object'): - qs = qs.filter(object_id=self.request.GET.get('object')) + if self.filter_form.is_valid(): + qs = self.filter_form.filter_qs(qs) return qs def get_context_data(self, **kwargs): ctx = super().get_context_data() - ctx['userlist'] = self.request.event.logentry_set.order_by().distinct().values('user__id', 'user__email') - ctx['devicelist'] = self.request.event.logentry_set.order_by('device__name').distinct().values('device__id', 'device__name') + ctx['filter_form'] = self.filter_form return ctx + @cached_property + def filter_form(self): + return LogFilterForm(data=self.request.GET, organizer=self.request.organizer) + class EventComment(EventPermissionRequiredMixin, View): permission = 'event.settings.general:write' diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 62f26c899a..3c04dae433 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -113,7 +113,8 @@ from pretix.base.views.tasks import AsyncAction from pretix.control.forms.exports import ScheduledOrganizerExportForm from pretix.control.forms.filter import ( CustomerFilterForm, DeviceFilterForm, EventFilterForm, GiftCardFilterForm, - OrganizerFilterForm, ReusableMediaFilterForm, TeamFilterForm, + LogFilterForm, OrganizerFilterForm, ReusableMediaFilterForm, + TeamFilterForm, ) from pretix.control.forms.orders import ExporterForm from pretix.control.forms.organizer import ( @@ -2672,16 +2673,21 @@ class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView): 'user', 'content_type', 'api_token', 'oauth_application', 'device' ).order_by('-datetime') qs = qs.exclude(action_type__in=OVERVIEW_BANLIST) - if self.request.GET.get('action_type'): - qs = qs.filter(action_type=self.request.GET['action_type']) - if self.request.GET.get('user'): - qs = qs.filter(user_id=self.request.GET.get('user')) + + if self.filter_form.is_valid(): + qs = self.filter_form.filter_qs(qs) + return qs def get_context_data(self, **kwargs): ctx = super().get_context_data() + ctx['filter_form'] = self.filter_form return ctx + @cached_property + def filter_form(self): + return LogFilterForm(data=self.request.GET, organizer=self.request.organizer) + class MembershipTypeListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView): model = MembershipType