mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
Allow to define ticket validity through a product (#3105)
This commit is contained in:
@@ -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'):
|
||||
|
||||
Reference in New Issue
Block a user