Allow to define ticket validity through a product (#3105)

This commit is contained in:
Raphael Michel
2023-02-13 14:46:52 +01:00
committed by GitHub
parent fdadda9910
commit f63408504e
24 changed files with 1025 additions and 167 deletions

View File

@@ -242,7 +242,9 @@ class ItemSerializer(I18nAwareModelSerializer):
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data',
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
'grant_membership_duration_like_event', 'grant_membership_duration_days',
'grant_membership_duration_months')
'grant_membership_duration_months', 'validity_mode', 'validity_fixed_from', 'validity_fixed_until',
'validity_dynamic_duration_minutes', 'validity_dynamic_duration_hours', 'validity_dynamic_duration_days',
'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit')
read_only_fields = ('has_variations',)
def __init__(self, *args, **kwargs):

View File

@@ -785,12 +785,14 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
voucher = serializers.SlugRelatedField(slug_field='code', queryset=Voucher.objects.none(),
required=False, allow_null=True)
country = CompatibleCountryField(source='*')
requested_valid_from = serializers.DateTimeField(required=False, allow_null=True)
class Meta:
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until')
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until',
'requested_valid_from')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -1177,6 +1179,20 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
elif seated:
errs[i]['seat'] = ['The specified product requires to choose a seat.']
requested_valid_from = pos_data.pop('requested_valid_from', None)
if 'valid_from' not in pos_data and 'valid_until' not in pos_data:
valid_from, valid_until = pos_data['item'].compute_validity(
requested_start=(
max(requested_valid_from, now())
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
else now()
),
enforce_start_limit=True,
override_tz=self.context['event'].timezone,
)
pos_data['valid_from'] = valid_from
pos_data['valid_until'] = valid_until
if not force:
for i, pos_data in enumerate(positions_data):
if pos_data.get('voucher'):

View File

@@ -35,6 +35,7 @@
import copy
import json
import logging
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
@@ -55,7 +56,7 @@ from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries import countries
from django_countries.fields import Country, CountryField
@@ -73,7 +74,7 @@ from pretix.base.forms.widgets import (
from pretix.base.i18n import (
get_babel_locale, get_language_without_region, language,
)
from pretix.base.models import InvoiceAddress, Question, QuestionOption
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
from pretix.base.models.tax import VAT_ID_COUNTRIES, ask_for_vat_id
from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
@@ -573,6 +574,34 @@ class BaseQuestionsForm(forms.Form):
super().__init__(*args, **kwargs)
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
if item.validity_dynamic_start_choice_day_limit:
max_date = now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
else:
max_date = None
if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
attrs = {}
if max_date:
attrs['data-max'] = max_date.date().isoformat()
self.fields['requested_valid_from'] = forms.DateField(
label=_('Start date'),
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
required=False,
widget=DatePickerWidget(attrs),
validators=[MaxDateValidator(max_date.date())] if max_date else []
)
else:
self.fields['requested_valid_from'] = forms.SplitDateTimeField(
label=_('Start date'),
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
required=False,
widget=SplitDateTimePickerWidget(
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
max_date=max_date
),
validators=[MaxDateTimeValidator(max_date)] if max_date else []
)
add_fields = {}
if item.ask_attendee_data and event.settings.attendee_names_asked:

View File

@@ -0,0 +1,64 @@
# Generated by Django 3.2.17 on 2023-02-08 15:46
import django.core.serializers.json
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0230_auto_20230208_0939'),
]
operations = [
migrations.AddField(
model_name='item',
name='validity_dynamic_duration_days',
field=models.PositiveIntegerField(null=True),
),
migrations.AddField(
model_name='item',
name='validity_dynamic_duration_hours',
field=models.PositiveIntegerField(null=True),
),
migrations.AddField(
model_name='item',
name='validity_dynamic_duration_minutes',
field=models.PositiveIntegerField(null=True),
),
migrations.AddField(
model_name='item',
name='validity_dynamic_duration_months',
field=models.PositiveIntegerField(null=True),
),
migrations.AddField(
model_name='item',
name='validity_dynamic_start_choice',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='item',
name='validity_dynamic_start_choice_day_limit',
field=models.PositiveIntegerField(null=True),
),
migrations.AddField(
model_name='item',
name='validity_fixed_from',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='item',
name='validity_fixed_until',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='item',
name='validity_mode',
field=models.CharField(max_length=16, null=True),
),
migrations.AddField(
model_name='cartposition',
name='requested_valid_from',
field=models.DateTimeField(null=True),
),
]

View File

