forked from CGM_Public/pretix_original
Allow to define ticket validity through a product (#3105)
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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:
|
||||
|
||||
64
src/pretix/base/migrations/0231_auto_20230208_1546.py
Normal file
64
src/pretix/base/migrations/0231_auto_20230208_1546.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
38
src/pretix/base/templatetags/daterange.py
Normal file
38
src/pretix/base/templatetags/daterange.py
Normal 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
|
||||
)
|
||||
@@ -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'):
|
||||
|
||||
@@ -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'
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -61,6 +61,7 @@ class BadgeLayoutChoiceField(forms.ModelChoiceField):
|
||||
|
||||
|
||||
class BadgeItemForm(forms.ModelForm):
|
||||
is_layouts = True
|
||||
layout = BadgeLayoutChoiceField(queryset=BadgeLayout.objects.none())
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -34,6 +34,8 @@ class TicketLayoutForm(forms.ModelForm):
|
||||
|
||||
|
||||
class TicketLayoutItemForm(forms.ModelForm):
|
||||
is_layouts = True
|
||||
|
||||
class Meta:
|
||||
model = TicketLayoutItem
|
||||
fields = ('layout',)
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
118
src/tests/base/test_item_validity.py
Normal file
118
src/tests/base/test_item_validity.py
Normal 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)
|
||||
@@ -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>'
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user