Compare commits

..

1 Commits

Author SHA1 Message Date
Raphael Michel
1682e983c7 Order details: Link to subevent details (Z#23227664) 2026-05-04 15:42:15 +02:00
9 changed files with 92 additions and 204 deletions

View File

@@ -115,10 +115,10 @@ class PluginsField(serializers.Field):
def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins
active_plugins = set(obj.get_plugins())
return sorted([
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in active_plugins
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
])
def to_internal_value(self, data):

View File

@@ -49,39 +49,14 @@ class PluginType(Enum):
EXPORT = 4
def plugin_is_available(meta, event=None, organizer=None):
if not hasattr(meta.app, 'is_available'):
return True
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if event:
return meta.app.is_available(event)
elif organizer:
if not hasattr(organizer, '_plugin_availability_fallback_event'):
with scope(organizer=organizer):
setattr(organizer, '_plugin_availability_fallback_event', organizer.events.first())
return (
organizer._plugin_availability_fallback_event
and meta.app.is_available(organizer._plugin_availability_fallback_event)
)
elif level == PLUGIN_LEVEL_ORGANIZER:
if organizer:
return meta.app.is_available(organizer)
elif event:
return meta.app.is_available(event.organizer)
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer):
return meta.app.is_available(event or organizer)
return True
def get_all_plugins(*, event=None, organizer=None) -> List[type]:
"""
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
"""
assert not event or not organizer
plugins = []
event_fallback = None
event_fallback_used = False
for app in apps.get_app_configs():
if hasattr(app, 'PretixPluginMeta'):
meta = app.PretixPluginMeta
@@ -90,8 +65,28 @@ def get_all_plugins(*, event=None, organizer=None) -> List[type]:
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue
if not plugin_is_available(meta, event, organizer):
continue
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if event and hasattr(app, 'is_available'):
if not app.is_available(event):
continue
elif organizer and hasattr(app, 'is_available'):
if not event_fallback_used:
with scope(organizer=organizer):
event_fallback = organizer.events.first()
event_fallback_used = True
if not event_fallback or not app.is_available(event_fallback):
continue
elif level == PLUGIN_LEVEL_ORGANIZER:
if organizer and hasattr(app, 'is_available'):
if not app.is_available(organizer):
continue
elif event and hasattr(app, 'is_available'):
if not app.is_available(event.organizer):
continue
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer) and hasattr(app, 'is_available'):
if not app.is_available(event or organizer):
continue
plugins.append(meta)
return sorted(

View File

@@ -21,7 +21,7 @@
#
import datetime
from collections import namedtuple
from typing import Tuple, Union
from typing import Union
from zoneinfo import ZoneInfo
from dateutil import parser
@@ -34,7 +34,7 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
EVENT_CHOICES = (
BASE_CHOICES = (
('date_from', _('Event start')),
('date_to', _('Event end')),
('date_admission', _('Event admission')),
@@ -42,12 +42,6 @@ EVENT_CHOICES = (
('presale_end', _('Presale end')),
)
# extend NO_BEFORE_VALUES in reldate.js if changed
ORDER_CHOICES = (
('datetime', _('Order creation')),
)
ORDER_CHOICES_KEYS = {choice[0] for choice in ORDER_CHOICES}
RelativeDate = namedtuple('RelativeDate', ['days', 'minutes', 'time', 'is_after', 'base_date_name'], defaults=(0, None, None, False, 'date_from'))
@@ -57,43 +51,17 @@ class RelativeDateWrapper:
that the underlying data is either a fixed date or a number of days and a wall clock
time to calculate the date based on a base point.
The base point can be the ``date_from``, ``date_to``, ``date_admission``, ``presale_start``
or ``presale_end`` attribute of an event or subevent, as well as a ``datetime`` of an order.
If the respective attribute is not set, ``date_from`` will be used.
The base point can be the date_from, date_to, date_admission, presale_start or presale_end
attribute of an event or subevent. If the respective attribute is not set, ``date_from``
will be used.
"""
def __init__(self, data: Union[datetime.datetime, RelativeDate]):
self.data = data
def _resolve_base_date(self, reference) -> Tuple[datetime.datetime, ZoneInfo]:
"""
def date(self, event) -> datetime.date:
from .models import SubEvent
:param reference:
:return:
"""
from .models import Event, Order, SubEvent
if self.data.base_date_name in ORDER_CHOICES_KEYS:
if not isinstance(reference, Order):
raise ValueError('A order-based relative datetime choice must be used with an order object')
event = reference.event
base_date = getattr(reference, self.data.base_date_name)
elif isinstance(reference, SubEvent):
event = reference.event
base_date = (getattr(reference, self.data.base_date_name) or
getattr(reference.event, self.data.base_date_name) or
reference.date_from)
elif isinstance(reference, Event):
event = reference
base_date = getattr(reference, self.data.base_date_name) or event.date_from
else:
raise TypeError("Only event, subevent or order objects are supported")
tz = ZoneInfo(event.settings.timezone)
return base_date, tz
def date(self, reference) -> datetime.date:
if isinstance(self.data, datetime.datetime):
return self.data.date()
elif isinstance(self.data, datetime.date):
@@ -102,7 +70,15 @@ class RelativeDateWrapper:
if self.data.minutes is not None:
raise ValueError('A minute-based relative datetime can not be used as a date')
base_date, tz = self._resolve_base_date(reference)
tz = ZoneInfo(event.settings.timezone)
if isinstance(event, SubEvent):
base_date = (
getattr(event, self.data.base_date_name)
or getattr(event.event, self.data.base_date_name)
or event.date_from
)
else:
base_date = getattr(event, self.data.base_date_name) or event.date_from
if self.data.is_after:
new_date = base_date.astimezone(tz) + datetime.timedelta(days=self.data.days)
@@ -110,11 +86,21 @@ class RelativeDateWrapper:
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days)
return new_date.date()
def datetime(self, reference) -> datetime.datetime:
def datetime(self, event) -> datetime.datetime:
from .models import SubEvent
if isinstance(self.data, (datetime.datetime, datetime.date)):
return self.data
else:
base_date, tz = self._resolve_base_date(reference)
tz = ZoneInfo(event.settings.timezone)
if isinstance(event, SubEvent):
base_date = (
getattr(event, self.data.base_date_name)
or getattr(event.event, self.data.base_date_name)
or event.date_from
)
else:
base_date = getattr(event, self.data.base_date_name) or event.date_from
if self.data.minutes is not None:
if self.data.is_after:
@@ -186,9 +172,7 @@ class RelativeDateWrapper:
minutes=None,
is_after=len(parts) > 4 and parts[4] == "after",
)
if data.base_date_name in ORDER_CHOICES_KEYS and parts[4] != "after":
raise ValueError('ORDER_CHOICE: {} cannot be combined with "before"'.format(data.base_date_name))
if data.base_date_name not in [k[0] for k in EVENT_CHOICES + ORDER_CHOICES]:
if data.base_date_name not in [k[0] for k in BASE_CHOICES]:
raise ValueError('{} is not a valid base date'.format(data.base_date_name))
else:
data = parser.parse(input)
@@ -327,17 +311,11 @@ class RelativeDateTimeField(forms.MultiValueField):
]
if kwargs.get('limit_choices'):
limit = kwargs.pop('limit_choices')
choices = [(k, v) for k, v in EVENT_CHOICES if k in limit]
choices = [(k, v) for k, v in BASE_CHOICES if k in limit]
else:
choices = EVENT_CHOICES
self.relative_to_order = kwargs.pop('relative_to_order', False)
if self.relative_to_order:
choices += ORDER_CHOICES
choices = BASE_CHOICES
if not kwargs.get('required', True):
status_choices.insert(0, ('unset', _('Not set')))
fields = reldatetimeparts(
status=forms.ChoiceField(
choices=status_choices,
@@ -381,13 +359,12 @@ class RelativeDateTimeField(forms.MultiValueField):
)
def set_event(self, event):
choices = [
(k, v) for k, v in EVENT_CHOICES if getattr(event, k, None)
self.widget.widgets[reldatetimeparts.indizes.rel_days_relationto].choices = [
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
]
self.widget.widgets[reldatetimeparts.indizes.rel_mins_relationto].choices = [
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
]
if self.relative_to_order:
choices += ORDER_CHOICES
self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = choices
self.widget.widgets[reldatetimeparts.indizes.rel_mins_relationto].choices = choices
def compress(self, data_list):
if not data_list:
@@ -427,10 +404,6 @@ class RelativeDateTimeField(forms.MultiValueField):
raise ValidationError(self.error_messages['incomplete'])
elif data.status == 'relative_minutes' and (data.rel_mins_number is None or not data.rel_mins_relationto):
raise ValidationError(self.error_messages['incomplete'])
elif data.status == 'relative' and data.rel_days_relationto in ORDER_CHOICES_KEYS and data.rel_days_relation == 'before':
raise ValidationError(_('A relative date in relation to an order can only be after the order has been placed'))
elif data.status == 'relative' and data.rel_mins_relationto in ORDER_CHOICES_KEYS and data.rel_mins_relation == 'before':
raise ValidationError(_('A relative date in relation to an order can only be after the order has been placed'))
return super().clean(value)
@@ -451,14 +424,13 @@ class RelativeDateWidget(RelativeDateTimeWidget):
def __init__(self, *args, **kwargs):
self.status_choices = kwargs.pop('status_choices')
base_choices = kwargs.pop('base_choices')
widgets = reldateparts(
status=forms.RadioSelect(choices=self.status_choices),
absolute=forms.DateInput(
attrs={'class': 'datepickerfield'}
),
rel_days_number=forms.NumberInput(),
rel_days_relationto=forms.Select(choices=base_choices),
rel_days_relationto=forms.Select(choices=kwargs.pop('base_choices')),
rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
)
forms.MultiWidget.__init__(self, widgets=widgets, *args, **kwargs)
@@ -502,12 +474,6 @@ class RelativeDateField(RelativeDateTimeField):
]
if not kwargs.get('required', True):
status_choices.insert(0, ('unset', _('Not set')))
choices = EVENT_CHOICES
self.relative_to_order = kwargs.pop('relative_to_order', False)
if self.relative_to_order:
choices += ORDER_CHOICES
fields = reldateparts(
status=forms.ChoiceField(
choices=status_choices,
@@ -520,7 +486,7 @@ class RelativeDateField(RelativeDateTimeField):
required=False
),
rel_days_relationto=forms.ChoiceField(
choices=choices,
choices=BASE_CHOICES,
required=False
),
rel_days_relation=forms.ChoiceField(
@@ -529,18 +495,15 @@ class RelativeDateField(RelativeDateTimeField):
),
)
if 'widget' not in kwargs:
kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=choices)
kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=BASE_CHOICES)
forms.MultiValueField.__init__(
self, fields=fields, require_all_fields=False, *args, **kwargs
)
def set_event(self, event):
choices = [
(k, v) for k, v in EVENT_CHOICES if getattr(event, k, None)
self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = [
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
]
if self.relative_to_order:
choices += ORDER_CHOICES
self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = choices
def compress(self, data_list):
if not data_list:
@@ -564,8 +527,6 @@ class RelativeDateField(RelativeDateTimeField):
raise ValidationError(self.error_messages['incomplete'])
elif data.status == 'relative' and (data.rel_days_number is None or not data.rel_days_relationto):
raise ValidationError(self.error_messages['incomplete'])
elif data.status == 'relative' and data.rel_days_relationto in ORDER_CHOICES_KEYS and data.rel_days_relation == 'before':
raise ValidationError(_('A relative date in relation to an order can only be after the order has been placed'))
return forms.MultiValueField.clean(self, value)

View File

@@ -1,6 +1,4 @@
{% load i18n %}
{% load static %}
<script src="{% static 'pretixbase/js/reldate.js' %}" defer></script>
<div class="reldatetime">
{% for group_name, group_choices, group-index in widget.subwidgets.0.optgroups %}
{% for selopt in group_choices %}

View File

@@ -1,6 +1,4 @@
{% load i18n %}
{% load static %}
<script src="{% static 'pretixbase/js/reldate.js' %}" defer></script>
<div class="reldatetime">
{% for group_name, group_choices, group-index in widget.subwidgets.0.optgroups %}
{% for selopt in group_choices %}

View File

@@ -471,7 +471,9 @@
{% endif %}
{% if line.subevent %}
<br/>
<span class="fa fa-calendar fa-fw"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display_with_times }}
<span class="fa fa-calendar fa-fw"></span>
<a href="{% url "control:event.subevent" organizer=request.event.organizer.slug event=request.event.slug subevent=line.subevent_id %}">{{ line.subevent.name }}</a>
&middot; {{ line.subevent.get_date_range_display_with_times }}
{% endif %}
{% if line.used_membership %}
<br /><span class="fa fa-id-card fa-fw" aria-hidden="true"></span>

View File

@@ -102,7 +102,7 @@ from pretix.base.models.organizer import (
from pretix.base.payment import PaymentException
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER, plugin_is_available,
PLUGIN_LEVEL_ORGANIZER,
)
from pretix.base.services.export import (
init_organizer_exporters, multiexport, scheduled_organizer_export,
@@ -597,13 +597,6 @@ class OrganizerCreate(CreateView):
})
def available_plugins(organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, TemplateView, SingleObjectMixin):
model = Organizer
context_object_name = 'organizer'
@@ -613,6 +606,12 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
def get_object(self, queryset=None) -> Organizer:
return self.request.organizer
def available_plugins(self, organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
def prepare_links(self, pluginmeta, key):
links = getattr(pluginmeta, key, [])
try:
@@ -638,7 +637,7 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
from pretix.base.plugins import CATEGORY_LABELS, CATEGORY_ORDER
context = super().get_context_data(*args, **kwargs)
plugins = list(available_plugins(self.object))
plugins = list(self.available_plugins(self.object))
active_counter = Counter()
events_total = 0
@@ -686,7 +685,7 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
self.object = self.get_object()
plugins_available = {
p.module: p for p in available_plugins(self.object)
p.module: p for p in self.available_plugins(self.object)
}
choose_events_next = False
with transaction.atomic():
@@ -787,6 +786,12 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
}
return kwargs
def available_plugins(self, organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
def get_context_data(self, **kwargs):
return super().get_context_data(
plugin=self.plugin,
@@ -794,10 +799,12 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
)
def dispatch(self, request, *args, **kwargs):
try:
self.plugin = next(p for p in available_plugins(self.request.organizer) if p.module == kwargs["plugin"])
except StopIteration:
plugins_available = {
p.module: p for p in self.available_plugins(self.request.organizer)
}
if kwargs["plugin"] not in plugins_available:
raise Http404(_("Unknown plugin."))
self.plugin = plugins_available[kwargs["plugin"]]
level = getattr(self.plugin, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_ORGANIZER:
raise Http404(_("This plugin can only be enabled for the entire organizer account."))
@@ -828,9 +835,6 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
logentries_to_save = []
for e in self.request.organizer.events.filter(pk__in=events_to_enable):
if not plugin_is_available(self.plugin, organizer=self.request.organizer, event=e):
messages.warning(self.request, _("This plugin cannot be activated for event {}.").format(e.name))
continue
logentries_to_save.append(
e.log_action('pretix.event.plugins.enabled', user=self.request.user, data={'plugin': self.plugin.module}, save=False)
)

View File

@@ -1,44 +0,0 @@
if (!window.__reldateInitialized) {
window.__reldateInitialized = true;
document.addEventListener('DOMContentLoaded', () => {
const NO_BEFORE_VALUES = ['datetime'];
document.querySelectorAll('.reldatetime, .reldate').forEach(container => {
const groups = container.querySelectorAll('.radio');
groups.forEach(group => {
const selects = group.querySelectorAll('select');
if (selects.length < 2) return;
let referenceSelect = null;
let beforeAfterSelect = null;
selects.forEach(sel => {
const values = Array.from(sel.options).map(o => o.value);
// only attach to selects that contain problematic values
if (NO_BEFORE_VALUES.some(v => values.includes(v))) {
referenceSelect = sel;
} else if (values.includes('before') && values.includes('after')) {
beforeAfterSelect = sel;
}
});
if (!referenceSelect || !beforeAfterSelect) return;
const beforeOption = beforeAfterSelect.querySelector('option[value="before"]');
const updateBeforeOption = () => {
if (NO_BEFORE_VALUES.includes(referenceSelect.value)) {
beforeOption.disabled = true;
if (beforeAfterSelect.value === 'before') {
beforeAfterSelect.value = 'after';
beforeAfterSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
} else {
beforeOption.disabled = false;
}
};
referenceSelect.addEventListener('change', updateBeforeOption);
updateBeforeOption();
});
});
});
}

View File

@@ -19,13 +19,13 @@
# 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 datetime import datetime, time, timedelta
from datetime import datetime, time
from zoneinfo import ZoneInfo
import pytest
from django_scopes import scope
from pretix.base.models import Event, Order, Organizer
from pretix.base.models import Event, Organizer
from pretix.base.reldate import RelativeDate, RelativeDateWrapper
TOKYO = ZoneInfo('Asia/Tokyo')
@@ -147,29 +147,3 @@ def test_unserialize():
rdw = RelativeDateWrapper.from_string('RELDATE/minutes/60/date_from/')
assert rdw.data == RelativeDate(days=0, time=None, base_date_name='date_from', minutes=60)
@pytest.mark.django_db
def test_relative_to_order(event):
with scope(organizer=event.organizer):
order_moment = datetime(2020, 3, 29, 18, 0, 0, tzinfo=TOKYO)
order = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, secret="k24fiuwvu8kxz3y1",
datetime=order_moment,
expires=order_moment + timedelta(days=10),
sales_channel=event.organizer.sales_channels.get(identifier="web"),
total=23, locale='en'
)
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='datetime', minutes=None))
assert rdw.datetime(order).astimezone(TOKYO) == datetime(2020, 3, 28, 18, 0, 0, tzinfo=TOKYO)
assert rdw.to_string() == 'RELDATE/1/-/datetime/'
# this is expressible as a RelativeDate but the Wrapper should catch it as invalid when parsing
with pytest.raises(ValueError):
rdw.from_string(rdw.to_string())
rdw = RelativeDateWrapper(RelativeDate(days=1, time=None, base_date_name='datetime', minutes=None, is_after=True))
assert rdw.datetime(order).astimezone(TOKYO) == datetime(2020, 3, 30, 18, 0, 0, tzinfo=TOKYO)
assert rdw.to_string() == 'RELDATE/1/-/datetime/after'