From 579aacae394e061e6ee97a48322e8c4beebdb022 Mon Sep 17 00:00:00 2001 From: Mira Weller Date: Tue, 17 Dec 2024 16:38:21 +0100 Subject: [PATCH] Implement schema validation --- src/pretix/base/logentrytypes.py | 52 +++++++++++++++++++++++++++++++- src/pretix/base/models/base.py | 7 +++++ src/pretix/base/signals.py | 16 ++++++---- src/pretix/control/logdisplay.py | 22 +++++++++++++- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/pretix/base/logentrytypes.py b/src/pretix/base/logentrytypes.py index c2c233c16..13dc1cf80 100644 --- a/src/pretix/base/logentrytypes.py +++ b/src/pretix/base/logentrytypes.py @@ -1,3 +1,4 @@ +import json # # This file is part of pretix (Community Edition). # @@ -20,7 +21,9 @@ # . # from collections import defaultdict +from functools import cached_property +import jsonschema from django.urls import reverse from django.utils.html import escape from django.utils.translation import gettext_lazy as _, pgettext_lazy @@ -89,13 +92,39 @@ They are annotated with their ``action_type`` and the defining ``plugin``. log_entry_types = LogEntryTypeRegistry({'action_type': lambda o: getattr(o, 'action_type')}) +def prepare_schema(schema): + def handle_properties(t): + return {"shred_properties": [k for k, v in t["properties"].items() if v["shred"]]} + + def walk_tree(schema): + if type(schema) is dict: + new_keys = {} + for k, v in schema.items(): + if k == "properties": + new_keys = handle_properties(schema) + walk_tree(v) + if schema.get("type") == "object" and "additionalProperties" not in new_keys: + new_keys["additionalProperties"] = False + schema.update(new_keys) + elif type(schema) is list: + for v in schema: + walk_tree(v) + + walk_tree(schema) + return schema + + class LogEntryType: """ Base class for a type of LogEntry, identified by its action_type. """ + data_schema = None # {"type": "object", "properties": []} + def __init__(self, action_type=None, plain=None): assert self.__module__ != LogEntryType.__module__ # must not instantiate base classes, only derived ones + if self.data_schema: + print(self.__class__.__name__, "has schema", self._prepared_schema) if action_type: self.action_type = action_type if plain: @@ -132,12 +161,33 @@ class LogEntryType: object_link_wrapper = '{val}' + def validate_data(self, parsed_data): + if not self._prepared_schema: + return + jsonschema.validate(parsed_data, self._prepared_schema) + + @cached_property + def _prepared_schema(self): + if self.data_schema: + return prepare_schema(self.data_schema) + def shred_pii(self, logentry): """ To be used for shredding personally identified information contained in the data field of a LogEntry of this type. """ - raise NotImplementedError + if self._prepared_schema: + def shred_fun(validator, value, instance, schema): + for key in value: + instance[key] = "##########" + + v = jsonschema.validators.extend(jsonschema.validators.Draft202012Validator, + validators={"shred_properties": shred_fun}) + data = logentry.parsed_data + jsonschema.validate(data, self._prepared_schema, v) + logentry.data = json.dumps(data) + else: + raise NotImplementedError class EventLogEntryType(LogEntryType): diff --git a/src/pretix/base/models/base.py b/src/pretix/base/models/base.py index c1c1fb553..057eb5e0b 100644 --- a/src/pretix/base/models/base.py +++ b/src/pretix/base/models/base.py @@ -31,6 +31,7 @@ from django.urls import reverse from django.utils.crypto import get_random_string from django.utils.functional import cached_property +from pretix.base.logentrytypes import log_entry_types from pretix.helpers.json import CustomJSONEncoder @@ -124,7 +125,13 @@ class LoggingMixin: if (sensitivekey in k) and v: data[k] = "********" + type, meta = log_entry_types.get(action_type=action) + if not type: + raise TypeError("Undefined log entry type '%s'" % action) + logentry.data = json.dumps(data, cls=CustomJSONEncoder, sort_keys=True) + + type.validate_data(json.loads(logentry.data)) elif data: raise TypeError("You should only supply dictionaries as log data.") if save: diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index f98406115..ff910b45f 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -33,14 +33,15 @@ # License for the specific language governing permissions and limitations under the License. import warnings -from typing import Any, Callable, List, Tuple +from typing import TYPE_CHECKING, Any, Callable, List, Tuple import django.dispatch from django.apps import apps from django.conf import settings from django.dispatch.dispatcher import NO_RECEIVERS -from .models import Event +if TYPE_CHECKING: + from .models import Event app_cache = {} @@ -103,13 +104,14 @@ class EventPluginSignal(django.dispatch.Signal): Event. """ - def send(self, sender: Event, **named) -> List[Tuple[Callable, Any]]: + def send(self, sender: "Event", **named) -> List[Tuple[Callable, Any]]: """ Send signal from sender to all connected receivers that belong to plugins enabled for the given Event. sender is required to be an instance of ``pretix.base.models.Event``. """ + from .models import Event if sender and not isinstance(sender, Event): raise ValueError("Sender needs to be an event.") @@ -126,7 +128,7 @@ class EventPluginSignal(django.dispatch.Signal): responses.append((receiver, response)) return responses - def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]: + def send_chained(self, sender: "Event", chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]: """ Send signal from sender to all connected receivers. The return value of the first receiver will be used as the keyword argument specified by ``chain_kwarg_name`` in the input to the @@ -134,6 +136,7 @@ class EventPluginSignal(django.dispatch.Signal): sender is required to be an instance of ``pretix.base.models.Event``. """ + from .models import Event if sender and not isinstance(sender, Event): raise ValueError("Sender needs to be an event.") @@ -150,7 +153,7 @@ class EventPluginSignal(django.dispatch.Signal): response = receiver(signal=self, sender=sender, **named) return response - def send_robust(self, sender: Event, **named) -> List[Tuple[Callable, Any]]: + def send_robust(self, sender: "Event", **named) -> List[Tuple[Callable, Any]]: """ Send signal from sender to all connected receivers. If a receiver raises an exception instead of returning a value, the exception is included as the result instead of @@ -158,6 +161,7 @@ class EventPluginSignal(django.dispatch.Signal): sender is required to be an instance of ``pretix.base.models.Event``. """ + from .models import Event if sender and not isinstance(sender, Event): raise ValueError("Sender needs to be an event.") @@ -195,7 +199,7 @@ class EventPluginSignal(django.dispatch.Signal): class GlobalSignal(django.dispatch.Signal): - def send_chained(self, sender: Event, chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]: + def send_chained(self, sender: "Event", chain_kwarg_name, **named) -> List[Tuple[Callable, Any]]: """ Send signal from sender to all connected receivers. The return value of the first receiver will be used as the keyword argument specified by ``chain_kwarg_name`` in the input to the diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 7c9f9aad8..24eb72496 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -403,7 +403,6 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl 'pretix.event.order.valid_if_pending.set': _('The order has been set to be usable before it is paid.'), 'pretix.event.order.valid_if_pending.unset': _('The order has been set to require payment before use.'), 'pretix.event.order.expired': _('The order has been marked as expired.'), - 'pretix.event.order.paid': _('The order has been marked as paid.'), 'pretix.event.order.cancellationrequest.deleted': _('The cancellation request has been deleted.'), 'pretix.event.order.refunded': _('The order has been refunded.'), 'pretix.event.order.reactivated': _('The order has been reactivated.'), @@ -469,6 +468,21 @@ class CoreOrderLogEntryType(OrderLogEntryType): pass +@log_entry_types.new() +class OrderPaidLogEntryType(CoreOrderLogEntryType): + action_type = 'pretix.event.order.paid' + plain = _('The order has been marked as paid.') + data_schema = { + "type": "object", + "properties": { + "provider": {"type": "string", "shred": False, }, + "info": {"type": ["null", "string", "object"], "shred": True, }, + "date": {"type": "string", "shred": False, }, + "force": {"type": "boolean", "shred": False, }, + }, + } + + @log_entry_types.new_from_dict({ 'pretix.voucher.added': _('The voucher has been created.'), 'pretix.voucher.sent': _('The voucher has been sent to {recipient}.'), @@ -486,6 +500,12 @@ class CoreVoucherLogEntryType(VoucherLogEntryType): class VoucherRedeemedLogEntryType(VoucherLogEntryType): action_type = 'pretix.voucher.redeemed' plain = _('The voucher has been redeemed in order {order_code}.') + data_schema = { + "type": "object", + "properties": { + "order_code": {"type": "string", "shred": False, }, + }, + } def display(self, logentry): data = json.loads(logentry.data)