Compare commits

...

6 Commits

Author SHA1 Message Date
Mira Weller
f8e5c7867b add log message with json path on schema validation error 2025-11-18 18:15:23 +01:00
Mira Weller
7010752bb0 Fix schema 2025-11-18 18:15:23 +01:00
Mira Weller
7d8bcd4b10 Migrate log action types to registry 2025-11-18 18:15:23 +01:00
Mira Weller
2121566ae5 Fix incorrect or missing log action types 2025-11-05 20:27:56 +01:00
Mira Weller
9f6216e6f1 Add some example schemas 2025-11-05 20:27:56 +01:00
Mira Weller
c824663946 Implement schema validation and schema-based shredding 2025-11-05 19:41:47 +01:00
6 changed files with 180 additions and 47 deletions

View File

@@ -800,7 +800,7 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
identifier=serializer.instance.identifier,
)
inst.log_action(
'pretix.sales_channel.changed',
'pretix.saleschannel.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,

View File

@@ -19,15 +19,20 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import json
import logging
from collections import defaultdict
from functools import cached_property
from typing import Optional
import jsonschema
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from pretix.base.signals import PluginAwareRegistry
logger = logging.getLogger(__name__)
def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
if a_map:
@@ -105,12 +110,38 @@ They are annotated with their ``action_type`` and the defining ``plugin``.
log_entry_types = LogEntryTypeRegistry()
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):
if self.data_schema:
print(self.__class__.__name__, "has schema", self._prepared_schema)
if action_type:
self.action_type = action_type
if plain:
@@ -147,12 +178,37 @@ class LogEntryType:
object_link_wrapper = '{val}'
def validate_data(self, parsed_data):
if not self._prepared_schema:
return
try:
jsonschema.validate(parsed_data, self._prepared_schema)
except jsonschema.exceptions.ValidationError as ex:
logger.warning("%s schema validation failed: %s %s", type(self).__name__, ex.json_path, ex.message)
raise
@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 NoOpShredderMixin:

View File

@@ -80,6 +80,7 @@ class LoggingMixin:
from pretix.api.models import OAuthAccessToken, OAuthApplication
from pretix.api.webhooks import notify_webhooks
from ..logentrytype_registry import log_entry_types
from ..services.notifications import notify
from .devices import Device
from .event import Event
@@ -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:

View File

@@ -503,7 +503,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.'),
@@ -535,7 +534,7 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
'pretix.event.order.checkin_attention': _('The order\'s flag to require attention at check-in has been '
'toggled.'),
'pretix.event.order.checkin_text': _('The order\'s check-in text has been changed.'),
'pretix.event.order.pretix.event.order.valid_if_pending': _('The order\'s flag to be considered valid even if '
'pretix.event.order.valid_if_pending': _('The order\'s flag to be considered valid even if '
'unpaid has been toggled.'),
'pretix.event.order.payment.changed': _('A new payment {local_id} has been started instead of the previous one.'),
'pretix.event.order.email.sent': _('An unidentified type email has been sent.'),
@@ -575,6 +574,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": ["null", "string"], "shred": False, },
"info": {"type": ["null", "string", "object"], "shred": True, },
"date": {"type": ["null", "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}.'),
@@ -585,13 +599,63 @@ class CoreOrderLogEntryType(OrderLogEntryType):
'pretix.voucher.added.waitinglist': _('The voucher has been assigned to {email} through the waiting list.'),
})
class CoreVoucherLogEntryType(VoucherLogEntryType):
pass
data_schema = {
"type": "object",
"properties": {
"item": {"type": ["null", "number"], "shred": False, },
"variation": {"type": ["null", "number"], "shred": False, },
"tag": {"type": "string", "shred": False,},
"block_quota": {"type": "boolean", "shred": False, },
"valid_until": {"type": ["null", "string"], "shred": False, },
"min_usages": {"type": "number", "shred": False, },
"max_usages": {"type": "number", "shred": False, },
"subevent": {"type": ["null", "number", "object"], "shred": False, },
"source": {"type": "string", "shred": False,},
"allow_ignore_quota": {"type": "boolean", "shred": False, },
"code": {"type": "string", "shred": False,},
"comment": {"type": "string", "shred": True,},
"price_mode": {"type": "string", "shred": False,},
"seat": {"type": "string", "shred": False,},
"quota": {"type": ["null", "number"], "shred": False,},
"value": {"type": ["null", "string"], "shred": False,},
"redeemed": {"type": "number", "shred": False,},
"all_addons_included": {"type": "boolean", "shred": False, },
"all_bundles_included": {"type": "boolean", "shred": False, },
"budget": {"type": ["null", "number"], "shred": False, },
"itemvar": {"type": "string", "shred": False,},
"show_hidden_items": {"type": "boolean", "shred": False, },
# bulk create:
"bulk": {"type": "boolean", "shred": False,},
"seats": {"type": "array", "shred": False,},
"send": {"type": ["string", "boolean"], "shred": False,},
"send_recipients": {"type": "array", "shred": True,},
"send_subject": {"type": "string", "shred": False,},
"send_message": {"type": "string", "shred": True,},
# pretix.voucher.sent
"recipient": {"type": "string", "shred": True,},
"name": {"type": "string", "shred": True,},
"subject": {"type": "string", "shred": False,},
"message": {"type": "string", "shred": True,},
# pretix.voucher.added.waitinglist
"email": {"type": "string", "shred": True,},
"waitinglistentry": {"type": "number", "shred": False, },
},
}
@log_entry_types.new()
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):
url = reverse('control:event.order', kwargs={
@@ -634,9 +698,16 @@ class TeamMembershipLogEntryType(LogEntryType):
'pretix.team.member.removed': _('{user} has been removed from the team.'),
'pretix.team.invite.created': _('{user} has been invited to the team.'),
'pretix.team.invite.resent': _('Invite for {user} has been resent.'),
'pretix.team.invite.deleted': _('Invite for {user} has been deleted.'),
})
class CoreTeamMembershipLogEntryType(TeamMembershipLogEntryType):
pass
data_schema = {
"type": "object",
"properties": {
"email": {"type": "string", "shred": True, },
"user": {"type": "number", "shred": False, },
},
}
@log_entry_types.new()
@@ -833,6 +904,10 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
'pretix.event.permissions.invited': _('A user has been invited to the event team.'),
'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'),
'pretix.event.permissions.deleted': _('A user has been removed from the event team.'),
'pretix.event.seats.blocks.changed': _('A seat in the seating plan has been blocked or unblocked.'),
'pretix.seatingplan.added': _('A seating plan has been added.'),
'pretix.seatingplan.changed': _('A seating plan has been changed.'),
'pretix.seatingplan.deleted': _('A seating plan has been deleted.'),
})
class CoreEventLogEntryType(EventLogEntryType):
pass