@@ -33,12 +33,13 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import calendar
import sys
import uuid
from collections import Counter, OrderedDict
from datetime import date, datetime, time
from datetime import date, datetime, time, timedelta
from decimal import Decimal, DecimalException
from typing import Tuple
from typing import Optional, Tuple
import dateutil.parser
import pytz
@@ -339,7 +340,33 @@ class Item(LoggedModel):
:type sales_channels: bool
:param issue_giftcard: If ``True``, buying this product will give you a gift card with the value of the product's price
:type issue_giftcard: bool
:param validity_mode: Instruction how to set ``valid_from``/``valid_until`` on tickets, ``null`` is default event validity.
:type validity_mode: str
:param validity_fixed_from: Start of validity if ``validity_mode`` is ``"fixed"``.
:type validity_fixed_from: datetime
:param validity_fixed_until: End of validity if ``validity_mode`` is ``"fixed"``.
:type validity_fixed_until: datetime
:param validity_dynamic_duration_minutes: Number of minutes if ``validity_mode`` is ``"dnyamic"``.
:type validity_dynamic_duration_minutes: int
:param validity_dynamic_duration_hours: Number of hours if ``validity_mode`` is ``"dnyamic"``.
:type validity_dynamic_duration_hours: int
:param validity_dynamic_duration_days: Number of days if ``validity_mode`` is ``"dnyamic"``.
:type validity_dynamic_duration_days: int
:param validity_dynamic_duration_months: Number of months if ``validity_mode`` is ``"dnyamic"``.
:type validity_dynamic_duration_months: int
:param validity_dynamic_start_choice: Whether customers can choose the start date if ``validity_mode`` is ``"dnyamic"``.
:type validity_dynamic_start_choice: bool
:param validity_dynamic_start_choice_day_limit: Start date may be maximum this many days in the future if ``validity_mode`` is ``"dnyamic"``.
:type validity_dynamic_start_choice_day_limnit: int
"""
VALIDITY_MODE_FIXED = 'fixed'
VALIDITY_MODE_DYNAMIC = 'dynamic'
VALIDITY_MODES = (
(None, _('Event validity (default)')),
(VALIDITY_MODE_FIXED, _('Fixed time frame')),
(VALIDITY_MODE_DYNAMIC, _('Dynamic validity')),
)
objects = ItemQuerySetManager()
@@ -560,6 +587,49 @@ class Item(LoggedModel):
verbose_name=_('Membership duration in months'),
default=0,
)
validity_mode = models.CharField(
choices=VALIDITY_MODES,
null=True, blank=True, max_length=16,
verbose_name=_('Validity'),
help_text=_(
'When setting up a regular event, or an event series with time slots, you typically to NOT need to change '
'this value. The default setting means that the validity time of tickets will not be decided by the '
'product, but by the event and check-in configuration. Only use the other options if you need them to '
'realize e.g. a booking of a year-long ticket with a dynamic start date. Note that the validity will be '
'stored with the ticket, so if you change the settings here later, existing tickets will not be affected '
'by the change but keep their current validity.'
)
)
validity_fixed_from = models.DateTimeField(null=True, blank=True, verbose_name=_('Start of validity'))
validity_fixed_until = models.DateTimeField(null=True, blank=True, verbose_name=_('End of validity'))
validity_dynamic_duration_minutes = models.PositiveIntegerField(
blank=True, null=True,
verbose_name=_('Minutes'),
)
validity_dynamic_duration_hours = models.PositiveIntegerField(
blank=True, null=True,
verbose_name=_('Hours')
)
validity_dynamic_duration_days = models.PositiveIntegerField(
blank=True, null=True,
verbose_name=_('Days'),
)
validity_dynamic_duration_months = models.PositiveIntegerField(
blank=True, null=True,
verbose_name=_('Months'),
)
validity_dynamic_start_choice = models.BooleanField(
verbose_name=_('Customers can select the validity start date'),
help_text=_('If not selected, the validity always starts at the time of purchase.'),
default=False
)
validity_dynamic_start_choice_day_limit = models.PositiveIntegerField(
blank=True, null=True,
verbose_name=_('Maximum future start'),
help_text=_('The selected start date may only be this many days in the future.')
)
# !!! Attention: If you add new fields here, also add them to the copying code in
# pretix/control/forms/item.py if applicable.
@@ -764,6 +834,63 @@ class Item(LoggedModel):
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
def compute_validity(
self, *, requested_start: datetime, override_tz=None, enforce_start_limit=False
) -> Tuple[Optional[datetime], Optional[datetime]]:
if self.validity_mode == Item.VALIDITY_MODE_FIXED:
return self.validity_fixed_from, self.validity_fixed_until
elif self.validity_mode == Item.VALIDITY_MODE_DYNAMIC:
tz = override_tz or self.event.timezone
requested_start = requested_start or now()
if enforce_start_limit and not self.validity_dynamic_start_choice:
requested_start = now()
if enforce_start_limit and self.validity_dynamic_start_choice_day_limit is not None:
requested_start = min(requested_start, now() + timedelta(days=self.validity_dynamic_start_choice_day_limit))
valid_until = requested_start.astimezone(tz)
if self.validity_dynamic_duration_months:
replace_year = valid_until.year
replace_month = valid_until.month + self.validity_dynamic_duration_months
while replace_month > 12:
replace_month -= 12
replace_year += 1
max_day = calendar.monthrange(replace_year, replace_month)[1]
replace_date = date(
year=replace_year,
month=replace_month,
day=min(valid_until.day, max_day),
)
if self.validity_dynamic_duration_days:
replace_date += timedelta(days=self.validity_dynamic_duration_days)
valid_until = tz.localize(valid_until.replace(
year=replace_date.year,
month=replace_date.month,
day=replace_date.day,
hour=23, minute=59, second=59, microsecond=0,
tzinfo=None,
))
elif self.validity_dynamic_duration_days:
replace_date = valid_until.date() + timedelta(days=self.validity_dynamic_duration_days - 1)
valid_until = tz.localize(valid_until.replace(
year=replace_date.year,
month=replace_date.month,
day=replace_date.day,
hour=23, minute=59, second=59, microsecond=0,
tzinfo=None
))
if self.validity_dynamic_duration_hours:
valid_until += timedelta(hours=self.validity_dynamic_duration_hours)
if self.validity_dynamic_duration_minutes:
valid_until += timedelta(minutes=self.validity_dynamic_duration_minutes)
return requested_start, valid_until
else:
return None, None
def _all_sales_channels_identifiers():
from pretix.base.channels import get_all_sales_channels

View File

@@ -2357,6 +2357,20 @@ class OrderPosition(AbstractPosition):
op._calculate_tax()
if cartpos.voucher:
op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher
if cartpos.item.validity_mode:
valid_from, valid_until = cartpos.item.compute_validity(
requested_start=(
max(cartpos.requested_valid_from, now())
if cartpos.requested_valid_from and cartpos.item.validity_dynamic_start_choice
else now()
),
enforce_start_limit=True,
override_tz=order.event.timezone,
)
op.valid_from = valid_from
op.valid_until = valid_until
op.positionid = i + 1
op.save()
ops.append(op)
@@ -2730,6 +2744,9 @@ class CartPosition(AbstractPosition):
line_price_gross = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
)
requested_valid_from = models.DateTimeField(
null=True,
)
objects = ScopedManager(organizer='event__organizer')
@@ -2823,6 +2840,25 @@ class CartPosition(AbstractPosition):
addons = [op for op in self.addons.all() if not op.is_bundled]
return sorted(addons, key=lambda cp: cp.sort_key)
@cached_property
def predicted_validity(self):
return self.item.compute_validity(
requested_start=(
max(self.requested_valid_from, now())
if self.requested_valid_from and self.item.validity_dynamic_start_choice
else now()
),
override_tz=self.event.timezone,
)
@property
def valid_from(self):
return self.predicted_validity[0]
@property
def valid_until(self):
return self.predicted_validity[1]
class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True)

View File

@@ -405,7 +405,55 @@ DEFAULT_VARIABLES = OrderedDict((
"evaluate": lambda op, order, ev: date_format(
now().astimezone(timezone(ev.settings.timezone)),
"TIME_FORMAT"
) if ev.date_admission else ""
)
}),
("valid_from_date", {
"label": _("Validity start date"),
"editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: date_format(
now().astimezone(timezone(ev.settings.timezone)),
"SHORT_DATE_FORMAT"
) if op.valid_from else ""
}),
("valid_from_datetime", {
"label": _("Validity start date and time"),
"editor_sample": _("2017-05-31 19:00"),
"evaluate": lambda op, order, ev: date_format(
op.valid_from.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATETIME_FORMAT"
) if op.valid_from else ""
}),
("valid_from_time", {
"label": _("Validity start time"),
"editor_sample": _("19:00"),
"evaluate": lambda op, order, ev: date_format(
op.valid_from.astimezone(timezone(ev.settings.timezone)),
"TIME_FORMAT"
) if op.valid_from else ""
}),
("valid_until_date", {
"label": _("Validity end date"),
"editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: date_format(
now().astimezone(timezone(ev.settings.timezone)),
"SHORT_DATE_FORMAT"
) if op.valid_until else ""
}),
("valid_until_datetime", {
"label": _("Validity end date and time"),
"editor_sample": _("2017-05-31 19:00"),
"evaluate": lambda op, order, ev: date_format(
op.valid_until.astimezone(timezone(ev.settings.timezone)),
"SHORT_DATETIME_FORMAT"
) if op.valid_until else ""
}),
("valid_until_time", {
"label": _("Validity end time"),
"editor_sample": _("19:00"),
"evaluate": lambda op, order, ev: date_format(
op.valid_until.astimezone(timezone(ev.settings.timezone)),
"TIME_FORMAT"
) if op.valid_until else ""
}),
("seat", {
"label": _("Seat: Full name"),

View File

@@ -0,0 +1,38 @@
#
# 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 datetime import datetime
from django import template
from django.utils.timezone import get_current_timezone
from pretix.helpers.daterange import datetimerange
register = template.Library()
@register.filter("datetimerange")
def datetimerange_filter(start: datetime, end: datetime):
return datetimerange(
start.astimezone(get_current_timezone()),
end.astimezone(get_current_timezone()),
as_html=True
)

View File

@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import datetime
import json
from collections import OrderedDict
from decimal import Decimal
@@ -28,6 +29,7 @@ from django.core.files.uploadedfile import UploadedFile
from django.db import IntegrityError
from django.db.models import Prefetch, QuerySet
from django.utils.functional import cached_property
from django.utils.timezone import make_aware
from pretix.base.forms.questions import (
BaseInvoiceAddressForm, BaseInvoiceNameForm, BaseQuestionsForm,
@@ -155,6 +157,16 @@ class BaseQuestionsViewMixin:
v = v if v != '' else None
setattr(form.pos, k, v)
setattr(prof, k, v)
elif k == 'requested_valid_from':
if isinstance(v, datetime.datetime):
form.pos.requested_valid_from = v
elif isinstance(v, datetime.date):
form.pos.requested_valid_from = make_aware(datetime.datetime.combine(
v,
datetime.time(hour=0, minute=0, second=0, microsecond=0)
), self.request.event.timezone)
else:
form.pos.requested_valid_from = None
elif k.startswith('question_'):
field = form.fields[k]
if hasattr(field, 'answer'):

View File

@@ -618,6 +618,14 @@ class ItemUpdateForm(I18nModelForm):
"admission ticket. Otherwise customers might not be able to use the membership later. If you "
"want the membership to be non-personalized, set the membership type to be transferable.")
)
if d.get('validity_mode') == Item.VALIDITY_MODE_FIXED and d.get('validity_fixed_from') and d.get('validity_fixed_until'):
if d.get('validity_fixed_from') > d.get('validity_fixed_until'):
self.add_error(
'validity_fixed_from',
_("The start of validity must be before the end of validity.")
)
return d
def clean_picture(self):
@@ -667,10 +675,21 @@ class ItemUpdateForm(I18nModelForm):
'grant_membership_duration_like_event',
'grant_membership_duration_days',
'grant_membership_duration_months',
'validity_mode',
'validity_fixed_from',
'validity_fixed_until',
'validity_dynamic_duration_minutes',
'validity_dynamic_duration_hours',
'validity_dynamic_duration_days',
'validity_dynamic_duration_months',
'validity_dynamic_start_choice',
'validity_dynamic_start_choice_day_limit',
]
field_classes = {
'available_from': SplitDateTimeField,
'available_until': SplitDateTimeField,
'validity_fixed_from': SplitDateTimeField,
'validity_fixed_until': SplitDateTimeField,
'hidden_if_available': SafeModelChoiceField,
'grant_membership_type': SafeModelChoiceField,
'require_membership_types': SafeModelMultipleChoiceField,
@@ -678,6 +697,8 @@ class ItemUpdateForm(I18nModelForm):
widgets = {
'available_from': SplitDateTimePickerWidget(),
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
'validity_fixed_from': SplitDateTimePickerWidget(),
'validity_fixed_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_validity_fixed_from_0'}),
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),

View File

@@ -176,14 +176,55 @@
</fieldset>
{% endfor %}
<fieldset>
<legend>{% trans "Tickets & check-in" %}</legend>
<legend>{% trans "Tickets & Badges" %}</legend>
{% bootstrap_field form.generate_tickets layout="control" %}
{% for f in plugin_forms %}
{% if f.is_layouts %}
{% bootstrap_form f layout="control" %}
{% endif %}
{% endfor %}
</fieldset>
<fieldset>
<legend>{% trans "Check-in & Validity" %}</legend>
{% bootstrap_field form.checkin_attention layout="control" %}
{% bootstrap_field form.validity_mode layout="control" %}
<div data-display-dependency="#{{ form.validity_mode.id_for_label }}" data-display-dependency-value="fixed">
{% bootstrap_field form.validity_fixed_from layout="control" %}
{% bootstrap_field form.validity_fixed_until layout="control" %}
</div>
<div data-display-dependency="#{{ form.validity_mode.id_for_label }}" data-display-dependency-value="dynamic">
<div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Duration" %}</label>
<div class="col-md-9">
{% trans "minutes" as t_minutes %}
{% trans "hours" as t_hours %}
{% trans "days" as t_days %}
{% trans "months" as t_months %}
{% trans "years" as t_years %}
{% bootstrap_field form.validity_dynamic_duration_months layout="control" addon_after=t_months horizontal_field_class="" horizontal_label_class="sr-only" %}
{% bootstrap_field form.validity_dynamic_duration_days layout="control" addon_after=t_days addon_before="+" horizontal_field_class="" horizontal_label_class="sr-only" %}
{% bootstrap_field form.validity_dynamic_duration_hours layout="control" addon_after=t_hours addon_before="+" horizontal_field_class="" horizontal_label_class="sr-only" %}
{% bootstrap_field form.validity_dynamic_duration_minutes layout="control" addon_after=t_minutes addon_before="+" horizontal_field_class="" horizontal_label_class="sr-only" %}
<div class="help-block">
{% blocktrans trimmed %}
If you select a duration given in days, months or years, the validity will always end at the
end of a full day (midnight), plus the number of minutes and hours selected above.
The start date is included in the calculation, so if you enter "1 day", the ticket
will be valid until the end of the day it starts on.
{% endblocktrans %}
</div>
</div>
</div>
{% bootstrap_field form.validity_dynamic_start_choice layout="control" %}
<div data-display-dependency="#{{ form.validity_dynamic_start_choice.id_for_label }}">
{% trans "days" as t_days %}
{% bootstrap_field form.validity_dynamic_start_choice_day_limit addon_after=t_days layout="control" %}
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Additional settings" %}</legend>
{% bootstrap_field form.issue_giftcard layout="control" %}
{% bootstrap_field form.show_quota_left layout="control" %}
{% if form.grant_membership_type %}
{% bootstrap_field form.grant_membership_type layout="control" %}
<div data-display-dependency="#id_grant_membership_type">
@@ -206,8 +247,11 @@
</div>
</div>
{% endif %}
{% bootstrap_field form.show_quota_left layout="control" %}
{% for f in plugin_forms %}
{% bootstrap_form f layout="control" %}
{% if not f.is_layouts %}
{% bootstrap_form f layout="control" %}
{% endif %}
{% endfor %}
</fieldset>
</div>

View File

@@ -236,7 +236,7 @@
{% if position.valid_from %}
<br />
{% endif %}
{% blocktrans trimmed with datetime=position.valid_from|date:"SHORT_DATETIME_FORMAT" %}
{% blocktrans trimmed with datetime=position.valid_until|date:"SHORT_DATETIME_FORMAT" %}
Valid until {{ datetime }}
{% endblocktrans %}
{% endif %}

View File

@@ -1,6 +1,7 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load daterange %}
{% load eventurl %}
{% load money %}
{% load rich_text %}
@@ -446,8 +447,8 @@
<dd>
<span class="fa fa-clock-o fa-fw" aria-hidden="true"></span>
{% if line.valid_from and line.valid_until %}
{% blocktrans trimmed with datetime_from=line.valid_from|date:"SHORT_DATETIME_FORMAT" datetime_until=line.valid_until|date:"SHORT_DATETIME_FORMAT" %}
Valid from {{ datetime_from }} until {{ datetime_until }}
{% blocktrans trimmed with datetime_range=line.valid_from|datetimerange:line.valid_until %}
Valid {{ datetime_range }}
{% endblocktrans %}
{% elif line.valid_from %}
{% blocktrans trimmed with datetime=line.valid_from|date:"SHORT_DATETIME_FORMAT" %}

View File

@@ -98,3 +98,15 @@ def daterange(df, dt, as_html=False):
date_from=_date(df, "DATE_FORMAT"),
date_to=_date(dt, "DATE_FORMAT"),
)
def datetimerange(df, dt, as_html=False):
if as_html:
base_format = format_html("<time datetime=\"{}\">{{}}</time>{{}}<time datetime=\"{}\">{{}}</time>", _date(df, "Y-m-d H:i"), _date(dt, "Y-m-d H:i"))
else:
base_format = "{}{}{}"
if df.year == dt.year and df.month == dt.month and df.day == dt.day:
return format_html(base_format, _date(df, "SHORT_DATE_FORMAT") + " " + _date(df, "TIME_FORMAT"), " ", _date(dt, "TIME_FORMAT"))
else:
return format_html(base_format, _date(df, "SHORT_DATETIME_FORMAT"), " ", _date(dt, "SHORT_DATETIME_FORMAT"))

View File

@@ -61,6 +61,7 @@ class BadgeLayoutChoiceField(forms.ModelChoiceField):
class BadgeItemForm(forms.ModelForm):
is_layouts = True
layout = BadgeLayoutChoiceField(queryset=BadgeLayout.objects.none())
class Meta:

View File

@@ -34,6 +34,8 @@ class TicketLayoutForm(forms.ModelForm):
class TicketLayoutItemForm(forms.ModelForm):
is_layouts = True
class Meta:
model = TicketLayoutItem
fields = ('layout',)

View File

@@ -1,5 +1,6 @@
{% load i18n %}
{% load eventurl %}
{% load daterange %}
{% load safelink %}
{% load rich_text %}
{% load money %}
@@ -103,8 +104,8 @@
<dd>
<span class="fa fa-clock-o fa-fw" aria-hidden="true"></span>
{% if line.valid_from and line.valid_until %}
{% blocktrans trimmed with datetime_from=line.valid_from|date:"SHORT_DATETIME_FORMAT" datetime_until=line.valid_until|date:"SHORT_DATETIME_FORMAT" %}
Valid from {{ datetime_from }} until {{ datetime_until }}
{% blocktrans trimmed with datetime_range=line.valid_from|datetimerange:line.valid_until %}
Valid {{ datetime_range }}
{% endblocktrans %}
{% elif line.valid_from %}
{% blocktrans trimmed with datetime=line.valid_from|date:"SHORT_DATETIME_FORMAT" %}

View File

@@ -296,6 +296,15 @@ TEST_ITEM_RES = {
"grant_membership_duration_like_event": True,
"grant_membership_duration_days": 0,
"grant_membership_duration_months": 0,
"validity_mode": None,
"validity_fixed_from": None,
"validity_fixed_until": None,
"validity_dynamic_duration_minutes": None,
"validity_dynamic_duration_hours": None,
"validity_dynamic_duration_days": None,
"validity_dynamic_duration_months": None,
"validity_dynamic_start_choice": False,
"validity_dynamic_start_choice_day_limit": None,
}

View File

@@ -2253,6 +2253,101 @@ def test_order_paid_require_payment_method(token_client, organizer, event, item,
assert not o.payments.exists()
@pytest.mark.django_db
def test_order_create_auto_validity(token_client, organizer, event, item, quota, question):
item.validity_mode = 'dynamic'
item.validity_dynamic_duration_minutes = 30
item.save()
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
del res['positions'][0]['price']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
p = o.positions.first()
assert now() - datetime.timedelta(seconds=30) < p.valid_from <= now()
assert now() + datetime.timedelta(minutes=29) < p.valid_until < now() + datetime.timedelta(minutes=31)
@pytest.mark.django_db
def test_order_create_manual_validity_precedence(token_client, organizer, event, item, quota, question):
item.validity_mode = 'dynamic'
item.validity_dynamic_duration_minutes = 30
item.save()
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
res['positions'][0]['valid_from'] = '2022-01-01T09:00:00.000Z'
res['positions'][0]['valid_until'] = '2022-01-03T09:00:00.000Z'
del res['positions'][0]['price']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
p = o.positions.first()
assert p.valid_from.isoformat() == '2022-01-01T09:00:00+00:00'
assert p.valid_until.isoformat() == '2022-01-03T09:00:00+00:00'
@pytest.mark.django_db
def test_order_create_auto_validity_with_requested_start(token_client, organizer, event, item, quota, question):
item.validity_mode = 'dynamic'
item.validity_dynamic_duration_minutes = 30
item.validity_dynamic_start_choice = True
item.save()
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
res['positions'][0]['requested_valid_from'] = '2039-01-01T09:00:00.000Z'
del res['positions'][0]['price']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
p = o.positions.first()
assert p.valid_from.isoformat() == '2039-01-01T09:00:00+00:00'
assert p.valid_until.isoformat() == '2039-01-01T09:30:00+00:00'
@pytest.mark.django_db
def test_order_create_auto_validity_with_requested_start_limitation(token_client, organizer, event, item, quota, question):
item.validity_mode = 'dynamic'
item.validity_dynamic_duration_minutes = 30
item.validity_dynamic_start_choice = True
item.validity_dynamic_start_choice_day_limit = 24
item.save()
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
res['positions'][0]['requested_valid_from'] = (now() + datetime.timedelta(days=30)).isoformat()
del res['positions'][0]['price']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
p = o.positions.first()
assert now() + datetime.timedelta(days=23) < p.valid_from <= now() + datetime.timedelta(days=26)
assert p.valid_until == p.valid_from + datetime.timedelta(minutes=30)
@pytest.mark.django_db
def test_order_create_auto_pricing(token_client, organizer, event, item, quota, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)

View File

@@ -0,0 +1,118 @@
#
# 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 datetime import datetime
import pytest
import pytz
from pretix.base.models import Item
tz = pytz.timezone("Europe/Berlin")
def dt(*args, is_dst=None, **kwargs):
return tz.localize(datetime(*args, **kwargs), is_dst=is_dst)
@pytest.mark.parametrize("minutes,hours,days,months,start,expected_end", [
# Simple cases
(0, 0, 0, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 9, 10, 30, 0)), # zero case
(10, 0, 0, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 9, 10, 40, 0)), # "10 minute pass"
(0, 1, 0, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 9, 11, 30, 0)), # "hour pass"
(10, 1, 0, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 9, 11, 40, 0)), # "1h 10min pass"
(0, 0, 1, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 9, 23, 59, 59)), # "day pass"
(0, 0, 3, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 11, 23, 59, 59)), # "3-day pass"
(30, 6, 3, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 12, 6, 29, 59)), # "3-day pass with day end at 6:30"
(0, 0, 0, 1, dt(2023, 2, 9, 10, 30, 0), dt(2023, 3, 9, 23, 59, 59)), # "month pass"
(0, 0, 3, 1, dt(2023, 2, 9, 10, 30, 0), dt(2023, 3, 12, 23, 59, 59)), # "month pass + 3 days"
(30, 6, 0, 1, dt(2023, 2, 9, 10, 30, 0), dt(2023, 3, 10, 6, 29, 59)), # "month pass with day end at 6:30"
(30, 6, 1, 1, dt(2023, 2, 9, 10, 30, 0), dt(2023, 3, 11, 6, 29, 59)), # "month pass + 1 day with day end at 6:30"
(0, 0, 0, 12, dt(2023, 2, 9, 10, 30, 0), dt(2024, 2, 9, 23, 59, 59)), # "year pass"
(30, 6, 0, 12, dt(2023, 2, 9, 10, 30, 0), dt(2024, 2, 10, 6, 29, 59)), # "year pass with day end at 6:30"
# Calendrical edge cases
# Multi-day across a DST change
(0, 0, 2, 0, dt(2023, 3, 25, 10, 30, 0), dt(2023, 3, 26, 23, 59, 59)),
# Month + day across a DST change
(0, 0, 1, 1, dt(2023, 2, 25, 10, 30, 0), dt(2023, 3, 26, 23, 59, 59)),
# Day + hour with possibly non-existant end time during DST change
(30, 2, 1, 0, dt(2023, 3, 25, 10, 30, 0), dt(2023, 3, 26, 3, 29, 59)),
# Day + hour with ambiguous end time during DST change
(30, 2, 1, 0, dt(2023, 10, 28, 10, 30, 0), dt(2023, 10, 29, 2, 29, 59, is_dst=True)),
# Month with short month following
(0, 0, 0, 1, dt(2023, 1, 31, 10, 30, 0), dt(2023, 2, 28, 23, 59, 59)),
# Interaction on months and leap days
(0, 0, 0, 1, dt(2024, 1, 31, 10, 30, 0), dt(2024, 2, 29, 23, 59, 59)),
(0, 0, 0, 12, dt(2024, 2, 29, 10, 30, 0), dt(2025, 2, 28, 23, 59, 59)),
(0, 0, 0, 12, dt(2024, 1, 31, 10, 30, 0), dt(2025, 1, 31, 23, 59, 59)),
])
def test_dynamic_validity(minutes, hours, days, months, start, expected_end):
i = Item(
validity_mode="dynamic",
validity_dynamic_start_choice=True,
validity_dynamic_duration_minutes=minutes,
validity_dynamic_duration_hours=hours,
validity_dynamic_duration_days=days,
validity_dynamic_duration_months=months,
)
assert i.compute_validity(requested_start=start, override_tz=tz) == (start, expected_end)
def test_fixed_validity():
i = Item(
validity_mode="fixed",
validity_fixed_from=dt(2023, 2, 9, 10, 15, 0),
validity_fixed_until=dt(2023, 2, 9, 12, 15, 0),
)
assert i.compute_validity(requested_start=dt(2024, 1, 1, 0, 0, 0), override_tz=tz) == (
i.validity_fixed_from, i.validity_fixed_until
)
def test_fixed_validity_one_sided():
i = Item(
validity_mode="fixed",
validity_fixed_from=dt(2023, 2, 9, 10, 15, 0),
validity_fixed_until=None,
)
assert i.compute_validity(requested_start=dt(2024, 1, 1, 0, 0, 0), override_tz=tz) == (i.validity_fixed_from, None)
i = Item(
validity_mode="fixed",
validity_fixed_from=None,
validity_fixed_until=dt(2023, 2, 9, 10, 15, 0),
)
assert i.compute_validity(requested_start=dt(2024, 1, 1, 0, 0, 0), override_tz=tz) == (None, i.validity_fixed_until)
def test_default_validity():
i = Item(
validity_mode=None,
validity_fixed_from=dt(2023, 2, 9, 10, 15, 0),
validity_fixed_until=dt(2023, 2, 9, 12, 15, 0),
)
assert i.compute_validity(requested_start=dt(2024, 1, 1, 0, 0, 0), override_tz=tz) == (None, None)

View File

@@ -32,11 +32,12 @@
# 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.
from datetime import date
from datetime import date, datetime
from django.utils import translation
from pretix.helpers.daterange import daterange
from pretix.base.i18n import language
from pretix.helpers.daterange import daterange, datetimerange
def test_same_day_german():
@@ -147,3 +148,33 @@ def test_different_dates_other_lang():
assert daterange(df, dt) == "01 Şubat 2003 03 Nisan 2005"
assert daterange(df, dt, as_html=True) == '<time datetime="2003-02-01">01 Şubat 2003</time> ' \
'<time datetime="2005-04-03">03 Nisan 2005</time>'
def test_datetime_same_day():
with translation.override('de'):
df = datetime(2003, 2, 1, 9, 0)
dt = datetime(2003, 2, 1, 10, 0)
assert datetimerange(df, dt) == "01.02.2003 09:00 10:00"
assert datetimerange(df, dt, as_html=True) == '<time datetime="2003-02-01 09:00">01.02.2003 09:00</time> ' \
'<time datetime="2003-02-01 10:00">10:00</time>'
with language('en', 'US'):
df = datetime(2003, 2, 1, 9, 0)
dt = datetime(2003, 2, 1, 10, 0)
assert datetimerange(df, dt) == "02/01/2003 9 a.m. 10 a.m."
assert datetimerange(df, dt, as_html=True) == '<time datetime="2003-02-01 09:00">02/01/2003 9 a.m.</time> ' \
'<time datetime="2003-02-01 10:00">10 a.m.</time>'
def test_datetime_different_day():
with translation.override('de'):
df = datetime(2003, 2, 1, 9, 0)
dt = datetime(2003, 2, 2, 10, 0)
assert datetimerange(df, dt) == "01.02.2003 09:00 02.02.2003 10:00"
assert datetimerange(df, dt, as_html=True) == '<time datetime="2003-02-01 09:00">01.02.2003 09:00</time> ' \
'<time datetime="2003-02-02 10:00">02.02.2003 10:00</time>'
with language('en', 'US'):
df = datetime(2003, 2, 1, 9, 0)
dt = datetime(2003, 2, 2, 10, 0)
assert datetimerange(df, dt) == "02/01/2003 9 a.m. 02/02/2003 10 a.m."
assert datetimerange(df, dt, as_html=True) == '<time datetime="2003-02-01 09:00">02/01/2003 9 a.m.</time> ' \
'<time datetime="2003-02-02 10:00">02/02/2003 10 a.m.</time>'

View File

@@ -38,6 +38,7 @@ from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django_countries.fields import Country
from django_scopes import scopes_disabled
from freezegun import freeze_time
from pretix.base.decimal import round_decimal
from pretix.base.models import (
@@ -2353,6 +2354,98 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
cr1 = CartPosition.objects.get(id=cr1.id)
self.assertEqual(cr1.price, 24)
@freeze_time("2023-01-18 03:00:00+01:00")
def test_validity_requested_start_date(self):
self.ticket.validity_mode = Item.VALIDITY_MODE_DYNAMIC
self.ticket.validity_dynamic_duration_days = 1
self.ticket.validity_dynamic_start_choice = True
self.ticket.validity_dynamic_start_choice_day_limit = 30
self.ticket.save()
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=42, listed_price=42, price_after_voucher=42, expires=now() + timedelta(minutes=10)
)
# Date too far in the future, expected to fail
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-requested_valid_from' % cr1.id: '2024-01-20',
'email': 'admin@localhost'
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-requested_valid_from' % cr1.id: '2023-01-20',
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
cr1.refresh_from_db()
assert cr1.requested_valid_from.isoformat() == '2023-01-20T00:00:00+00:00'
self._set_payment()
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
with scopes_disabled():
self.assertEqual(len(doc.select(".thank-you")), 1)
self.assertFalse(CartPosition.objects.filter(id=cr1.id).exists())
self.assertEqual(Order.objects.count(), 1)
self.assertEqual(OrderPosition.objects.count(), 1)
op = OrderPosition.objects.get()
assert op.valid_from.isoformat() == '2023-01-20T00:00:00+00:00'
assert op.valid_until.isoformat() == '2023-01-20T23:59:59+00:00'
@freeze_time("2023-01-18 03:00:00+01:00")
def test_validity_requested_start_date_and_time(self):
self.ticket.validity_mode = Item.VALIDITY_MODE_DYNAMIC
self.ticket.validity_dynamic_duration_hours = 2
self.ticket.validity_dynamic_start_choice = True
self.ticket.validity_dynamic_start_choice_day_limit = 30
self.ticket.save()
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=42, listed_price=42, price_after_voucher=42, expires=now() + timedelta(minutes=10)
)
# Date too far in the future, expected to fail
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-requested_valid_from_0' % cr1.id: '2024-01-20',
'%s-requested_valid_from_1' % cr1.id: '11:00:00',
'email': 'admin@localhost'
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-requested_valid_from_0' % cr1.id: '2023-01-20',
'%s-requested_valid_from_1' % cr1.id: '11:00:00',
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
cr1.refresh_from_db()
assert cr1.requested_valid_from.isoformat() == '2023-01-20T11:00:00+00:00'
self._set_payment()
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
with scopes_disabled():
self.assertEqual(len(doc.select(".thank-you")), 1)
self.assertFalse(CartPosition.objects.filter(id=cr1.id).exists())
self.assertEqual(Order.objects.count(), 1)
self.assertEqual(OrderPosition.objects.count(), 1)
op = OrderPosition.objects.get()
assert op.valid_from.isoformat() == '2023-01-20T11:00:00+00:00'
assert op.valid_until.isoformat() == '2023-01-20T13:00:00+00:00'
def test_voucher(self):
with scopes_disabled():
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event, price_mode='set',