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

@@ -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'):