View File

@@ -26,6 +26,7 @@ from django.dispatch import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from pretix.base.logentrytype_registry import LogEntryType, log_entry_types
from pretix.base.models import Checkin, OrderPayment
from pretix.base.signals import (
checkin_created, event_copy_data, item_copy_data, logentry_display,
@@ -64,19 +65,13 @@ def nav_event_receiver(sender, request, **kwargs):
]
@receiver(signal=logentry_display)
def logentry_display_receiver(sender, logentry, **kwargs):
plains = {
"pretix.plugins.autocheckin.rule.added": _("An auto check-in rule was created"),
"pretix.plugins.autocheckin.rule.changed": _(
"An auto check-in rule was updated"
),
"pretix.plugins.autocheckin.rule.deleted": _(
"An auto check-in rule was deleted"
),
}
if logentry.action_type in plains:
return plains[logentry.action_type]
@log_entry_types.new_from_dict({
"pretix.plugins.autocheckin.rule.added": _("An auto check-in rule was created"),
"pretix.plugins.autocheckin.rule.changed": _("An auto check-in rule was updated"),
"pretix.plugins.autocheckin.rule.deleted": _("An auto check-in rule was deleted"),
})
class AutocheckinLogEntryType(LogEntryType):
pass
@receiver(item_copy_data, dispatch_uid="autocheckin_item_copy")

View File

@@ -31,6 +31,7 @@ from django.utils.translation import gettext_lazy as _
from paypalhttp import HttpResponse
from pretix.base.forms import SecretKeySettingsField
from pretix.base.logentrytype_registry import LogEntryType, log_entry_types
from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp
from pretix.base.settings import settings_hierarkey
from pretix.base.signals import (
@@ -80,37 +81,36 @@ def html_head_presale(sender, request=None, **kwargs):
return ""
@receiver(signal=logentry_display, dispatch_uid="stripe_logentry_display")
def pretixcontrol_logentry_display(sender, logentry, **kwargs):
if logentry.action_type != 'pretix.plugins.stripe.event':
return
@log_entry_types.new()
class StripeEvent(LogEntryType):
action_type = 'pretix.plugins.stripe.event'
data = json.loads(logentry.data)
event_type = data.get('type')
text = None
plains = {
'charge.succeeded': _('Charge succeeded.'),
'charge.refunded': _('Charge refunded.'),
'charge.updated': _('Charge updated.'),
'charge.pending': _('Charge pending'),
'source.chargeable': _('Payment authorized.'),
'source.canceled': _('Payment authorization canceled.'),
'source.failed': _('Payment authorization failed.')
}
def display(self, logentry, data):
event_type = data.get('type')
text = None
plains = {
'charge.succeeded': _('Charge succeeded.'),
'charge.refunded': _('Charge refunded.'),
'charge.updated': _('Charge updated.'),
'charge.pending': _('Charge pending'),
'source.chargeable': _('Payment authorized.'),
'source.canceled': _('Payment authorization canceled.'),
'source.failed': _('Payment authorization failed.')
}
if event_type in plains:
text = plains[event_type]
elif event_type == 'charge.failed':
text = _('Charge failed. Reason: {}').format(data['data']['object']['failure_message'])
elif event_type == 'charge.dispute.created':
text = _('Dispute created. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.updated':
text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.closed':
text = _('Dispute closed. Status: {}').format(data['data']['object']['status'])
if event_type in plains:
text = plains[event_type]
elif event_type == 'charge.failed':
text = _('Charge failed. Reason: {}').format(data['data']['object']['failure_message'])
elif event_type == 'charge.dispute.created':
text = _('Dispute created. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.updated':
text = _('Dispute updated. Reason: {}').format(data['data']['object']['reason'])
elif event_type == 'charge.dispute.closed':
text = _('Dispute closed. Status: {}').format(data['data']['object']['status'])
if text:
return _('Stripe reported an event: {}').format(text)
if text:
return _('Stripe reported an event: {}').format(text)
settings_hierarkey.add_default('payment_stripe_method_card', True, bool)