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
+204 -148
View File
@@ -11,153 +11,165 @@ The item resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the item
name multi-lingual string The item's visible name
internal_name string An optional name that is only used in the backend
default_price money (string) The item price that is applied if the price is not
overwritten by variations or other options.
category integer The ID of the category this item belongs to
(or ``null``).
active boolean If ``false``, the item is hidden from all public lists
and will not be sold.
description multi-lingual string A public description of the item. May contain Markdown
syntax or can be ``null``.
free_price boolean If ``true``, customers can change the price at which
they buy the product (however, the price can't be set
lower than the price defined by ``default_price`` or
otherwise).
tax_rate decimal (string) The VAT rate to be applied for this item (read-only,
set through ``tax_rule``).
tax_rule integer The internal ID of the applied tax rule (or ``null``).
admission boolean ``true`` for items that grant admission to the event
(such as primary tickets) and ``false`` for others
(such as add-ons or merchandise).
personalized boolean ``true`` for items that require personalization according
to event settings. Only affects system-level fields, not
custom questions. Currently only allowed for products with
``admission`` set to ``true``. For backwards compatibility,
when creating new items and this field is not given, it defaults
to the same value as ``admission``.
position integer An integer, used for sorting
picture file A product picture to be displayed in the shop
(can be ``null``).
sales_channels list of strings Sales channels this product is available on, such as
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
available_from datetime The first date time at which this item can be bought
(or ``null``).
available_until datetime The last date time at which this item can be bought
(or ``null``).
hidden_if_available integer The internal ID of a quota object, or ``null``. If
set, this item won't be shown publicly as long as this
quota is available.
require_voucher boolean If ``true``, this item can only be bought using a
voucher that is specifically assigned to this item.
hide_without_voucher boolean If ``true``, this item is only shown during the voucher
redemption process, but not in the normal shop
frontend.
allow_cancel boolean If ``false``, customers cannot cancel orders containing
this item.
min_per_order integer This product can only be bought if it is included at
least this many times in the order (or ``null`` for no
limitation).
max_per_order integer This product can only be bought if it is included at
most this many times in the order (or ``null`` for no
limitation).
checkin_attention boolean If ``true``, the check-in app should show a warning
that this ticket requires special attention if such
a product is being scanned.
original_price money (string) An original price, shown for comparison, not used
for price calculations (or ``null``).
require_approval boolean If ``true``, orders with this product will need to be
approved by the event organizer before they can be
paid.
require_bundling boolean If ``true``, this item is only available as part of bundles.
require_membership boolean If ``true``, booking this item requires an active membership.
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this product will
be hidden from users without a valid membership.
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
grant_membership_type integer If set to the internal ID of a membership type, purchasing this item will
create a membership of the given type.
grant_membership_duration_like_event boolean If ``true``, the membership created through ``grant_membership_type`` will derive
its term from ``date_from`` to ``date_to`` of the purchased (sub)event.
grant_membership_duration_days integer If ``grant_membership_duration_like_event`` is ``false``, this sets the number of
days for the membership.
grant_membership_duration_months integer If ``grant_membership_duration_like_event`` is ``false``, this sets the number of
calendar months for the membership.
generate_tickets boolean If ``false``, tickets are never generated for this
product, regardless of other settings. If ``true``,
tickets are generated even if this is a
non-admission or add-on product, regardless of event
settings. If this is ``null``, regular ticketing
rules apply.
allow_waitinglist boolean If ``false``, no waiting list will be shown for this
product when it is sold out.
issue_giftcard boolean If ``true``, buying this product will yield a gift card.
show_quota_left boolean Publicly show how many tickets are still available.
If this is ``null``, the event default is used.
has_variations boolean Shows whether or not this item has variations.
variations list of objects A list with one object for each variation of this item.
Can be empty. Only writable during creation,
use separate endpoint to modify this later.
├ id integer Internal ID of the variation
├ value multi-lingual string The "name" of the variation
├ default_price money (string) The price set directly for this variation or ``null``
├ price money (string) The price used for this variation. This is either the
same as ``default_price`` if that value is set or equal
to the item's ``default_price``.
├ original_price money (string) An original price, shown for comparison, not used
for price calculations (or ``null``).
├ active boolean If ``false``, this variation will not be sold or shown.
├ description multi-lingual string A public description of the variation. May contain
├ checkin_attention boolean If ``true``, the check-in app should show a warning
that this ticket requires special attention if such
a variation is being scanned.
require_approval boolean If ``true``, orders with this variation will need to be
approved by the event organizer before they can be
paid.
├ require_membership boolean If ``true``, booking this variation requires an active membership.
├ require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
be hidden from users without a valid membership.
├ require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
Markdown syntax or can be ``null``.
sales_channels list of strings Sales channels this variation is available on, such as
``"web"`` or ``"resellers"``. Defaults to all existing sales channels.
The item-level list takes precedence, i.e. a sales
channel needs to be on both lists for the item to be
available.
├ available_from datetime The first date time at which this variation can be bought
(or ``null``).
available_until datetime The last date time at which this variation can be bought
(or ``null``).
├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
redemption process, but not in the normal shop
frontend.
meta_data object Values set for event-specific meta data parameters.
└ position integer An integer, used for sorting
addons list of objects Definition of add-ons that can be chosen for this item.
Only writable during creation,
use separate endpoint to modify this later.
├ addon_category integer Internal ID of the item category the add-on can be
chosen from.
min_count integer The minimal number of add-ons that need to be chosen.
├ max_count integer The maximal number of add-ons that can be chosen.
position integer An integer, used for sorting
├ multi_allowed boolean Adding the same item multiple times is allowed
└ price_included boolean Adding this add-on to the item is free
bundles list of objects Definition of bundles that are included in this item.
Only writable during creation,
use separate endpoint to modify this later.
├ bundled_item integer Internal ID of the item that is included.
├ bundled_variation integer Internal ID of the variation of the item (or ``null``).
count integer Number of items included
└ designated_price money (string) Designated price of the bundled product. This will be
used to split the price of the base item e.g. for mixed
taxation. This is not added to the price.
meta_data object Values set for event-specific meta data parameters.
===================================== ========================== =======================================================
======================================= ========================== =======================================================
Field Type Description
======================================= ========================== =======================================================
id integer Internal ID of the item
name multi-lingual string The item's visible name
internal_name string An optional name that is only used in the backend
default_price money (string) The item price that is applied if the price is not
overwritten by variations or other options.
category integer The ID of the category this item belongs to
(or ``null``).
active boolean If ``false``, the item is hidden from all public lists
and will not be sold.
description multi-lingual string A public description of the item. May contain Markdown
syntax or can be ``null``.
free_price boolean If ``true``, customers can change the price at which
they buy the product (however, the price can't be set
lower than the price defined by ``default_price`` or
otherwise).
tax_rate decimal (string) The VAT rate to be applied for this item (read-only,
set through ``tax_rule``).
tax_rule integer The internal ID of the applied tax rule (or ``null``).
admission boolean ``true`` for items that grant admission to the event
(such as primary tickets) and ``false`` for others
(such as add-ons or merchandise).
personalized boolean ``true`` for items that require personalization according
to event settings. Only affects system-level fields, not
custom questions. Currently only allowed for products with
``admission`` set to ``true``. For backwards compatibility,
when creating new items and this field is not given, it defaults
to the same value as ``admission``.
position integer An integer, used for sorting
picture file A product picture to be displayed in the shop
(can be ``null``).
sales_channels list of strings Sales channels this product is available on, such as
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
available_from datetime The first date time at which this item can be bought
(or ``null``).
available_until datetime The last date time at which this item can be bought
(or ``null``).
hidden_if_available integer The internal ID of a quota object, or ``null``. If
set, this item won't be shown publicly as long as this
quota is available.
require_voucher boolean If ``true``, this item can only be bought using a
voucher that is specifically assigned to this item.
hide_without_voucher boolean If ``true``, this item is only shown during the voucher
redemption process, but not in the normal shop
frontend.
allow_cancel boolean If ``false``, customers cannot cancel orders containing
this item.
min_per_order integer This product can only be bought if it is included at
least this many times in the order (or ``null`` for no
limitation).
max_per_order integer This product can only be bought if it is included at
most this many times in the order (or ``null`` for no
limitation).
checkin_attention boolean If ``true``, the check-in app should show a warning
that this ticket requires special attention if such
a product is being scanned.
original_price money (string) An original price, shown for comparison, not used
for price calculations (or ``null``).
require_approval boolean If ``true``, orders with this product will need to be
approved by the event organizer before they can be
paid.
require_bundling boolean If ``true``, this item is only available as part of bundles.
require_membership boolean If ``true``, booking this item requires an active membership.
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this product will
be hidden from users without a valid membership.
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
grant_membership_type integer If set to the internal ID of a membership type, purchasing this item will
create a membership of the given type.
grant_membership_duration_like_event boolean If ``true``, the membership created through ``grant_membership_type`` will derive
its term from ``date_from`` to ``date_to`` of the purchased (sub)event.
grant_membership_duration_days integer If ``grant_membership_duration_like_event`` is ``false``, this sets the number of
days for the membership.
grant_membership_duration_months integer If ``grant_membership_duration_like_event`` is ``false``, this sets the number of
calendar months for the membership.
validity_mode string If ``null``, tickets generated for this product do not
have special validity behavior, but follow event configuration and
can be limited e.g. through check-in rules. Other values are ``"fixed"`` and ``"dynamic"``
validity_fixed_from datetime If ``validity_mode`` is ``"fixed"``, this is the start of validity for issued tickets.
validity_fixed_until datetime If ``validity_mode`` is ``"fixed"``, this is the end of validity for issued tickets.
validity_dynamic_duration_minutes integer If ``validity_mode`` is ``"dynamic"``, this is the "minutes" component of the ticket validity duration.
validity_dynamic_duration_hours integer If ``validity_mode`` is ``"dynamic"``, this is the "hours" component of the ticket validity duration.
validity_dynamic_duration_days integer If ``validity_mode`` is ``"dynamic"``, this is the "days" component of the ticket validity duration.
validity_dynamic_duration_months integer If ``validity_mode`` is ``"dynamic"``, this is the "months" component of the ticket validity duration.
validity_dynamic_start_choice boolean If ``validity_mode`` is ``"dynamic"`` and this is ``true``, customers can choose the start of validity.
validity_dynamic_start_choice_day_limit boolean If ``validity_mode`` is ``"dynamic"`` and ``validity_dynamic_start_choice`` is ``true``,
this is the maximum number of days the start can be in the future.
generate_tickets boolean If ``false``, tickets are never generated for this
product, regardless of other settings. If ``true``,
tickets are generated even if this is a
non-admission or add-on product, regardless of event
settings. If this is ``null``, regular ticketing
rules apply.
allow_waitinglist boolean If ``false``, no waiting list will be shown for this
product when it is sold out.
issue_giftcard boolean If ``true``, buying this product will yield a gift card.
show_quota_left boolean Publicly show how many tickets are still available.
If this is ``null``, the event default is used.
has_variations boolean Shows whether or not this item has variations.
variations list of objects A list with one object for each variation of this item.
Can be empty. Only writable during creation,
use separate endpoint to modify this later.
├ id integer Internal ID of the variation
├ value multi-lingual string The "name" of the variation
├ default_price money (string) The price set directly for this variation or ``null``
├ price money (string) The price used for this variation. This is either the
same as ``default_price`` if that value is set or equal
to the item's ``default_price``.
├ original_price money (string) An original price, shown for comparison, not used
for price calculations (or ``null``).
├ active boolean If ``false``, this variation will not be sold or shown.
description multi-lingual string A public description of the variation. May contain
├ checkin_attention boolean If ``true``, the check-in app should show a warning
that this ticket requires special attention if such
a variation is being scanned.
├ require_approval boolean If ``true``, orders with this variation will need to be
approved by the event organizer before they can be
paid.
require_membership boolean If ``true``, booking this variation requires an active membership.
├ require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
be hidden from users without a valid membership.
├ require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
Markdown syntax or can be ``null``.
sales_channels list of strings Sales channels this variation is available on, such as
``"web"`` or ``"resellers"``. Defaults to all existing sales channels.
The item-level list takes precedence, i.e. a sales
channel needs to be on both lists for the item to be
available.
├ available_from datetime The first date time at which this variation can be bought
(or ``null``).
available_until datetime The last date time at which this variation can be bought
(or ``null``).
hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
redemption process, but not in the normal shop
frontend.
├ meta_data object Values set for event-specific meta data parameters.
└ position integer An integer, used for sorting
addons list of objects Definition of add-ons that can be chosen for this item.
Only writable during creation,
use separate endpoint to modify this later.
addon_category integer Internal ID of the item category the add-on can be
chosen from.
├ min_count integer The minimal number of add-ons that need to be chosen.
├ max_count integer The maximal number of add-ons that can be chosen.
├ position integer An integer, used for sorting
├ multi_allowed boolean Adding the same item multiple times is allowed
└ price_included boolean Adding this add-on to the item is free
bundles list of objects Definition of bundles that are included in this item.
Only writable during creation,
use separate endpoint to modify this later.
├ bundled_item integer Internal ID of the item that is included.
├ bundled_variation integer Internal ID of the variation of the item (or ``null``).
├ count integer Number of items included
└ designated_price money (string) Designated price of the bundled product. This will be
used to split the price of the base item e.g. for mixed
taxation. This is not added to the price.
meta_data object Values set for event-specific meta data parameters.
======================================= ========================== =======================================================
.. versionchanged:: 4.0
@@ -170,9 +182,13 @@ meta_data object Values set for
.. versionchanged:: 4.16
The ``variations[x].meta_data`` and ``variations[x].checkin_attention`` attributes has been added.
The ``variations[x].meta_data`` and ``variations[x].checkin_attention`` attributes have been added.
The ``personalized`` attribute has been added.
.. versionchanged:: 4.17
The ``validity_*`` attributes have been added.
Notes
-----
@@ -252,6 +268,14 @@ Endpoints
"grant_membership_duration_like_event": true,
"grant_membership_duration_days": 0,
"grant_membership_duration_months": 0,
"validity_fixed_from": null,
"validity_fixed_until": null,
"validity_dynamic_duration_minutes": null,
"validity_dynamic_duration_hours": null,
"validity_dynamic_duration_days": null,
"validity_dynamic_duration_months": null,
"validity_dynamic_start_choice": false,
"validity_dynamic_start_choice_day_limit": null,
"variations": [
{
"value": {"en": "Student"},
@@ -373,6 +397,14 @@ Endpoints
"grant_membership_duration_like_event": true,
"grant_membership_duration_days": 0,
"grant_membership_duration_months": 0,
"validity_fixed_from": null,
"validity_fixed_until": null,
"validity_dynamic_duration_minutes": null,
"validity_dynamic_duration_hours": null,
"validity_dynamic_duration_days": null,
"validity_dynamic_duration_months": null,
"validity_dynamic_start_choice": false,
"validity_dynamic_start_choice_day_limit": null,
"variations": [
{
"value": {"en": "Student"},
@@ -474,6 +506,14 @@ Endpoints
"grant_membership_duration_like_event": true,
"grant_membership_duration_days": 0,
"grant_membership_duration_months": 0,
"validity_fixed_from": null,
"validity_fixed_until": null,
"validity_dynamic_duration_minutes": null,
"validity_dynamic_duration_hours": null,
"validity_dynamic_duration_days": null,
"validity_dynamic_duration_months": null,
"validity_dynamic_start_choice": false,
"validity_dynamic_start_choice_day_limit": null,
"variations": [
{
"value": {"en": "Student"},
@@ -564,6 +604,14 @@ Endpoints
"grant_membership_duration_like_event": true,
"grant_membership_duration_days": 0,
"grant_membership_duration_months": 0,
"validity_fixed_from": null,
"validity_fixed_until": null,
"validity_dynamic_duration_minutes": null,
"validity_dynamic_duration_hours": null,
"validity_dynamic_duration_days": null,
"validity_dynamic_duration_months": null,
"validity_dynamic_start_choice": false,
"validity_dynamic_start_choice_day_limit": null,
"variations": [
{
"value": {"en": "Student"},
@@ -685,6 +733,14 @@ Endpoints
"grant_membership_duration_like_event": true,
"grant_membership_duration_days": 0,
"grant_membership_duration_months": 0,
"validity_fixed_from": null,
"validity_fixed_until": null,
"validity_dynamic_duration_minutes": null,
"validity_dynamic_duration_hours": null,
"validity_dynamic_duration_days": null,
"validity_dynamic_duration_months": null,
"validity_dynamic_start_choice": false,
"validity_dynamic_start_choice_day_limit": null,
"variations": [
{
"value": {"en": "Student"},
+4 -2
View File
@@ -840,6 +840,7 @@ Creating orders
* does not support or validate memberships
You can supply the following fields of the resource:
* ``code`` (optional) Only ``A-Z`` and ``0-9``, but without ``O`` and ``1``.
@@ -906,8 +907,9 @@ Creating orders
* ``secret`` (optional)
* ``addon_to`` (optional, see below)
* ``subevent`` (optional)
* ``valid_from`` (optional)
* ``valid_until`` (optional)
* ``valid_from`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
* ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
* ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected)
* ``answers``
* ``question``
+3 -1
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):
+17 -1
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'):
+31 -2
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:
@@ -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),
),
]
+129 -2
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
+36
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)
+49 -1
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"),
+38
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
)
+12
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'):
+21
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'
}),
@@ -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" %}
+12
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"))
+1
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:
@@ -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" %}
+9
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,
}
+95
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)
+118
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)
+33 -2
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>'
+93
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',