Compare commits

...

7 Commits

Author SHA1 Message Date
Mira Weller
d4246cf752 Fix required-if references as well 2025-01-24 16:25:37 +01:00
Mira Weller
f3022ddca9 Remove duplicate attribute 2025-01-24 12:13:40 +01:00
Mira Weller
939a591061 Fix company & vat_id dependencies on is_business 2025-01-24 11:36:21 +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
Martin Gross
9622bf41a1 InvoiceForm: Display vat_id especially if company_required (Z#23180046) (#4775) 2025-01-23 12:09:45 +01:00
20 changed files with 586 additions and 435 deletions

View File

@@ -69,6 +69,10 @@ hidden_if_available integer **DEPRECATED*
hidden_if_item_available integer The internal ID of a different item, or ``null``. If hidden_if_item_available integer The internal ID of a different item, or ``null``. If
set, this item won't be shown publicly as long as this set, this item won't be shown publicly as long as this
other item is available. other item is available.
hidden_if_item_available_mode string If ``hide`` (the default), this item is hidden in the shop
if unavailable due to the ``hidden_if_item_available`` setting.
If ``info``, the item is visible, but can't be purchased,
and a note explaining the unavailability is displayed.
require_voucher boolean If ``true``, this item can only be bought using a require_voucher boolean If ``true``, this item can only be bought using a
voucher that is specifically assigned to this item. voucher that is specifically assigned to this item.
hide_without_voucher boolean If ``true``, this item is only shown during the voucher hide_without_voucher boolean If ``true``, this item is only shown during the voucher
@@ -239,6 +243,10 @@ meta_data object Values set fo
The ``hidden_if_item_available`` attributes has been added, the ``hidden_if_available`` attribute has been The ``hidden_if_item_available`` attributes has been added, the ``hidden_if_available`` attribute has been
deprecated. deprecated.
.. versionchanged:: 2025.01
The ``hidden_if_item_available_mode`` attributes has been added.
Notes Notes
----- -----
@@ -308,6 +316,7 @@ Endpoints
"available_until_mode": "hide", "available_until_mode": "hide",
"hidden_if_available": null, "hidden_if_available": null,
"hidden_if_item_available": null, "hidden_if_item_available": null,
"hidden_if_item_available_mode": "hide",
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
@@ -459,6 +468,7 @@ Endpoints
"available_until_mode": "hide", "available_until_mode": "hide",
"hidden_if_available": null, "hidden_if_available": null,
"hidden_if_item_available": null, "hidden_if_item_available": null,
"hidden_if_item_available_mode": "hide",
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
@@ -589,6 +599,7 @@ Endpoints
"available_until_mode": "hide", "available_until_mode": "hide",
"hidden_if_available": null, "hidden_if_available": null,
"hidden_if_item_available": null, "hidden_if_item_available": null,
"hidden_if_item_available_mode": "hide",
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
@@ -705,6 +716,7 @@ Endpoints
"available_until_mode": "hide", "available_until_mode": "hide",
"hidden_if_available": null, "hidden_if_available": null,
"hidden_if_item_available": null, "hidden_if_item_available": null,
"hidden_if_item_available_mode": "hide",
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
@@ -855,6 +867,7 @@ Endpoints
"available_until_mode": "hide", "available_until_mode": "hide",
"hidden_if_available": null, "hidden_if_available": null,
"hidden_if_item_available": null, "hidden_if_item_available": null,
"hidden_if_item_available_mode": "hide",
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"generate_tickets": null, "generate_tickets": null,

View File

@@ -272,7 +272,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations', 'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
'show_quota_left', 'hidden_if_available', 'hidden_if_item_available', 'allow_waitinglist', 'show_quota_left', 'hidden_if_available', 'hidden_if_item_available', 'hidden_if_item_available_mode', 'allow_waitinglist',
'issue_giftcard', 'meta_data', 'issue_giftcard', 'meta_data',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type', 'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
'grant_membership_duration_like_event', 'grant_membership_duration_days', 'grant_membership_duration_like_event', 'grant_membership_duration_days',

View File

@@ -1025,10 +1025,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'autocomplete': 'address-level2', 'autocomplete': 'address-level2',
}), }),
'company': forms.TextInput(attrs={ 'company': forms.TextInput(attrs={
'data-display-dependency': '#id_is_business_1',
'autocomplete': 'organization', 'autocomplete': 'organization',
}), }),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}), 'vat_id': forms.TextInput(),
'internal_reference': forms.TextInput, 'internal_reference': forms.TextInput,
} }
labels = { labels = {
@@ -1059,8 +1058,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["company"].widget.attrs["data-display-dependency"] = f'#id_{self.add_prefix("is_business")}_1' self.fields["company"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]'
self.fields["vat_id"].widget.attrs["data-display-dependency"] = f'#id_{self.add_prefix("is_business")}_1' self.fields["vat_id"].widget.attrs["data-display-dependency"] = f'input[name="{self.add_prefix("is_business")}"][value="business"]'
if not self.ask_vat_id: if not self.ask_vat_id:
del self.fields['vat_id'] del self.fields['vat_id']
@@ -1141,9 +1140,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
) )
if self.address_required and not self.company_required and not self.all_optional: if self.address_required and not self.company_required and not self.all_optional:
if not event.settings.invoice_name_required: if not event.settings.invoice_name_required:
self.fields['name_parts'].widget.attrs['data-required-if'] = f'#id_{self.add_prefix("is_business")}_0' self.fields['name_parts'].widget.attrs['data-required-if'] = f'input[name="{self.add_prefix("is_business")}"][value="individual"]'
self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1' self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1'
self.fields['company'].widget.attrs['data-required-if'] = f'#id_{self.add_prefix("is_business")}_1' self.fields['company'].widget.attrs['data-required-if'] = f'input[name="{self.add_prefix("is_business")}"][value="business"]'
if not event.settings.invoice_address_beneficiary: if not event.settings.invoice_address_beneficiary:
del self.fields['beneficiary'] del self.fields['beneficiary']

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 # 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/>. # <https://www.gnu.org/licenses/>.
# #
from collections import defaultdict from typing import Optional
from django.urls import reverse from django.urls import reverse
from django.utils.html import escape
from django.utils.translation import gettext_lazy as _, pgettext_lazy 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,
)
from .logentrytype_registry import ( # noqa
def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None): ClearDataShredderMixin, LogEntryType, NoOpShredderMixin, log_entry_types,
if a_map: make_link, LogEntryTypeRegistry,
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
class EventLogEntryType(LogEntryType): 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`. 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: def get_object_link_info(self, logentry) -> Optional[dict]:
if hasattr(self, 'object_link_viewname') and logentry.content_object: 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 { return {
'href': reverse(self.object_link_viewname, kwargs={ 'href': reverse(self.object_link_viewname, kwargs={
'event': logentry.event.slug, 'event': logentry.event.slug,
'organizer': logentry.event.organizer.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): def object_link_args(self, content_object):
@@ -182,6 +77,7 @@ class EventLogEntryType(LogEntryType):
class OrderLogEntryType(EventLogEntryType): class OrderLogEntryType(EventLogEntryType):
object_link_wrapper = _('Order {val}') object_link_wrapper = _('Order {val}')
object_link_viewname = 'control:event.order' object_link_viewname = 'control:event.order'
content_type = Order
def object_link_args(self, order): def object_link_args(self, order):
return {'code': order.code} return {'code': order.code}
@@ -194,6 +90,7 @@ class VoucherLogEntryType(EventLogEntryType):
object_link_wrapper = _('Voucher {val}') object_link_wrapper = _('Voucher {val}')
object_link_viewname = 'control:event.voucher' object_link_viewname = 'control:event.voucher'
object_link_argname = 'voucher' object_link_argname = 'voucher'
content_type = Voucher
def object_link_display_name(self, voucher): def object_link_display_name(self, voucher):
if len(voucher.code) > 6: if len(voucher.code) > 6:
@@ -205,49 +102,46 @@ class ItemLogEntryType(EventLogEntryType):
object_link_wrapper = _('Product {val}') object_link_wrapper = _('Product {val}')
object_link_viewname = 'control:event.item' object_link_viewname = 'control:event.item'
object_link_argname = 'item' object_link_argname = 'item'
content_type = Item
class SubEventLogEntryType(EventLogEntryType): class SubEventLogEntryType(EventLogEntryType):
object_link_wrapper = pgettext_lazy('subevent', 'Date {val}') object_link_wrapper = pgettext_lazy('subevent', 'Date {val}')
object_link_viewname = 'control:event.subevent' object_link_viewname = 'control:event.subevent'
object_link_argname = 'subevent' object_link_argname = 'subevent'
content_type = SubEvent
class QuotaLogEntryType(EventLogEntryType): class QuotaLogEntryType(EventLogEntryType):
object_link_wrapper = _('Quota {val}') object_link_wrapper = _('Quota {val}')
object_link_viewname = 'control:event.items.quotas.show' object_link_viewname = 'control:event.items.quotas.show'
object_link_argname = 'quota' object_link_argname = 'quota'
content_type = Quota
class DiscountLogEntryType(EventLogEntryType): class DiscountLogEntryType(EventLogEntryType):
object_link_wrapper = _('Discount {val}') object_link_wrapper = _('Discount {val}')
object_link_viewname = 'control:event.items.discounts.edit' object_link_viewname = 'control:event.items.discounts.edit'
object_link_argname = 'discount' object_link_argname = 'discount'
content_type = Discount
class ItemCategoryLogEntryType(EventLogEntryType): class ItemCategoryLogEntryType(EventLogEntryType):
object_link_wrapper = _('Category {val}') object_link_wrapper = _('Category {val}')
object_link_viewname = 'control:event.items.categories.edit' object_link_viewname = 'control:event.items.categories.edit'
object_link_argname = 'category' object_link_argname = 'category'
content_type = ItemCategory
class QuestionLogEntryType(EventLogEntryType): class QuestionLogEntryType(EventLogEntryType):
object_link_wrapper = _('Question {val}') object_link_wrapper = _('Question {val}')
object_link_viewname = 'control:event.items.questions.show' object_link_viewname = 'control:event.items.questions.show'
object_link_argname = 'question' object_link_argname = 'question'
content_type = Question
class TaxRuleLogEntryType(EventLogEntryType): class TaxRuleLogEntryType(EventLogEntryType):
object_link_wrapper = _('Tax rule {val}') object_link_wrapper = _('Tax rule {val}')
object_link_viewname = 'control:event.settings.tax.edit' object_link_viewname = 'control:event.settings.tax.edit'
object_link_argname = 'rule' object_link_argname = 'rule'
content_type = TaxRule
class NoOpShredderMixin:
def shred_pii(self, logentry):
pass
class ClearDataShredderMixin:
def shred_pii(self, logentry):
logentry.data = None

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),
),
]

View File

@@ -442,8 +442,12 @@ class Item(LoggedModel):
UNAVAIL_MODE_INFO = "info" UNAVAIL_MODE_INFO = "info"
UNAVAIL_MODES = ( UNAVAIL_MODES = (
(UNAVAIL_MODE_HIDDEN, _("Hide product if unavailable")), (UNAVAIL_MODE_HIDDEN, _("Hide product if unavailable")),
(UNAVAIL_MODE_INFO, _("Show info text if unavailable")), (UNAVAIL_MODE_INFO, _("Show product with info on why its unavailable")),
) )
UNAVAIL_MODE_ICONS = {
UNAVAIL_MODE_HIDDEN: 'eye-slash',
UNAVAIL_MODE_INFO: 'info'
}
MEDIA_POLICY_REUSE = 'reuse' MEDIA_POLICY_REUSE = 'reuse'
MEDIA_POLICY_NEW = 'new' MEDIA_POLICY_NEW = 'new'
@@ -596,6 +600,11 @@ class Item(LoggedModel):
"be a short period in which both products are visible while all tickets of the referenced " "be a short period in which both products are visible while all tickets of the referenced "
"product are reserved, but not yet sold.") "product are reserved, but not yet sold.")
) )
hidden_if_item_available_mode = models.CharField(
choices=UNAVAIL_MODES,
default=UNAVAIL_MODE_HIDDEN,
max_length=16,
)
require_voucher = models.BooleanField( require_voucher = models.BooleanField(
verbose_name=_('This product can only be bought using a voucher.'), verbose_name=_('This product can only be bought using a voucher.'),
default=False, default=False,
@@ -885,6 +894,8 @@ class Item(LoggedModel):
return 'available_from' return 'available_from'
elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt: elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt:
return 'available_until' return 'available_until'
elif self.hidden_if_item_available and self._dependency_available:
return 'hidden_if_item_available'
else: else:
return None return None

View File

@@ -40,7 +40,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db import models from django.db import models
from django.utils.functional import cached_property 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 from pretix.base.signals import is_app_active, logentry_object_link
@@ -93,7 +93,7 @@ class LogEntry(models.Model):
def display(self): def display(self):
log_entry_type, meta = log_entry_types.get(action_type=self.action_type) log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
if log_entry_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 from ..signals import logentry_display

View File

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

View File

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

View File

@@ -476,6 +476,7 @@ class ItemCreateForm(I18nModelForm):
'show_quota_left', 'show_quota_left',
'hidden_if_available', 'hidden_if_available',
'hidden_if_item_available', 'hidden_if_item_available',
'hidden_if_item_available_mode',
'require_bundling', 'require_bundling',
'require_membership', 'require_membership',
'grant_membership_type', 'grant_membership_type',
@@ -646,18 +647,12 @@ class ItemUpdateForm(I18nModelForm):
self.fields['available_from_mode'].widget = ButtonGroupRadioSelect( self.fields['available_from_mode'].widget = ButtonGroupRadioSelect(
choices=self.fields['available_from_mode'].choices, choices=self.fields['available_from_mode'].choices,
option_icons={ option_icons=Item.UNAVAIL_MODE_ICONS
Item.UNAVAIL_MODE_HIDDEN: 'eye-slash',
Item.UNAVAIL_MODE_INFO: 'info'
}
) )
self.fields['available_until_mode'].widget = ButtonGroupRadioSelect( self.fields['available_until_mode'].widget = ButtonGroupRadioSelect(
choices=self.fields['available_until_mode'].choices, choices=self.fields['available_until_mode'].choices,
option_icons={ option_icons=Item.UNAVAIL_MODE_ICONS
Item.UNAVAIL_MODE_HIDDEN: 'eye-slash',
Item.UNAVAIL_MODE_INFO: 'info'
}
) )
self.fields['hide_without_voucher'].widget = ButtonGroupRadioSelect( self.fields['hide_without_voucher'].widget = ButtonGroupRadioSelect(
@@ -672,6 +667,11 @@ class ItemUpdateForm(I18nModelForm):
attrs={'data-checkbox-dependency': '#id_require_voucher'} attrs={'data-checkbox-dependency': '#id_require_voucher'}
) )
self.fields['hidden_if_item_available_mode'].widget = ButtonGroupRadioSelect(
choices=self.fields['hidden_if_item_available_mode'].choices,
option_icons=Item.UNAVAIL_MODE_ICONS
)
if self.instance.hidden_if_available_id: if self.instance.hidden_if_available_id:
self.fields['hidden_if_available'].queryset = self.event.quotas.all() self.fields['hidden_if_available'].queryset = self.event.quotas.all()
self.fields['hidden_if_available'].help_text = format_html( self.fields['hidden_if_available'].help_text = format_html(
@@ -853,6 +853,7 @@ class ItemUpdateForm(I18nModelForm):
'show_quota_left', 'show_quota_left',
'hidden_if_available', 'hidden_if_available',
'hidden_if_item_available', 'hidden_if_item_available',
'hidden_if_item_available_mode',
'issue_giftcard', 'issue_giftcard',
'require_membership', 'require_membership',
'require_membership_types', 'require_membership_types',
@@ -970,18 +971,12 @@ class ItemVariationForm(I18nModelForm):
self.fields['available_from_mode'].widget = ButtonGroupRadioSelect( self.fields['available_from_mode'].widget = ButtonGroupRadioSelect(
choices=self.fields['available_from_mode'].choices, choices=self.fields['available_from_mode'].choices,
option_icons={ option_icons=Item.UNAVAIL_MODE_ICONS
Item.UNAVAIL_MODE_HIDDEN: 'eye-slash',
Item.UNAVAIL_MODE_INFO: 'info'
}
) )
self.fields['available_until_mode'].widget = ButtonGroupRadioSelect( self.fields['available_until_mode'].widget = ButtonGroupRadioSelect(
choices=self.fields['available_until_mode'].choices, choices=self.fields['available_until_mode'].choices,
option_icons={ option_icons=Item.UNAVAIL_MODE_ICONS
Item.UNAVAIL_MODE_HIDDEN: 'eye-slash',
Item.UNAVAIL_MODE_INFO: 'info'
}
) )
self.meta_fields = [] self.meta_fields = []

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 # 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. # License for the specific language governing permissions and limitations under the License.
import json
from collections import defaultdict from collections import defaultdict
from decimal import Decimal from decimal import Decimal
from typing import Optional
import bleach import bleach
import dateutil.parser import dateutil.parser
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils.formats import date_format 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.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
@@ -68,128 +68,212 @@ OVERVIEW_BANLIST = [
] ]
def _display_order_changed(event: Event, logentry: LogEntry): class OrderChangeLogEntryType(OrderLogEntryType):
data = json.loads(logentry.data) prefix = _('The order has been changed:')
text = _('The order has been changed:') def display(self, logentry, data):
if logentry.action_type == 'pretix.event.order.changed.item': 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'])) old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']: if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=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'])) new_item = str(event.items.get(pk=data['new_item']))
if data['new_variation']: if data['new_variation']:
new_item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=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 ' return _('Position #{posid}: {old_item} ({old_price}) changed to {new_item} ({new_price}).').format(
'to {new_item} ({new_price}).').format(
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
old_item=old_item, new_item=new_item, old_item=old_item, new_item=new_item,
old_price=money_filter(Decimal(data['old_price']), event.currency), old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_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', '?'), @log_entry_types.new()
) class OrderMembershipChanged(OrderPositionChangeLogEntryType):
elif logentry.action_type == 'pretix.event.order.changed.seat': action_type = 'pretix.event.order.changed.membership'
return text + ' ' + _('Position #{posid}: Seat "{old_seat}" changed ' plain = _('Position #{posid}: Used membership changed.')
'to "{new_seat}".').format(
posid=data.get('positionid', '?'),
old_seat=data.get('old_seat'), new_seat=data.get('new_seat'), @log_entry_types.new()
) class OrderSeatChanged(OrderPositionChangeLogEntryType):
elif logentry.action_type == 'pretix.event.order.changed.subevent': 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'])) old_se = str(event.subevents.get(pk=data['old_subevent']))
new_se = str(event.subevents.get(pk=data['new_subevent'])) new_se = str(event.subevents.get(pk=data['new_subevent']))
return text + ' ' + _('Position #{posid}: Event date "{old_event}" ({old_price}) changed ' return _('Position #{posid}: Event date "{old_event}" ({old_price}) changed '
'to "{new_event}" ({new_price}).').format( 'to "{new_event}" ({new_price}).').format(
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
old_event=old_se, new_event=new_se, old_event=old_se, new_event=new_se,
old_price=money_filter(Decimal(data['old_price']), event.currency), old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_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', '?'), posid=data.get('positionid', '?'),
old_price=money_filter(Decimal(data['old_price']), event.currency), old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_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: if 'positionid' in data:
return text + ' ' + _('Tax rule of position #{posid} changed from {old_rule} ' return _('Tax rule of position #{posid} changed from {old_rule} to {new_rule}.').format(
'to {new_rule}.').format(
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '', old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=TaxRule.objects.get(pk=data['new_taxrule']), new_rule=TaxRule.objects.get(pk=data['new_taxrule']),
) )
elif 'fee' in data: elif 'fee' in data:
return text + ' ' + _('Tax rule of fee #{fee} changed from {old_rule} ' return _('Tax rule of fee #{fee} changed from {old_rule} to {new_rule}.').format(
'to {new_rule}.').format(
fee=data.get('fee', '?'), fee=data.get('fee', '?'),
old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '', old_rule=TaxRule.objects.get(pk=data['old_taxrule']) if data['old_taxrule'] else '',
new_rule=TaxRule.objects.get(pk=data['new_taxrule']), 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': @log_entry_types.new()
return text + ' ' + _('A fee was changed from {old_price} to {new_price}.').format( 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), old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_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), 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'])) old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']: if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(pk=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', '?'), posid=data.get('positionid', '?'),
old_item=old_item, old_item=old_item,
old_price=money_filter(Decimal(data['old_price']), event.currency), 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'])) item = str(event.items.get(pk=data['item']))
if data['variation']: if data['variation']:
item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['variation'])) item += ' - ' + str(ItemVariation.objects.get(item__event=event, pk=data['variation']))
if data['addon_to']: if data['addon_to']:
addon_to = OrderPosition.objects.get(order__event=event, pk=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 ' return _('Position #{posid} created: {item} ({price}) as an add-on to position #{addon_to}.').format(
'position #{addon_to}.').format(
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
item=item, addon_to=addon_to.positionid, item=item, addon_to=addon_to.positionid,
price=money_filter(Decimal(data['price']), event.currency), price=money_filter(Decimal(data['price']), event.currency),
) )
else: else:
return text + ' ' + _('Position #{posid} created: {item} ({price}).').format( return _('Position #{posid} created: {item} ({price}).').format(
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
item=item, item=item,
price=money_filter(Decimal(data['price']), event.currency), 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', '?'), @log_entry_types.new()
) class OrderSecretChanged(OrderPositionChangeLogEntryType):
elif logentry.action_type == 'pretix.event.order.changed.valid_from': action_type = 'pretix.event.order.changed.secret'
return text + ' ' + _('The validity start date for position #{posid} has been changed to {value}.').format( 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', '?'), posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get( value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
'new_value') else '' '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', '?'), posid=data.get('positionid', '?'),
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else '' 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', '?'), @log_entry_types.new()
) class OrderChangedBlockAdded(OrderPositionChangeLogEntryType):
elif logentry.action_type == 'pretix.event.order.changed.remove_block': action_type = 'pretix.event.order.changed.add_block'
return text + ' ' + _('A block has been removed for position #{posid}.').format( plain = _('A block has been added for position #{posid}.')
posid=data.get('positionid', '?'),
)
elif logentry.action_type == 'pretix.event.order.changed.split': @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'])) old_item = str(event.items.get(pk=data['old_item']))
if data['old_variation']: if data['old_variation']:
old_item += ' - ' + str(ItemVariation.objects.get(pk=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, 'organizer': event.organizer.slug,
'code': data['new_order'] '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), old_item=escape(old_item),
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
order='<a href="{}">{}</a>'.format(url, data['new_order']), order='<a href="{}">{}</a>'.format(url, data['new_order']),
old_price=money_filter(Decimal(data['old_price']), event.currency), 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( return _('This order has been created by splitting the order {order}').format(
order=data['original_order'], order=data['original_order'],
) )
def _display_checkin(event, logentry): @log_entry_types.new_from_dict({
data = logentry.parsed_data '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 def display_plain(self, plain, logentry: LogEntry, data):
if 'datetime' in data: if isinstance(plain, tuple):
dt = dateutil.parser.parse(data.get('datetime')) plain_with_dt, plain_without_dt = plain
show_dt = abs((logentry.datetime - dt).total_seconds()) > 5 or 'forced' in data else:
tz = event.timezone plain_with_dt, plain_without_dt = plain, plain
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
if 'list' in data: data = defaultdict(lambda: '?', data)
try:
checkin_list = event.checkin_lists.get(pk=data.get('list')).name
except CheckinList.DoesNotExist:
checkin_list = _("(unknown)")
else:
checkin_list = _("(unknown)")
if logentry.action_type == 'pretix.event.checkin.unknown': event = logentry.event
if show_dt:
return _( if 'list' in data:
'Unknown scan of code "{barcode}" at {datetime} for list "{list}", type "{type}".' try:
).format( data['list'] = event.checkin_lists.get(pk=data.get('list')).name
posid=data.get('positionid'), except CheckinList.DoesNotExist:
type=data.get('type'), data['list'] = _("(unknown)")
barcode=data.get('barcode')[:16], else:
datetime=dt_formatted, data['list'] = _("(unknown)")
list=checkin_list
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: else:
return _( return self.display_plain(
'Unknown scan of code "{barcode}" for list "{list}", type "{type}".' _('Position #{posid} has been scanned and rejected because it has already been scanned before '
).format( 'on list "{list}".'),
posid=data.get('positionid'), logentry, data
type=data.get('type'),
barcode=data.get('barcode')[:16],
list=checkin_list
) )
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': class OrderConsentLogEntryType(OrderLogEntryType):
if show_dt: action_type = 'pretix.event.order.consent'
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
)
if data.get('type') == Checkin.TYPE_EXIT: def display(self, logentry: LogEntry, data):
if show_dt: return _('The user confirmed the following message: "{}"').format(
return _('Position #{posid} has been checked out at {datetime} for list "{list}".').format( bleach.clean(data.get('msg'), tags=set(), strip=True)
posid=data.get('positionid'), )
datetime=dt_formatted,
list=checkin_list
) 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: else:
return _('Position #{posid} has been checked out for list "{list}".').format( return _('The order has been canceled.')
posid=data.get('positionid'),
list=checkin_list
) class OrderPrintLogEntryType(OrderLogEntryType):
if data.get('first'): action_type = 'pretix.event.order.print'
if show_dt:
return _('Position #{posid} has been checked in at {datetime} for list "{list}".').format( def display(self, logentry: LogEntry, data):
posid=data.get('positionid'), return _('Position #{posid} has been printed at {datetime} with type "{type}".').format(
datetime=dt_formatted, posid=data.get('positionid'),
list=checkin_list datetime=date_format(
) dateutil.parser.parse(data["datetime"]).astimezone(logentry.event.timezone),
else: "SHORT_DATETIME_FORMAT"
return _('Position #{posid} has been checked in for list "{list}".').format( ),
posid=data.get('positionid'), type=dict(PrintLog.PRINT_TYPES)[data["type"]],
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
)
) )
@receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display") @receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display")
def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 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.'): if logentry.action_type.startswith('pretix.event.payment.provider.'):
return _('The settings of a payment provider have been changed.') return _('The settings of a payment provider have been changed.')
if logentry.action_type.startswith('pretix.event.tickets.provider.'): if logentry.action_type.startswith('pretix.event.tickets.provider.'):
return _('The settings of a ticket output provider have been changed.') 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") @receiver(signal=orderposition_blocked_display, dispatch_uid="pretixcontrol_orderposition_blocked_display")
def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, block_name, **kwargs): 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({ @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.modified': _('The order details have been changed.'),
'pretix.event.order.unpaid': _('The order has been marked as unpaid.'), 'pretix.event.order.unpaid': _('The order has been marked as unpaid.'),
'pretix.event.order.secret.changed': _('The order\'s secret has been changed.'), 'pretix.event.order.secret.changed': _('The order\'s secret has been changed.'),
@@ -486,17 +521,16 @@ class VoucherRedeemedLogEntryType(VoucherLogEntryType):
action_type = 'pretix.voucher.redeemed' action_type = 'pretix.voucher.redeemed'
plain = _('The voucher has been redeemed in order {order_code}.') plain = _('The voucher has been redeemed in order {order_code}.')
def display(self, logentry): def display(self, logentry, data):
data = json.loads(logentry.data)
data = defaultdict(lambda: '?', data)
url = reverse('control:event.order', kwargs={ url = reverse('control:event.order', kwargs={
'event': logentry.event.slug, 'event': logentry.event.slug,
'organizer': logentry.event.organizer.slug, 'organizer': logentry.event.organizer.slug,
'code': data['order_code'] 'code': data.get('order_code', '?')
}) })
return mark_safe(self.plain.format( return format_html(
order_code='<a href="{}">{}</a>'.format(url, data['order_code']), self.plain,
)) order_code=format_html('<a href="{}">{}</a>', url, data('order_code', '?')),
)
@log_entry_types.new_from_dict({ @log_entry_types.new_from_dict({
@@ -519,8 +553,8 @@ class CoreTaxRuleLogEntryType(TaxRuleLogEntryType):
class TeamMembershipLogEntryType(LogEntryType): class TeamMembershipLogEntryType(LogEntryType):
def display(self, logentry): def display(self, logentry, data):
return self.plain.format(user=logentry.parsed_data.get('email')) return self.plain.format(user=data.get('email'))
@log_entry_types.new_from_dict({ @log_entry_types.new_from_dict({
@@ -537,9 +571,9 @@ class CoreTeamMembershipLogEntryType(TeamMembershipLogEntryType):
class TeamMemberJoinedLogEntryType(LogEntryType): class TeamMemberJoinedLogEntryType(LogEntryType):
action_type = 'pretix.team.member.joined' 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( 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): class UserSettingsChangedLogEntryType(LogEntryType):
action_type = 'pretix.user.settings.changed' action_type = 'pretix.user.settings.changed'
def display(self, logentry): def display(self, logentry, data):
text = str(_('Your account settings have been changed.')) text = str(_('Your account settings have been changed.'))
if 'email' in logentry.parsed_data: if 'email' in data:
text = text + ' ' + str( text = text + ' ' + str(
_('Your email address has been changed to {email}.').format(email=logentry.parsed_data['email'])) _('Your email address has been changed to {email}.').format(email=data['email']))
if 'new_pw' in logentry.parsed_data: if 'new_pw' in data:
text = text + ' ' + str(_('Your password has been changed.')) 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.')) 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.')) text = text + ' ' + str(_('Your account has been disabled.'))
return text return text
class UserImpersonatedLogEntryType(LogEntryType): class UserImpersonatedLogEntryType(LogEntryType):
def display(self, logentry): def display(self, logentry, data):
return self.plain.format(logentry.parsed_data['other_email']) return self.plain.format(data['other_email'])
@log_entry_types.new_from_dict({ @log_entry_types.new_from_dict({
@@ -688,16 +722,13 @@ class CoreLogEntryType(LogEntryType):
@log_entry_types.new_from_dict({ @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.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.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.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.settings': _('The event settings have been changed.'),
'pretix.event.tickets.settings': _('The ticket download 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.activated': _('The shop has been taken live.'),
'pretix.event.live.deactivated': _('The shop has been taken offline.'), 'pretix.event.live.deactivated': _('The shop has been taken offline.'),
'pretix.event.testmode.activated': _('The shop has been taken into test mode.'), 'pretix.event.testmode.activated': _('The shop has been taken into test mode.'),
@@ -717,6 +748,19 @@ class CoreEventLogEntryType(EventLogEntryType):
pass 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({ @log_entry_types.new_from_dict({
'pretix.event.plugins.enabled': _('The plugin has been enabled.'), 'pretix.event.plugins.enabled': _('The plugin has been enabled.'),
'pretix.event.plugins.disabled': _('The plugin has been disabled.'), 'pretix.event.plugins.disabled': _('The plugin has been disabled.'),
@@ -724,7 +768,7 @@ class CoreEventLogEntryType(EventLogEntryType):
class EventPluginStateLogEntryType(EventLogEntryType): class EventPluginStateLogEntryType(EventLogEntryType):
object_link_wrapper = _('Plugin {val}') 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: if 'plugin' in logentry.parsed_data:
app = app_cache.get(logentry.parsed_data['plugin']) app = app_cache.get(logentry.parsed_data['plugin'])
if app and hasattr(app, 'PretixPluginMeta'): if app and hasattr(app, 'PretixPluginMeta'):
@@ -759,17 +803,17 @@ class CoreItemLogEntryType(ItemLogEntryType):
'pretix.event.item.variation.changed': _('The variation "{value}" has been changed.'), 'pretix.event.item.variation.changed': _('The variation "{value}" has been changed.'),
}) })
class VariationLogEntryType(ItemLogEntryType): class VariationLogEntryType(ItemLogEntryType):
def display(self, logentry): def display(self, logentry, data):
if 'value' not in logentry.parsed_data: if 'value' not in data:
# Backwards compatibility # Backwards compatibility
var = ItemVariation.objects.filter(id=logentry.parsed_data['id']).first() var = ItemVariation.objects.filter(id=data['id']).first()
if var: if var:
logentry.parsed_data['value'] = str(var.value) data['value'] = str(var.value)
else: else:
logentry.parsed_data['value'] = '?' data['value'] = '?'
else: else:
logentry.parsed_data['value'] = LazyI18nString(logentry.parsed_data['value']) data['value'] = LazyI18nString(data['value'])
return super().display(logentry) return super().display(logentry, data)
@log_entry_types.new_from_dict({ @log_entry_types.new_from_dict({
@@ -825,27 +869,27 @@ class CoreDiscountLogEntryType(DiscountLogEntryType):
class LegacyCheckinLogEntryType(OrderLogEntryType): class LegacyCheckinLogEntryType(OrderLogEntryType):
action_type = 'pretix.control.views.checkin' action_type = 'pretix.control.views.checkin'
def display(self, logentry): def display(self, logentry, data):
# deprecated # deprecated
dt = dateutil.parser.parse(logentry.parsed_data.get('datetime')) dt = dateutil.parser.parse(data.get('datetime'))
tz = logentry.event.timezone tz = logentry.event.timezone
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT") dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
if 'list' in logentry.parsed_data: if 'list' in data:
try: 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: except CheckinList.DoesNotExist:
checkin_list = _("(unknown)") checkin_list = _("(unknown)")
else: else:
checkin_list = _("(unknown)") 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( 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, datetime=dt_formatted,
list=checkin_list, list=checkin_list,
) )
return _('Position #{posid} has been checked in again at {datetime} on list "{list}".').format( 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, datetime=dt_formatted,
list=checkin_list list=checkin_list
) )

View File

@@ -172,7 +172,7 @@
{% if form.hidden_if_available %} {% if form.hidden_if_available %}
{% bootstrap_field form.hidden_if_available layout="control" horizontal_field_class="col-md-7" %} {% bootstrap_field form.hidden_if_available layout="control" horizontal_field_class="col-md-7" %}
{% endif %} {% endif %}
{% bootstrap_field form.hidden_if_item_available layout="control" horizontal_field_class="col-md-7" %} {% bootstrap_field form.hidden_if_item_available visibility_field=form.hidden_if_item_available_mode layout="control_with_visibility" %}
</fieldset> </fieldset>
{% for v in formsets.values %} {% for v in formsets.values %}
<fieldset> <fieldset>

View File

@@ -2,5 +2,5 @@
{% if mode == "hide" %} {% if mode == "hide" %}
<span class="pull-right text-muted unavail-mode-indicator" data-toggle="tooltip" title="{% trans "Hide product if unavailable" %}. {% if f.variation %}{% trans "You can change this option in the variation settings." %}{% else %}{% trans "You can change this option in the product settings." %}{% endif %}"><span class="fa fa-eye-slash"></span></span> <span class="pull-right text-muted unavail-mode-indicator" data-toggle="tooltip" title="{% trans "Hide product if unavailable" %}. {% if f.variation %}{% trans "You can change this option in the variation settings." %}{% else %}{% trans "You can change this option in the product settings." %}{% endif %}"><span class="fa fa-eye-slash"></span></span>
{% else %} {% else %}
<span class="pull-right text-muted unavail-mode-indicator" data-toggle="tooltip" title="{% trans "Show info text if unavailable" %}. {% if f.variation %}{% trans "You can change this option in the variation settings." %}{% else %}{% trans "You can change this option in the product settings." %}{% endif %}"><span class="fa fa-info-circle"></span></span> <span class="pull-right text-muted unavail-mode-indicator" data-toggle="tooltip" title="{% trans "Show product with info on why its unavailable" %}. {% if f.variation %}{% trans "You can change this option in the variation settings." %}{% else %}{% trans "You can change this option in the product settings." %}{% endif %}"><span class="fa fa-info-circle"></span></span>
{% endif %} {% endif %}

View File

@@ -491,8 +491,11 @@ class PaymentProviderSettings(EventSettingsViewMixin, EventPermissionRequiredMix
if self.form.is_valid(): if self.form.is_valid():
if self.form.has_changed(): if self.form.has_changed():
self.request.event.log_action( self.request.event.log_action(
'pretix.event.payment.provider.' + self.provider.identifier, user=self.request.user, data={ 'pretix.event.payment.provider', user=self.request.user, data={
k: self.form.cleaned_data.get(k) for k in self.form.changed_data 'provider': self.provider.identifier,
'new_values': {
k: self.form.cleaned_data.get(k) for k in self.form.changed_data
}
} }
) )
self.form.save() self.form.save()
@@ -888,11 +891,14 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
provider.form.save() provider.form.save()
if provider.form.has_changed(): if provider.form.has_changed():
self.request.event.log_action( self.request.event.log_action(
'pretix.event.tickets.provider.' + provider.identifier, user=self.request.user, data={ 'pretix.event.tickets.provider', user=self.request.user, data={
k: (provider.form.cleaned_data.get(k).name 'provider': provider.identifier,
if isinstance(provider.form.cleaned_data.get(k), File) 'new_values': {
else provider.form.cleaned_data.get(k)) k: (provider.form.cleaned_data.get(k).name
for k in provider.form.changed_data 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}) 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() 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']: 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( event.settings.tax_rate_default = event.tax_rules.create(
name=LazyI18nString.from_gettext(gettext('VAT')), name=LazyI18nString.from_gettext(gettext('VAT')),

View File

@@ -5,6 +5,10 @@
<div class="col-md-2 col-sm-3 col-xs-6 availability-box unavailable"> <div class="col-md-2 col-sm-3 col-xs-6 availability-box unavailable">
<p><small><a href="#voucher">{% trans "Enter a voucher code below to buy this product." %}</a></small></p> <p><small><a href="#voucher">{% trans "Enter a voucher code below to buy this product." %}</a></small></p>
</div> </div>
{% elif item.current_unavailability_reason == 'hidden_if_item_available' %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box unavailable">
<p><small>{% trans "Not available yet." %}</small></p>
</div>
{% elif item.current_unavailability_reason == 'available_from' or var.current_unavailability_reason == 'available_from' %} {% elif item.current_unavailability_reason == 'available_from' or var.current_unavailability_reason == 'available_from' %}
<div class="col-md-2 col-sm-3 col-xs-6 availability-box unavailable"> <div class="col-md-2 col-sm-3 col-xs-6 availability-box unavailable">
<p><small>{% trans "Not available yet." %}</small></p> <p><small>{% trans "Not available yet." %}</small></p>

View File

@@ -70,7 +70,7 @@ from pretix.base.models import (
) )
from pretix.base.models.event import Event, SubEvent from pretix.base.models.event import Event, SubEvent
from pretix.base.models.items import ( from pretix.base.models.items import (
ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation, Item, ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
) )
from pretix.base.services.placeholders import PlaceholderContext from pretix.base.services.placeholders import PlaceholderContext
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
@@ -302,14 +302,14 @@ def get_grouped_items(event, *, channel: SalesChannel, subevent=None, voucher=No
if item.hidden_if_item_available: if item.hidden_if_item_available:
if item.hidden_if_item_available.has_variations: if item.hidden_if_item_available.has_variations:
dependency_available = any( item._dependency_available = any(
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK
for var in item.hidden_if_item_available.available_variations for var in item.hidden_if_item_available.available_variations
) )
else: else:
q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True) q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
dependency_available = q[0] == Quota.AVAILABILITY_OK item._dependency_available = q[0] == Quota.AVAILABILITY_OK
if dependency_available: if item._dependency_available and item.hidden_if_item_available_mode == Item.UNAVAIL_MODE_HIDDEN:
item._remove = True item._remove = True
continue continue

View File

@@ -38,6 +38,7 @@ var strings = {
'unavailable_available_from': django.pgettext('widget', 'Not yet available'), 'unavailable_available_from': django.pgettext('widget', 'Not yet available'),
'unavailable_available_until': django.pgettext('widget', 'Not available anymore'), 'unavailable_available_until': django.pgettext('widget', 'Not available anymore'),
'unavailable_active': django.pgettext('widget', 'Currently not available'), 'unavailable_active': django.pgettext('widget', 'Currently not available'),
'unavailable_hidden_if_item_available': django.pgettext('widget', 'Not yet available'),
'order_min': django.pgettext('widget', 'minimum amount to order: %s'), 'order_min': django.pgettext('widget', 'minimum amount to order: %s'),
'exit': django.pgettext('widget', 'Close ticket shop'), 'exit': django.pgettext('widget', 'Close ticket shop'),
'loading_error': django.pgettext('widget', 'The ticket shop could not be loaded.'), 'loading_error': django.pgettext('widget', 'The ticket shop could not be loaded.'),

View File

@@ -323,6 +323,7 @@ TEST_ITEM_RES = {
"max_per_order": None, "max_per_order": None,
"hidden_if_available": None, "hidden_if_available": None,
"hidden_if_item_available": None, "hidden_if_item_available": None,
"hidden_if_item_available_mode": "hide",
"checkin_attention": False, "checkin_attention": False,
"checkin_text": None, "checkin_text": None,
"has_variations": False, "has_variations": False,