Compare commits

...

5 Commits

Author SHA1 Message Date
Mira Weller
1736efbdc3 Preliminary migration 2025-01-24 14:08:13 +01:00
Mira Weller
5cd7959e86 Revert "Implement hidden_if_item_available_mode option (Z#23177008) (#4776)"
This reverts commit b847612e1a.
2025-01-24 14:07:55 +01:00
Mira
b847612e1a Implement hidden_if_item_available_mode option (Z#23177008) (#4776) 2025-01-24 11:24:50 +01:00
Mira
832f4e4d68 Define LogEntryTypes for all actions in pretix core, improve content_object handling (#4768)
Create LogEntryType definitions for all missing action_types (order changes, check-in events, settings changes of PaymentProviders and TicketOutputs).

Check whether the stored content_object is of the expected model type, preventing incorrect links.

Refactoring:
-    Move the base LogEntryType definitions for our models to their own file
-    Move HTML escaping into make_link to make it less likely to oversee in the LogEntryType definitions
-    Log pretix.event.order.deleted with the deleted Order model as content_object, matching the other *.deleted action_types
2025-01-24 10:05:19 +01:00
Mira
0a23aeece4 Allow 0% tax rate on event creation (#4756)
(but still warn if tax rate is not filled at all)
2025-01-23 12:59:39 +01:00
9 changed files with 532 additions and 405 deletions

View File

@@ -0,0 +1,165 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
from collections import defaultdict
from typing import Optional
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 EventPluginRegistry
def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
if a_map:
if 'href' not in a_map:
a_map['val'] = format_html('<i>{val}</i>', **a_map)
elif is_active:
a_map['val'] = format_html('<a href="{href}">{val}</a>', **a_map)
elif event and plugin_name:
a_map['val'] = format_html(
'<i>{val}</i> <a href="{plugin_href}">'
'<span data-toggle="tooltip" title="{errmes}" class="fa fa-warning fa-fw"></span></a>',
**a_map,
errmes=_("The relevant plugin is currently not active. To activate it, click here to go to the plugin settings."),
plugin_href=reverse('control:event.settings.plugins', kwargs={
'organizer': event.organizer.slug,
'event': event.slug,
}) + '#plugin_' + plugin_name,
)
else:
a_map['val'] = format_html(
'<i>{val}</i> <span data-toggle="tooltip" title="{errmes}" class="fa fa-warning fa-fw"></span>',
**a_map,
errmes=_("The relevant plugin is currently not active."),
)
return format_html(wrapper, **a_map)
class LogEntryTypeRegistry(EventPluginRegistry):
def __init__(self):
super().__init__({'action_type': lambda o: getattr(o, 'action_type')})
def register(self, *objs):
for obj in objs:
if not isinstance(obj, LogEntryType):
raise TypeError('Entries must be derived from LogEntryType')
if obj.__module__.startswith('pretix.base.'):
raise TypeError('Must not register base classes, only derived ones')
return super().register(*objs)
def new_from_dict(self, data):
"""
Register multiple instance of a `LogEntryType` class with different `action_type`
and plain text strings, as given by the items of the specified data dictionary.
This method is designed to be used as a decorator as follows:
.. code-block:: python
@log_entry_types.new_from_dict({
'pretix.event.item.added': _('The product has been created.'),
'pretix.event.item.changed': _('The product has been changed.'),
# ...
})
class CoreItemLogEntryType(ItemLogEntryType):
# ...
:param data: action types and descriptions
``{"some_action_type": "Plain text description", ...}``
"""
def reg(clz):
for action_type, plain in data.items():
self.register(clz(action_type=action_type, plain=plain))
return clz
return reg
"""
Registry for LogEntry types.
Each entry in this registry should be an instance of a subclass of ``LogEntryType``.
They are annotated with their ``action_type`` and the defining ``plugin``.
"""
log_entry_types = LogEntryTypeRegistry()
class LogEntryType:
"""
Base class for a type of LogEntry, identified by its action_type.
"""
def __init__(self, action_type=None, plain=None):
if action_type:
self.action_type = action_type
if plain:
self.plain = plain
def display(self, logentry, data):
"""
Returns the message to be displayed for a given logentry of this type.
:return: `str` or `LazyI18nString`
"""
if hasattr(self, 'plain'):
plain = str(self.plain)
if '{' in plain:
data = defaultdict(lambda: '?', data)
return plain.format_map(data)
else:
return plain
def get_object_link_info(self, logentry) -> Optional[dict]:
"""
Return information to generate a link to the `content_object` of a given log entry.
Not implemented in the base class, causing the object link to be omitted.
:return: Dictionary with the keys ``href`` (URL to view/edit the object) and
``val`` (text for the anchor element)
"""
pass
def get_object_link(self, logentry):
a_map = self.get_object_link_info(logentry)
return make_link(a_map, self.object_link_wrapper)
object_link_wrapper = '{val}'
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
class NoOpShredderMixin:
def shred_pii(self, logentry):
pass
class ClearDataShredderMixin:
def shred_pii(self, logentry):
logentry.data = None

View File

@@ -19,137 +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/>.
#
from collections import defaultdict
from typing import Optional
from django.urls import reverse
from django.utils.html import escape
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.signals import EventPluginRegistry
from pretix.base.models import (
Discount, Item, ItemCategory, Order, Question, Quota, SubEvent, TaxRule,
Voucher,
)
def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
if a_map:
if is_active:
a_map['val'] = '<a href="{href}">{val}</a>'.format_map(a_map)
elif event and plugin_name:
a_map['val'] = (
'<i>{val}</i> <a href="{plugin_href}">'
'<span data-toggle="tooltip" title="{errmes}" class="fa fa-warning fa-fw"></span></a>'
).format_map({
**a_map,
"errmes": _("The relevant plugin is currently not active. To activate it, click here to go to the plugin settings."),
"plugin_href": reverse('control:event.settings.plugins', kwargs={
'organizer': event.organizer.slug,
'event': event.slug,
}) + '#plugin_' + plugin_name,
})
else:
a_map['val'] = '<i>{val}</i> <span data-toggle="tooltip" title="{errmes}" class="fa fa-warning fa-fw"></span>'.format_map({
**a_map,
"errmes": _("The relevant plugin is currently not active."),
})
return wrapper.format_map(a_map)
class LogEntryTypeRegistry(EventPluginRegistry):
def __init__(self):
super().__init__({'action_type': lambda o: getattr(o, 'action_type')})
def register(self, *objs):
for obj in objs:
if not isinstance(obj, LogEntryType):
raise TypeError('Entries must be derived from LogEntryType')
if obj.__module__ == LogEntryType.__module__:
raise TypeError('Must not register base classes, only derived ones')
return super().register(*objs)
def new_from_dict(self, data):
"""
Register multiple instance of a `LogEntryType` class with different `action_type`
and plain text strings, as given by the items of the specified data dictionary.
This method is designed to be used as a decorator as follows:
.. code-block:: python
@log_entry_types.new_from_dict({
'pretix.event.item.added': _('The product has been created.'),
'pretix.event.item.changed': _('The product has been changed.'),
# ...
})
class CoreItemLogEntryType(ItemLogEntryType):
# ...
:param data: action types and descriptions
``{"some_action_type": "Plain text description", ...}``
"""
def reg(clz):
for action_type, plain in data.items():
self.register(clz(action_type=action_type, plain=plain))
return clz
return reg
"""
Registry for LogEntry types.
Each entry in this registry should be an instance of a subclass of ``LogEntryType``.
They are annotated with their ``action_type`` and the defining ``plugin``.
"""
log_entry_types = LogEntryTypeRegistry()
class LogEntryType:
"""
Base class for a type of LogEntry, identified by its action_type.
"""
def __init__(self, action_type=None, plain=None):
if action_type:
self.action_type = action_type
if plain:
self.plain = plain
def display(self, logentry):
"""
Returns the message to be displayed for a given logentry of this type.
:return: `str` or `LazyI18nString`
"""
if hasattr(self, 'plain'):
plain = str(self.plain)
if '{' in plain:
data = defaultdict(lambda: '?', logentry.parsed_data)
return plain.format_map(data)
else:
return plain
def get_object_link_info(self, logentry) -> dict:
"""
Return information to generate a link to the `content_object` of a given log entry.
Not implemented in the base class, causing the object link to be omitted.
:return: Dictionary with the keys ``href`` (containing a URL to view/edit the object) and ``val`` (containing the
escaped text for the anchor element)
"""
pass
def get_object_link(self, logentry):
a_map = self.get_object_link_info(logentry)
return make_link(a_map, self.object_link_wrapper)
object_link_wrapper = '{val}'
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
from .logentrytype_registry import ( # noqa
ClearDataShredderMixin, LogEntryType, NoOpShredderMixin, log_entry_types,
make_link, LogEntryTypeRegistry,
)
class EventLogEntryType(LogEntryType):
@@ -157,15 +40,27 @@ class EventLogEntryType(LogEntryType):
Base class for any `LogEntry` type whose `content_object` is either an `Event` itself or belongs to a specific `Event`.
"""
def get_object_link_info(self, logentry) -> dict:
if hasattr(self, 'object_link_viewname') and logentry.content_object:
def get_object_link_info(self, logentry) -> Optional[dict]:
if hasattr(self, 'object_link_viewname'):
content = logentry.content_object
if not content:
if logentry.content_type_id:
return {
'val': _('(deleted)'),
}
else:
return
if hasattr(self, 'content_type') and not isinstance(content, self.content_type):
return
return {
'href': reverse(self.object_link_viewname, kwargs={
'event': logentry.event.slug,
'organizer': logentry.event.organizer.slug,
**self.object_link_args(logentry.content_object),
**self.object_link_args(content),
}),
'val': escape(self.object_link_display_name(logentry.content_object)),
'val': self.object_link_display_name(logentry.content_object),
}
def object_link_args(self, content_object):
@@ -182,6 +77,7 @@ class EventLogEntryType(LogEntryType):
class OrderLogEntryType(EventLogEntryType):
object_link_wrapper = _('Order {val}')
object_link_viewname = 'control:event.order'
content_type = Order
def object_link_args(self, order):
return {'code': order.code}
@@ -194,6 +90,7 @@ class VoucherLogEntryType(EventLogEntryType):
object_link_wrapper = _('Voucher {val}')
object_link_viewname = 'control:event.voucher'
object_link_argname = 'voucher'
content_type = Voucher
def object_link_display_name(self, voucher):
if len(voucher.code) > 6:
@@ -205,49 +102,46 @@ class ItemLogEntryType(EventLogEntryType):
object_link_wrapper = _('Product {val}')
object_link_viewname = 'control:event.item'
object_link_argname = 'item'
content_type = Item
class SubEventLogEntryType(EventLogEntryType):
object_link_wrapper = pgettext_lazy('subevent', 'Date {val}')
object_link_viewname = 'control:event.subevent'
object_link_argname = 'subevent'
content_type = SubEvent
class QuotaLogEntryType(EventLogEntryType):
object_link_wrapper = _('Quota {val}')
object_link_viewname = 'control:event.items.quotas.show'
object_link_argname = 'quota'
content_type = Quota
class DiscountLogEntryType(EventLogEntryType):
object_link_wrapper = _('Discount {val}')
object_link_viewname = 'control:event.items.discounts.edit'
object_link_argname = 'discount'
content_type = Discount
class ItemCategoryLogEntryType(EventLogEntryType):
object_link_wrapper = _('Category {val}')
object_link_viewname = 'control:event.items.categories.edit'
object_link_argname = 'category'
content_type = ItemCategory
class QuestionLogEntryType(EventLogEntryType):
object_link_wrapper = _('Question {val}')
object_link_viewname = 'control:event.items.questions.show'
object_link_argname = 'question'
content_type = Question
class TaxRuleLogEntryType(EventLogEntryType):
object_link_wrapper = _('Tax rule {val}')
object_link_viewname = 'control:event.settings.tax.edit'
object_link_argname = 'rule'
class NoOpShredderMixin:
def shred_pii(self, logentry):
pass
class ClearDataShredderMixin:
def shred_pii(self, logentry):
logentry.data = None
content_type = TaxRule

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.16 on 2025-01-23 11:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0275_alter_question_valid_number_max_and_more"),
]
operations = [
migrations.AddField(
model_name="item",
name="hidden_if_item_available_mode",
field=models.CharField(default="hide", max_length=16, null=True),
),
]

View File

@@ -40,7 +40,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.functional import cached_property
from pretix.base.logentrytypes import log_entry_types, make_link
from pretix.base.logentrytype_registry import log_entry_types, make_link
from pretix.base.signals import is_app_active, logentry_object_link
@@ -93,7 +93,7 @@ class LogEntry(models.Model):
def display(self):
log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
if log_entry_type:
return log_entry_type.display(self)
return log_entry_type.display(self, self.parsed_data)
from ..signals import logentry_display

View File

@@ -355,7 +355,7 @@ class Order(LockModel, LoggedModel):
if not self.testmode:
raise TypeError("Only test mode orders can be deleted.")
self.event.log_action(
self.log_action(
'pretix.event.order.deleted', user=user, auth=auth,
data={
'code': self.code,

View File

@@ -231,7 +231,7 @@ class EventWizardBasicsForm(I18nModelForm):
raise ValidationError({
'timezone': _('Your default locale must be specified.')
})
if not data.get("no_taxes") and not data.get("tax_rate"):
if not data.get("no_taxes") and data.get("tax_rate") is None:
raise ValidationError({
'tax_rate': _('You have not specified a tax rate. If you do not want us to compute sales taxes, please '
'check "{field}" above.').format(field=self.fields["no_taxes"].label)

View File

@@ -33,16 +33,16 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import json
from collections import defaultdict
from decimal import Decimal
from typing import Optional
import bleach
import dateutil.parser
from django.dispatch import receiver
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from i18nfield.strings import LazyI18nString
@@ -68,128 +68,212 @@ OVERVIEW_BANLIST = [
]
def _display_order_changed(event: Event, logentry: LogEntry):
data = json.loads(logentry.data)
class OrderChangeLogEntryType(OrderLogEntryType):
prefix = _('The order has been changed:')
text = _('The order has been changed:')
if logentry.action_type == 'pretix.event.order.changed.item':
def display(self, logentry, data):
return self.prefix + ' ' + self.display_prefixed(logentry.event, logentry, data)
def display_prefixed(self, event: Event, logentry: LogEntry, data):
return super().display(logentry, data)
class OrderPositionChangeLogEntryType(OrderChangeLogEntryType):
prefix = _('The order has been changed:')
def display(self, logentry, data):
return super().display(logentry, {**data, 'posid': data.get('positionid', '?')})
@log_entry_types.new()
class OrderItemChanged(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.item'
def display_prefixed(self, event: Event, logentry: LogEntry, data):
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['old_variation']))
new_item = str(event.items.get(pk=data['new_item']))
if data['new_variation']:
new_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['new_variation']))
return text + ' ' + _('Position #{posid}: {old_item} ({old_price}) changed '
'to {new_item} ({new_price}).').format(
return _('Position #{posid}: {old_item} ({old_price}) changed to {new_item} ({new_price}).').format(
posid=data.get('positionid', '?'),
old_item=old_item, new_item=new_item,
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.membership':
return text + ' ' + _('Position #{posid}: Used membership changed.').format(
posid=data.get('positionid', '?'),
)
elif logentry.action_type == 'pretix.event.order.changed.seat':
return text + ' ' + _('Position #{posid}: Seat "{old_seat}" changed '
'to "{new_seat}".').format(
posid=data.get('positionid', '?'),
old_seat=data.get('old_seat'), new_seat=data.get('new_seat'),
)
elif logentry.action_type == 'pretix.event.order.changed.subevent':
@log_entry_types.new()
class OrderMembershipChanged(OrderPositionChangeLogEntryType):
action_type = 'pretix.event.order.changed.membership'
plain = _('Position #{posid}: Used membership changed.')
@log_entry_types.new()
class OrderSeatChanged(OrderPositionChangeLogEntryType):
action_type = 'pretix.event.order.changed.seat'
plain = _('Position #{posid}: Seat "{old_seat}" changed to "{new_seat}".')
@log_entry_types.new()
class OrderSubeventChanged(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.subevent'
def display_prefixed(self, event: Event, logentry: LogEntry, data):
old_se = str(event.subevents.get(pk=data['old_subevent']))
new_se = str(event.subevents.get(pk=data['new_subevent']))
return text + ' ' + _('Position #{posid}: Event date "{old_event}" ({old_price}) changed '
'to "{new_event}" ({new_price}).').format(
return _('Position #{posid}: Event date "{old_event}" ({old_price}) changed '
'to "{new_event}" ({new_price}).').format(
posid=data.get('positionid', '?'),
old_event=old_se, new_event=new_se,
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.price':
return text + ' ' + _('Price of position #{posid} changed from {old_price} '
'to {new_price}.').format(
@log_entry_types.new()
class OrderPriceChanged(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.price'
def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('Price of position #{posid} changed from {old_price} to {new_price}.').format(
posid=data.get('positionid', '?'),
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.tax_rule':
@log_entry_types.new()
class OrderTaxRuleChanged(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.tax_rule'
def display_prefixed(self, event: Event, logentry: LogEntry, data):
if 'positionid' in data:
return text + ' ' + _('Tax rule of position #{posid} changed from {old_rule} '
'to {new_rule}.').format(
return _('Tax rule of position #{posid} changed from {old_rule} to {new_rule}.').format(
posid=data.get('positionid', '?'),
old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=TaxRule.objects.get(pk=data['new_taxrule']),
)
elif 'fee' in data:
return text + ' ' + _('Tax rule of fee #{fee} changed from {old_rule} '
'to {new_rule}.').format(
return _('Tax rule of fee #{fee} changed from {old_rule} to {new_rule}.').format(
fee=data.get('fee', '?'),
old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=TaxRule.objects.get(pk=data['new_taxrule']),
)
elif logentry.action_type == 'pretix.event.order.changed.addfee':
return text + ' ' + str(_('A fee has been added'))
elif logentry.action_type == 'pretix.event.order.changed.feevalue':
return text + ' ' + _('A fee was changed from {old_price} to {new_price}.').format(
@log_entry_types.new()
class OrderFeeAdded(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.addfee'
plain = _('A fee has been added')
@log_entry_types.new()
class OrderFeeChanged(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.feevalue'
def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('A fee was changed from {old_price} to {new_price}.').format(
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.cancelfee':
return text + ' ' + _('A fee of {old_price} was removed.').format(
@log_entry_types.new()
class OrderFeeRemoved(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.cancelfee'
def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('A fee of {old_price} was removed.').format(
old_price=money_filter(Decimal(data['old_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.cancel':
@log_entry_types.new()
class OrderCanceled(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.cancel'
def display_prefixed(self, event: Event, logentry: LogEntry, data):
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
return text + ' ' + _('Position #{posid} ({old_item}, {old_price}) canceled.').format(
return _('Position #{posid} ({old_item}, {old_price}) canceled.').format(
posid=data.get('positionid', '?'),
old_item=old_item,
old_price=money_filter(Decimal(data['old_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.add':
@log_entry_types.new()
class OrderPositionAdded(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.add'
def display_prefixed(self, event: Event, logentry: LogEntry, data):
item = str(event.items.get(pk=data['item']))
if data['variation']:
item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['variation']))
if data['addon_to']:
addon_to = OrderPosition.objects.get(order__event=event, pk=data['addon_to'])
return text + ' ' + _('Position #{posid} created: {item} ({price}) as an add-on to '
'position #{addon_to}.').format(
return _('Position #{posid} created: {item} ({price}) as an add-on to position #{addon_to}.').format(
posid=data.get('positionid', '?'),
item=item, addon_to=addon_to.positionid,
price=money_filter(Decimal(data['price']), event.currency),
)
else:
return text + ' ' + _('Position #{posid} created: {item} ({price}).').format(
return _('Position #{posid} created: {item} ({price}).').format(
posid=data.get('positionid', '?'),
item=item,
price=money_filter(Decimal(data['price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.secret':
return text + ' ' + _('A new secret has been generated for position #{posid}.').format(
posid=data.get('positionid', '?'),
)
elif logentry.action_type == 'pretix.event.order.changed.valid_from':
return text + ' ' + _('The validity start date for position #{posid} has been changed to {value}.').format(
@log_entry_types.new()
class OrderSecretChanged(OrderPositionChangeLogEntryType):
action_type = 'pretix.event.order.changed.secret'
plain = _('A new secret has been generated for position #{posid}.')
@log_entry_types.new()
class OrderValidFromChanged(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.valid_from'
def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('The validity start date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
'new_value') else ''
)
elif logentry.action_type == 'pretix.event.order.changed.valid_until':
return text + ' ' + _('The validity end date for position #{posid} has been changed to {value}.').format(
@log_entry_types.new()
class OrderValidUntilChanged(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.valid_until'
def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('The validity end date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else ''
)
elif logentry.action_type == 'pretix.event.order.changed.add_block':
return text + ' ' + _('A block has been added for position #{posid}.').format(
posid=data.get('positionid', '?'),
)
elif logentry.action_type == 'pretix.event.order.changed.remove_block':
return text + ' ' + _('A block has been removed for position #{posid}.').format(
posid=data.get('positionid', '?'),
)
elif logentry.action_type == 'pretix.event.order.changed.split':
@log_entry_types.new()
class OrderChangedBlockAdded(OrderPositionChangeLogEntryType):
action_type = 'pretix.event.order.changed.add_block'
plain = _('A block has been added for position #{posid}.')
@log_entry_types.new()
class OrderChangedBlockRemoved(OrderPositionChangeLogEntryType):
action_type = 'pretix.event.order.changed.remove_block'
plain = _('A block has been removed for position #{posid}.')
@log_entry_types.new()
class OrderChangedSplit(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.split'
def display_prefixed(self, event: Event, logentry: LogEntry, data):
old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(pk=data['old_variation']))
@@ -198,194 +282,144 @@ def _display_order_changed(event: Event, logentry: LogEntry):
'organizer': event.organizer.slug,
'code': data['new_order']
})
return mark_safe(escape(text) + ' ' + _('Position #{posid} ({old_item}, {old_price}) split into new order: {order}').format(
return mark_safe(self.prefix + ' ' + _('Position #{posid} ({old_item}, {old_price}) split into new order: {order}').format(
old_item=escape(old_item),
posid=data.get('positionid', '?'),
order='<a href="{}">{}</a>'.format(url, data['new_order']),
old_price=money_filter(Decimal(data['old_price']), event.currency),
))
elif logentry.action_type == 'pretix.event.order.changed.split_from':
@log_entry_types.new()
class OrderChangedSplitFrom(OrderLogEntryType):
action_type = 'pretix.event.order.changed.split_from'
def display(self, logentry: LogEntry, data):
return _('This order has been created by splitting the order {order}').format(
order=data['original_order'],
)
def _display_checkin(event, logentry):
data = logentry.parsed_data
@log_entry_types.new_from_dict({
'pretix.event.checkin.unknown': (
_('Unknown scan of code "{barcode}" at {datetime} for list "{list}", type "{type}".'),
_('Unknown scan of code "{barcode}" for list "{list}", type "{type}".'),
),
'pretix.event.checkin.revoked': (
_('Scan of revoked code "{barcode}" at {datetime} for list "{list}", type "{type}", was uploaded.'),
_('Scan of revoked code "{barcode}" for list "{list}", type "{type}", was uploaded.'),
),
'pretix.event.checkin.denied': (
_('Denied scan of position #{posid} at {datetime} for list "{list}", type "{type}", error code "{errorcode}".'),
_('Denied scan of position #{posid} for list "{list}", type "{type}", error code "{errorcode}".'),
),
'pretix.control.views.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'),
'pretix.event.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'),
})
class CheckinErrorLogEntryType(OrderLogEntryType):
def display(self, logentry: LogEntry, data):
self.display_plain(self.plain, logentry, data)
show_dt = False
if 'datetime' in data:
dt = dateutil.parser.parse(data.get('datetime'))
show_dt = abs((logentry.datetime - dt).total_seconds()) > 5 or 'forced' in data
tz = event.timezone
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
def display_plain(self, plain, logentry: LogEntry, data):
if isinstance(plain, tuple):
plain_with_dt, plain_without_dt = plain
else:
plain_with_dt, plain_without_dt = plain, plain
if 'list' in data:
try:
checkin_list = event.checkin_lists.get(pk=data.get('list')).name
except CheckinList.DoesNotExist:
checkin_list = _("(unknown)")
else:
checkin_list = _("(unknown)")
data = defaultdict(lambda: '?', data)
if logentry.action_type == 'pretix.event.checkin.unknown':
if show_dt:
return _(
'Unknown scan of code "{barcode}" at {datetime} for list "{list}", type "{type}".'
).format(
posid=data.get('positionid'),
type=data.get('type'),
barcode=data.get('barcode')[:16],
datetime=dt_formatted,
list=checkin_list
event = logentry.event
if 'list' in data:
try:
data['list'] = event.checkin_lists.get(pk=data.get('list')).name
except CheckinList.DoesNotExist:
data['list'] = _("(unknown)")
else:
data['list'] = _("(unknown)")
data['barcode'] = data.get('barcode')[:16]
data['posid'] = logentry.parsed_data.get('positionid', '?')
if 'datetime' in data:
dt = dateutil.parser.parse(data.get('datetime'))
if abs((logentry.datetime - dt).total_seconds()) > 5 or 'forced' in data:
tz = event.timezone
data['datetime'] = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
return str(plain_with_dt).format_map(data)
else:
return str(plain_without_dt).format_map(data)
class CheckinLogEntryType(CheckinErrorLogEntryType):
def display(self, logentry: LogEntry, data):
if data.get('type') == Checkin.TYPE_EXIT:
return self.display_plain((
_('Position #{posid} has been checked out at {datetime} for list "{list}".'),
_('Position #{posid} has been checked out for list "{list}".'),
), logentry, data)
elif data.get('first'):
return self.display_plain((
_('Position #{posid} has been checked in at {datetime} for list "{list}".'),
_('Position #{posid} has been checked in for list "{list}".'),
), logentry, data)
elif data.get('forced'):
return self.display_plain(
_('A scan for position #{posid} at {datetime} for list "{list}" has been uploaded even though it has '
'been scanned already.'),
logentry, data
)
else:
return _(
'Unknown scan of code "{barcode}" for list "{list}", type "{type}".'
).format(
posid=data.get('positionid'),
type=data.get('type'),
barcode=data.get('barcode')[:16],
list=checkin_list
return self.display_plain(
_('Position #{posid} has been scanned and rejected because it has already been scanned before '
'on list "{list}".'),
logentry, data
)
if logentry.action_type == 'pretix.event.checkin.revoked':
if show_dt:
return _(
'Scan scan of revoked code "{barcode}" at {datetime} for list "{list}", type "{type}", was uploaded.'
).format(
posid=data.get('positionid'),
type=data.get('type'),
barcode=data.get('barcode')[:16],
datetime=dt_formatted,
list=checkin_list
)
else:
return _(
'Scan of revoked code "{barcode}" for list "{list}", type "{type}", was uploaded.'
).format(
posid=data.get('positionid'),
type=data.get('type'),
barcode=data.get('barcode')[:16],
list=checkin_list
)
if logentry.action_type == 'pretix.event.checkin.denied':
if show_dt:
return _(
'Denied scan of position #{posid} at {datetime} for list "{list}", type "{type}", '
'error code "{errorcode}".'
).format(
posid=data.get('positionid'),
type=data.get('type'),
errorcode=data.get('errorcode'),
datetime=dt_formatted,
list=checkin_list
)
else:
return _(
'Denied scan of position #{posid} for list "{list}", type "{type}", error code "{errorcode}".'
).format(
posid=data.get('positionid'),
type=data.get('type'),
errorcode=data.get('errorcode'),
list=checkin_list
)
class OrderConsentLogEntryType(OrderLogEntryType):
action_type = 'pretix.event.order.consent'
if data.get('type') == Checkin.TYPE_EXIT:
if show_dt:
return _('Position #{posid} has been checked out at {datetime} for list "{list}".').format(
posid=data.get('positionid'),
datetime=dt_formatted,
list=checkin_list
)
def display(self, logentry: LogEntry, data):
return _('The user confirmed the following message: "{}"').format(
bleach.clean(data.get('msg'), tags=set(), strip=True)
)
class OrderCanceledLogEntryType(OrderLogEntryType):
action_type = 'pretix.event.order.canceled'
def display(self, logentry: LogEntry, data):
comment = data.get('comment')
if comment:
return _('The order has been canceled (comment: "{comment}").').format(comment=comment)
else:
return _('Position #{posid} has been checked out for list "{list}".').format(
posid=data.get('positionid'),
list=checkin_list
)
if data.get('first'):
if show_dt:
return _('Position #{posid} has been checked in at {datetime} for list "{list}".').format(
posid=data.get('positionid'),
datetime=dt_formatted,
list=checkin_list
)
else:
return _('Position #{posid} has been checked in for list "{list}".').format(
posid=data.get('positionid'),
list=checkin_list
)
else:
if data.get('forced'):
return _(
'A scan for position #{posid} at {datetime} for list "{list}" has been uploaded even though it has '
'been scanned already.'.format(
posid=data.get('positionid'),
datetime=dt_formatted,
list=checkin_list
)
)
return _(
'Position #{posid} has been scanned and rejected because it has already been scanned before '
'on list "{list}".'.format(
posid=data.get('positionid'),
list=checkin_list
)
return _('The order has been canceled.')
class OrderPrintLogEntryType(OrderLogEntryType):
action_type = 'pretix.event.order.print'
def display(self, logentry: LogEntry, data):
return _('Position #{posid} has been printed at {datetime} with type "{type}".').format(
posid=data.get('positionid'),
datetime=date_format(
dateutil.parser.parse(data["datetime"]).astimezone(logentry.event.timezone),
"SHORT_DATETIME_FORMAT"
),
type=dict(PrintLog.PRINT_TYPES)[data["type"]],
)
@receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display")
def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
if logentry.action_type.startswith('pretix.event.order.changed'):
return _display_order_changed(sender, logentry)
if logentry.action_type.startswith('pretix.event.payment.provider.'):
return _('The settings of a payment provider have been changed.')
if logentry.action_type.startswith('pretix.event.tickets.provider.'):
return _('The settings of a ticket output provider have been changed.')
if logentry.action_type == 'pretix.event.order.consent':
return _('The user confirmed the following message: "{}"').format(
bleach.clean(logentry.parsed_data.get('msg'), tags=set(), strip=True)
)
if logentry.action_type == 'pretix.event.order.canceled':
comment = logentry.parsed_data.get('comment')
if comment:
return _('The order has been canceled (comment: "{comment}").').format(comment=comment)
else:
return _('The order has been canceled.')
if logentry.action_type in ('pretix.control.views.checkin.reverted', 'pretix.event.checkin.reverted'):
if 'list' in logentry.parsed_data:
try:
checkin_list = sender.checkin_lists.get(pk=logentry.parsed_data.get('list')).name
except CheckinList.DoesNotExist:
checkin_list = _("(unknown)")
else:
checkin_list = _("(unknown)")
return _('The check-in of position #{posid} on list "{list}" has been reverted.').format(
posid=logentry.parsed_data.get('positionid'),
list=checkin_list,
)
if sender and logentry.action_type.startswith('pretix.event.checkin'):
return _display_checkin(sender, logentry)
if logentry.action_type == 'pretix.event.order.print':
return _('Position #{posid} has been printed at {datetime} with type "{type}".').format(
posid=logentry.parsed_data.get('positionid'),
datetime=date_format(
dateutil.parser.parse(logentry.parsed_data["datetime"]).astimezone(sender.timezone),
"SHORT_DATETIME_FORMAT"
),
type=dict(PrintLog.PRINT_TYPES)[logentry.parsed_data["type"]],
)
@receiver(signal=orderposition_blocked_display, dispatch_uid="pretixcontrol_orderposition_blocked_display")
def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, block_name, **kwargs):
@@ -396,6 +430,7 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
@log_entry_types.new_from_dict({
'pretix.event.order.deleted': _('The test mode order {code} has been deleted.'),
'pretix.event.order.modified': _('The order details have been changed.'),
'pretix.event.order.unpaid': _('The order has been marked as unpaid.'),
'pretix.event.order.secret.changed': _('The order\'s secret has been changed.'),
@@ -486,17 +521,16 @@ class VoucherRedeemedLogEntryType(VoucherLogEntryType):
action_type = 'pretix.voucher.redeemed'
plain = _('The voucher has been redeemed in order {order_code}.')
def display(self, logentry):
data = json.loads(logentry.data)
data = defaultdict(lambda: '?', data)
def display(self, logentry, data):
url = reverse('control:event.order', kwargs={
'event': logentry.event.slug,
'organizer': logentry.event.organizer.slug,
'code': data['order_code']
'code': data.get('order_code', '?')
})
return mark_safe(self.plain.format(
order_code='<a href="{}">{}</a>'.format(url, data['order_code']),
))
return format_html(
self.plain,
order_code=format_html('<a href="{}">{}</a>', url, data('order_code', '?')),
)
@log_entry_types.new_from_dict({
@@ -519,8 +553,8 @@ class CoreTaxRuleLogEntryType(TaxRuleLogEntryType):
class TeamMembershipLogEntryType(LogEntryType):
def display(self, logentry):
return self.plain.format(user=logentry.parsed_data.get('email'))
def display(self, logentry, data):
return self.plain.format(user=data.get('email'))
@log_entry_types.new_from_dict({
@@ -537,9 +571,9 @@ class CoreTeamMembershipLogEntryType(TeamMembershipLogEntryType):
class TeamMemberJoinedLogEntryType(LogEntryType):
action_type = 'pretix.team.member.joined'
def display(self, logentry):
def display(self, logentry, data):
return _('{user} has joined the team using the invite sent to {email}.').format(
user=logentry.parsed_data.get('email'), email=logentry.parsed_data.get('invite_email')
user=data.get('email'), email=data.get('invite_email')
)
@@ -547,23 +581,23 @@ class TeamMemberJoinedLogEntryType(LogEntryType):
class UserSettingsChangedLogEntryType(LogEntryType):
action_type = 'pretix.user.settings.changed'
def display(self, logentry):
def display(self, logentry, data):
text = str(_('Your account settings have been changed.'))
if 'email' in logentry.parsed_data:
if 'email' in data:
text = text + ' ' + str(
_('Your email address has been changed to {email}.').format(email=logentry.parsed_data['email']))
if 'new_pw' in logentry.parsed_data:
_('Your email address has been changed to {email}.').format(email=data['email']))
if 'new_pw' in data:
text = text + ' ' + str(_('Your password has been changed.'))
if logentry.parsed_data.get('is_active') is True:
if data.get('is_active') is True:
text = text + ' ' + str(_('Your account has been enabled.'))
elif logentry.parsed_data.get('is_active') is False:
elif data.get('is_active') is False:
text = text + ' ' + str(_('Your account has been disabled.'))
return text
class UserImpersonatedLogEntryType(LogEntryType):
def display(self, logentry):
return self.plain.format(logentry.parsed_data['other_email'])
def display(self, logentry, data):
return self.plain.format(data['other_email'])
@log_entry_types.new_from_dict({
@@ -688,16 +722,13 @@ class CoreLogEntryType(LogEntryType):
@log_entry_types.new_from_dict({
'pretix.event.order.deleted': _('The test mode order {code} has been deleted.'),
'pretix.event.item_meta_property.added': _('A meta property has been added to this event.'),
'pretix.event.item_meta_property.deleted': _('A meta property has been removed from this event.'),
'pretix.event.item_meta_property.changed': _('A meta property has been changed on this event.'),
'pretix.event.checkinlist.added': _('The check-in list has been added.'),
'pretix.event.checkinlist.deleted': _('The check-in list has been deleted.'),
'pretix.event.checkinlists.deleted': _('The check-in list has been deleted.'), # backwards compatibility
'pretix.event.checkinlist.changed': _('The check-in list has been changed.'),
'pretix.event.settings': _('The event settings have been changed.'),
'pretix.event.tickets.settings': _('The ticket download settings have been changed.'),
'pretix.event.tickets.provider': _('The settings of a ticket output provider have been changed.'),
'pretix.event.payment.provider': _('The settings of a payment provider have been changed.'),
'pretix.event.live.activated': _('The shop has been taken live.'),
'pretix.event.live.deactivated': _('The shop has been taken offline.'),
'pretix.event.testmode.activated': _('The shop has been taken into test mode.'),
@@ -717,6 +748,19 @@ class CoreEventLogEntryType(EventLogEntryType):
pass
@log_entry_types.new_from_dict({
'pretix.event.checkinlist.added': _('The check-in list has been added.'),
'pretix.event.checkinlist.deleted': _('The check-in list has been deleted.'),
'pretix.event.checkinlists.deleted': _('The check-in list has been deleted.'), # backwards compatibility
'pretix.event.checkinlist.changed': _('The check-in list has been changed.'),
})
class CheckinlistLogEntryType(EventLogEntryType):
object_link_wrapper = _('Check-in list {val}')
object_link_viewname = 'control:event.orders.checkinlists.edit'
object_link_argname = 'list'
content_type = CheckinList
@log_entry_types.new_from_dict({
'pretix.event.plugins.enabled': _('The plugin has been enabled.'),
'pretix.event.plugins.disabled': _('The plugin has been disabled.'),
@@ -724,7 +768,7 @@ class CoreEventLogEntryType(EventLogEntryType):
class EventPluginStateLogEntryType(EventLogEntryType):
object_link_wrapper = _('Plugin {val}')
def get_object_link_info(self, logentry) -> dict:
def get_object_link_info(self, logentry) -> Optional[dict]:
if 'plugin' in logentry.parsed_data:
app = app_cache.get(logentry.parsed_data['plugin'])
if app and hasattr(app, 'PretixPluginMeta'):
@@ -759,17 +803,17 @@ class CoreItemLogEntryType(ItemLogEntryType):
'pretix.event.item.variation.changed': _('The variation "{value}" has been changed.'),
})
class VariationLogEntryType(ItemLogEntryType):
def display(self, logentry):
if 'value' not in logentry.parsed_data:
def display(self, logentry, data):
if 'value' not in data:
# Backwards compatibility
var = ItemVariation.objects.filter(id=logentry.parsed_data['id']).first()
var = ItemVariation.objects.filter(id=data['id']).first()
if var:
logentry.parsed_data['value'] = str(var.value)
data['value'] = str(var.value)
else:
logentry.parsed_data['value'] = '?'
data['value'] = '?'
else:
logentry.parsed_data['value'] = LazyI18nString(logentry.parsed_data['value'])
return super().display(logentry)
data['value'] = LazyI18nString(data['value'])
return super().display(logentry, data)
@log_entry_types.new_from_dict({
@@ -825,27 +869,27 @@ class CoreDiscountLogEntryType(DiscountLogEntryType):
class LegacyCheckinLogEntryType(OrderLogEntryType):
action_type = 'pretix.control.views.checkin'
def display(self, logentry):
def display(self, logentry, data):
# deprecated
dt = dateutil.parser.parse(logentry.parsed_data.get('datetime'))
dt = dateutil.parser.parse(data.get('datetime'))
tz = logentry.event.timezone
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
if 'list' in logentry.parsed_data:
if 'list' in data:
try:
checkin_list = logentry.event.checkin_lists.get(pk=logentry.parsed_data.get('list')).name
checkin_list = logentry.event.checkin_lists.get(pk=data.get('list')).name
except CheckinList.DoesNotExist:
checkin_list = _("(unknown)")
else:
checkin_list = _("(unknown)")
if logentry.parsed_data.get('first'):
if data.get('first'):
return _('Position #{posid} has been checked in manually at {datetime} on list "{list}".').format(
posid=logentry.parsed_data.get('positionid'),
posid=data.get('positionid'),
datetime=dt_formatted,
list=checkin_list,
)
return _('Position #{posid} has been checked in again at {datetime} on list "{list}".').format(
posid=logentry.parsed_data.get('positionid'),
posid=data.get('positionid'),
datetime=dt_formatted,
list=checkin_list
)

View File

@@ -491,8 +491,11 @@ class PaymentProviderSettings(EventSettingsViewMixin, EventPermissionRequiredMix
if self.form.is_valid():
if self.form.has_changed():
self.request.event.log_action(
'pretix.event.payment.provider.' + self.provider.identifier, user=self.request.user, data={
k: self.form.cleaned_data.get(k) for k in self.form.changed_data
'pretix.event.payment.provider', user=self.request.user, data={
'provider': self.provider.identifier,
'new_values': {
k: self.form.cleaned_data.get(k) for k in self.form.changed_data
}
}
)
self.form.save()
@@ -888,11 +891,14 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
provider.form.save()
if provider.form.has_changed():
self.request.event.log_action(
'pretix.event.tickets.provider.' + provider.identifier, user=self.request.user, data={
k: (provider.form.cleaned_data.get(k).name
if isinstance(provider.form.cleaned_data.get(k), File)
else provider.form.cleaned_data.get(k))
for k in provider.form.changed_data
'pretix.event.tickets.provider', user=self.request.user, data={
'provider': provider.identifier,
'new_values': {
k: (provider.form.cleaned_data.get(k).name
if isinstance(provider.form.cleaned_data.get(k), File)
else provider.form.cleaned_data.get(k))
for k in provider.form.changed_data
}
}
)
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk, 'provider': provider.identifier})

View File

@@ -313,7 +313,7 @@ class EventWizard(SafeSessionWizardView):
)
event.set_defaults()
if basics_data['tax_rate']:
if basics_data['tax_rate'] is not None:
if not event.settings.tax_rate_default or event.settings.tax_rate_default.rate != basics_data['tax_rate']:
event.settings.tax_rate_default = event.tax_rules.create(
name=LazyI18nString.from_gettext(gettext('VAT')),