forked from CGM_Public/pretix_original
Allow to define ticket validity through a product (#3105)
This commit is contained in:
+204
-148
@@ -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"},
|
||||
|
||||
@@ -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``
|
||||
|
||||
@@ -242,7 +242,9 @@ class ItemSerializer(I18nAwareModelSerializer):
|
||||
'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
|
||||
'grant_membership_duration_like_event', 'grant_membership_duration_days',
|
||||
'grant_membership_duration_months')
|
||||
'grant_membership_duration_months', 'validity_mode', 'validity_fixed_from', 'validity_fixed_until',
|
||||
'validity_dynamic_duration_minutes', 'validity_dynamic_duration_hours', 'validity_dynamic_duration_days',
|
||||
'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit')
|
||||
read_only_fields = ('has_variations',)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -785,12 +785,14 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
voucher = serializers.SlugRelatedField(slug_field='code', queryset=Voucher.objects.none(),
|
||||
required=False, allow_null=True)
|
||||
country = CompatibleCountryField(source='*')
|
||||
requested_valid_from = serializers.DateTimeField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
|
||||
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until')
|
||||
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until',
|
||||
'requested_valid_from')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -1177,6 +1179,20 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
elif seated:
|
||||
errs[i]['seat'] = ['The specified product requires to choose a seat.']
|
||||
|
||||
requested_valid_from = pos_data.pop('requested_valid_from', None)
|
||||
if 'valid_from' not in pos_data and 'valid_until' not in pos_data:
|
||||
valid_from, valid_until = pos_data['item'].compute_validity(
|
||||
requested_start=(
|
||||
max(requested_valid_from, now())
|
||||
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
|
||||
else now()
|
||||
),
|
||||
enforce_start_limit=True,
|
||||
override_tz=self.context['event'].timezone,
|
||||
)
|
||||
pos_data['valid_from'] = valid_from
|
||||
pos_data['valid_until'] = valid_until
|
||||
|
||||
if not force:
|
||||
for i, pos_data in enumerate(positions_data):
|
||||
if pos_data.get('voucher'):
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
|
||||
@@ -55,7 +56,7 @@ from django.forms.widgets import FILE_INPUT_CONTRADICTION
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.timezone import get_current_timezone
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_countries import countries
|
||||
from django_countries.fields import Country, CountryField
|
||||
@@ -73,7 +74,7 @@ from pretix.base.forms.widgets import (
|
||||
from pretix.base.i18n import (
|
||||
get_babel_locale, get_language_without_region, language,
|
||||
)
|
||||
from pretix.base.models import InvoiceAddress, Question, QuestionOption
|
||||
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
|
||||
from pretix.base.models.tax import VAT_ID_COUNTRIES, ask_for_vat_id
|
||||
from pretix.base.services.tax import (
|
||||
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
|
||||
@@ -573,6 +574,34 @@ class BaseQuestionsForm(forms.Form):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
|
||||
if item.validity_dynamic_start_choice_day_limit:
|
||||
max_date = now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
|
||||
else:
|
||||
max_date = None
|
||||
if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
|
||||
attrs = {}
|
||||
if max_date:
|
||||
attrs['data-max'] = max_date.date().isoformat()
|
||||
self.fields['requested_valid_from'] = forms.DateField(
|
||||
label=_('Start date'),
|
||||
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=False,
|
||||
widget=DatePickerWidget(attrs),
|
||||
validators=[MaxDateValidator(max_date.date())] if max_date else []
|
||||
)
|
||||
else:
|
||||
self.fields['requested_valid_from'] = forms.SplitDateTimeField(
|
||||
label=_('Start date'),
|
||||
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=False,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
max_date=max_date
|
||||
),
|
||||
validators=[MaxDateTimeValidator(max_date)] if max_date else []
|
||||
)
|
||||
|
||||
add_fields = {}
|
||||
|
||||
if item.ask_attendee_data and event.settings.attendee_names_asked:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+49
-1
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from datetime import datetime
|
||||
|
||||
from django import template
|
||||
from django.utils.timezone import get_current_timezone
|
||||
|
||||
from pretix.helpers.daterange import datetimerange
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter("datetimerange")
|
||||
def datetimerange_filter(start: datetime, end: datetime):
|
||||
return datetimerange(
|
||||
start.astimezone(get_current_timezone()),
|
||||
end.astimezone(get_current_timezone()),
|
||||
as_html=True
|
||||
)
|
||||
@@ -19,6 +19,7 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import datetime
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
@@ -28,6 +29,7 @@ from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Prefetch, QuerySet
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import make_aware
|
||||
|
||||
from pretix.base.forms.questions import (
|
||||
BaseInvoiceAddressForm, BaseInvoiceNameForm, BaseQuestionsForm,
|
||||
@@ -155,6 +157,16 @@ class BaseQuestionsViewMixin:
|
||||
v = v if v != '' else None
|
||||
setattr(form.pos, k, v)
|
||||
setattr(prof, k, v)
|
||||
elif k == 'requested_valid_from':
|
||||
if isinstance(v, datetime.datetime):
|
||||
form.pos.requested_valid_from = v
|
||||
elif isinstance(v, datetime.date):
|
||||
form.pos.requested_valid_from = make_aware(datetime.datetime.combine(
|
||||
v,
|
||||
datetime.time(hour=0, minute=0, second=0, microsecond=0)
|
||||
), self.request.event.timezone)
|
||||
else:
|
||||
form.pos.requested_valid_from = None
|
||||
elif k.startswith('question_'):
|
||||
field = form.fields[k]
|
||||
if hasattr(field, 'answer'):
|
||||
|
||||
@@ -618,6 +618,14 @@ class ItemUpdateForm(I18nModelForm):
|
||||
"admission ticket. Otherwise customers might not be able to use the membership later. If you "
|
||||
"want the membership to be non-personalized, set the membership type to be transferable.")
|
||||
)
|
||||
|
||||
if d.get('validity_mode') == Item.VALIDITY_MODE_FIXED and d.get('validity_fixed_from') and d.get('validity_fixed_until'):
|
||||
if d.get('validity_fixed_from') > d.get('validity_fixed_until'):
|
||||
self.add_error(
|
||||
'validity_fixed_from',
|
||||
_("The start of validity must be before the end of validity.")
|
||||
)
|
||||
|
||||
return d
|
||||
|
||||
def clean_picture(self):
|
||||
@@ -667,10 +675,21 @@ class ItemUpdateForm(I18nModelForm):
|
||||
'grant_membership_duration_like_event',
|
||||
'grant_membership_duration_days',
|
||||
'grant_membership_duration_months',
|
||||
'validity_mode',
|
||||
'validity_fixed_from',
|
||||
'validity_fixed_until',
|
||||
'validity_dynamic_duration_minutes',
|
||||
'validity_dynamic_duration_hours',
|
||||
'validity_dynamic_duration_days',
|
||||
'validity_dynamic_duration_months',
|
||||
'validity_dynamic_start_choice',
|
||||
'validity_dynamic_start_choice_day_limit',
|
||||
]
|
||||
field_classes = {
|
||||
'available_from': SplitDateTimeField,
|
||||
'available_until': SplitDateTimeField,
|
||||
'validity_fixed_from': SplitDateTimeField,
|
||||
'validity_fixed_until': SplitDateTimeField,
|
||||
'hidden_if_available': SafeModelChoiceField,
|
||||
'grant_membership_type': SafeModelChoiceField,
|
||||
'require_membership_types': SafeModelMultipleChoiceField,
|
||||
@@ -678,6 +697,8 @@ class ItemUpdateForm(I18nModelForm):
|
||||
widgets = {
|
||||
'available_from': SplitDateTimePickerWidget(),
|
||||
'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}),
|
||||
'validity_fixed_from': SplitDateTimePickerWidget(),
|
||||
'validity_fixed_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_validity_fixed_from_0'}),
|
||||
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
|
||||
'class': 'scrolling-multiple-choice'
|
||||
}),
|
||||
|
||||
@@ -176,14 +176,55 @@
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
<fieldset>
|
||||
<legend>{% trans "Tickets & check-in" %}</legend>
|
||||
<legend>{% trans "Tickets & Badges" %}</legend>
|
||||
{% bootstrap_field form.generate_tickets layout="control" %}
|
||||
{% for f in plugin_forms %}
|
||||
{% if f.is_layouts %}
|
||||
{% bootstrap_form f layout="control" %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Check-in & Validity" %}</legend>
|
||||
{% bootstrap_field form.checkin_attention layout="control" %}
|
||||
{% bootstrap_field form.validity_mode layout="control" %}
|
||||
<div data-display-dependency="#{{ form.validity_mode.id_for_label }}" data-display-dependency-value="fixed">
|
||||
{% bootstrap_field form.validity_fixed_from layout="control" %}
|
||||
{% bootstrap_field form.validity_fixed_until layout="control" %}
|
||||
</div>
|
||||
<div data-display-dependency="#{{ form.validity_mode.id_for_label }}" data-display-dependency-value="dynamic">
|
||||
<div class="form-group metadata-group">
|
||||
<label class="col-md-3 control-label">{% trans "Duration" %}</label>
|
||||
<div class="col-md-9">
|
||||
{% trans "minutes" as t_minutes %}
|
||||
{% trans "hours" as t_hours %}
|
||||
{% trans "days" as t_days %}
|
||||
{% trans "months" as t_months %}
|
||||
{% trans "years" as t_years %}
|
||||
{% bootstrap_field form.validity_dynamic_duration_months layout="control" addon_after=t_months horizontal_field_class="" horizontal_label_class="sr-only" %}
|
||||
{% bootstrap_field form.validity_dynamic_duration_days layout="control" addon_after=t_days addon_before="+" horizontal_field_class="" horizontal_label_class="sr-only" %}
|
||||
{% bootstrap_field form.validity_dynamic_duration_hours layout="control" addon_after=t_hours addon_before="+" horizontal_field_class="" horizontal_label_class="sr-only" %}
|
||||
{% bootstrap_field form.validity_dynamic_duration_minutes layout="control" addon_after=t_minutes addon_before="+" horizontal_field_class="" horizontal_label_class="sr-only" %}
|
||||
<div class="help-block">
|
||||
{% blocktrans trimmed %}
|
||||
If you select a duration given in days, months or years, the validity will always end at the
|
||||
end of a full day (midnight), plus the number of minutes and hours selected above.
|
||||
The start date is included in the calculation, so if you enter "1 day", the ticket
|
||||
will be valid until the end of the day it starts on.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% bootstrap_field form.validity_dynamic_start_choice layout="control" %}
|
||||
<div data-display-dependency="#{{ form.validity_dynamic_start_choice.id_for_label }}">
|
||||
{% trans "days" as t_days %}
|
||||
{% bootstrap_field form.validity_dynamic_start_choice_day_limit addon_after=t_days layout="control" %}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Additional settings" %}</legend>
|
||||
{% bootstrap_field form.issue_giftcard layout="control" %}
|
||||
{% bootstrap_field form.show_quota_left layout="control" %}
|
||||
{% if form.grant_membership_type %}
|
||||
{% bootstrap_field form.grant_membership_type layout="control" %}
|
||||
<div data-display-dependency="#id_grant_membership_type">
|
||||
@@ -206,8 +247,11 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% bootstrap_field form.show_quota_left layout="control" %}
|
||||
{% for f in plugin_forms %}
|
||||
{% bootstrap_form f layout="control" %}
|
||||
{% if not f.is_layouts %}
|
||||
{% bootstrap_form f layout="control" %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
@@ -236,7 +236,7 @@
|
||||
{% if position.valid_from %}
|
||||
<br />
|
||||
{% endif %}
|
||||
{% blocktrans trimmed with datetime=position.valid_from|date:"SHORT_DATETIME_FORMAT" %}
|
||||
{% blocktrans trimmed with datetime=position.valid_until|date:"SHORT_DATETIME_FORMAT" %}
|
||||
Valid until {{ datetime }}
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load daterange %}
|
||||
{% load eventurl %}
|
||||
{% load money %}
|
||||
{% load rich_text %}
|
||||
@@ -446,8 +447,8 @@
|
||||
<dd>
|
||||
<span class="fa fa-clock-o fa-fw" aria-hidden="true"></span>
|
||||
{% if line.valid_from and line.valid_until %}
|
||||
{% blocktrans trimmed with datetime_from=line.valid_from|date:"SHORT_DATETIME_FORMAT" datetime_until=line.valid_until|date:"SHORT_DATETIME_FORMAT" %}
|
||||
Valid from {{ datetime_from }} until {{ datetime_until }}
|
||||
{% blocktrans trimmed with datetime_range=line.valid_from|datetimerange:line.valid_until %}
|
||||
Valid {{ datetime_range }}
|
||||
{% endblocktrans %}
|
||||
{% elif line.valid_from %}
|
||||
{% blocktrans trimmed with datetime=line.valid_from|date:"SHORT_DATETIME_FORMAT" %}
|
||||
|
||||
@@ -98,3 +98,15 @@ def daterange(df, dt, as_html=False):
|
||||
date_from=_date(df, "DATE_FORMAT"),
|
||||
date_to=_date(dt, "DATE_FORMAT"),
|
||||
)
|
||||
|
||||
|
||||
def datetimerange(df, dt, as_html=False):
|
||||
if as_html:
|
||||
base_format = format_html("<time datetime=\"{}\">{{}}</time>{{}}<time datetime=\"{}\">{{}}</time>", _date(df, "Y-m-d H:i"), _date(dt, "Y-m-d H:i"))
|
||||
else:
|
||||
base_format = "{}{}{}"
|
||||
|
||||
if df.year == dt.year and df.month == dt.month and df.day == dt.day:
|
||||
return format_html(base_format, _date(df, "SHORT_DATE_FORMAT") + " " + _date(df, "TIME_FORMAT"), " – ", _date(dt, "TIME_FORMAT"))
|
||||
else:
|
||||
return format_html(base_format, _date(df, "SHORT_DATETIME_FORMAT"), " – ", _date(dt, "SHORT_DATETIME_FORMAT"))
|
||||
|
||||
@@ -61,6 +61,7 @@ class BadgeLayoutChoiceField(forms.ModelChoiceField):
|
||||
|
||||
|
||||
class BadgeItemForm(forms.ModelForm):
|
||||
is_layouts = True
|
||||
layout = BadgeLayoutChoiceField(queryset=BadgeLayout.objects.none())
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -34,6 +34,8 @@ class TicketLayoutForm(forms.ModelForm):
|
||||
|
||||
|
||||
class TicketLayoutItemForm(forms.ModelForm):
|
||||
is_layouts = True
|
||||
|
||||
class Meta:
|
||||
model = TicketLayoutItem
|
||||
fields = ('layout',)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load daterange %}
|
||||
{% load safelink %}
|
||||
{% load rich_text %}
|
||||
{% load money %}
|
||||
@@ -103,8 +104,8 @@
|
||||
<dd>
|
||||
<span class="fa fa-clock-o fa-fw" aria-hidden="true"></span>
|
||||
{% if line.valid_from and line.valid_until %}
|
||||
{% blocktrans trimmed with datetime_from=line.valid_from|date:"SHORT_DATETIME_FORMAT" datetime_until=line.valid_until|date:"SHORT_DATETIME_FORMAT" %}
|
||||
Valid from {{ datetime_from }} until {{ datetime_until }}
|
||||
{% blocktrans trimmed with datetime_range=line.valid_from|datetimerange:line.valid_until %}
|
||||
Valid {{ datetime_range }}
|
||||
{% endblocktrans %}
|
||||
{% elif line.valid_from %}
|
||||
{% blocktrans trimmed with datetime=line.valid_from|date:"SHORT_DATETIME_FORMAT" %}
|
||||
|
||||
@@ -296,6 +296,15 @@ TEST_ITEM_RES = {
|
||||
"grant_membership_duration_like_event": True,
|
||||
"grant_membership_duration_days": 0,
|
||||
"grant_membership_duration_months": 0,
|
||||
"validity_mode": None,
|
||||
"validity_fixed_from": None,
|
||||
"validity_fixed_until": None,
|
||||
"validity_dynamic_duration_minutes": None,
|
||||
"validity_dynamic_duration_hours": None,
|
||||
"validity_dynamic_duration_days": None,
|
||||
"validity_dynamic_duration_months": None,
|
||||
"validity_dynamic_start_choice": False,
|
||||
"validity_dynamic_start_choice_day_limit": None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2253,6 +2253,101 @@ def test_order_paid_require_payment_method(token_client, organizer, event, item,
|
||||
assert not o.payments.exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_auto_validity(token_client, organizer, event, item, quota, question):
|
||||
item.validity_mode = 'dynamic'
|
||||
item.validity_dynamic_duration_minutes = 30
|
||||
item.save()
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res['positions'][0]['item'] = item.pk
|
||||
res['positions'][0]['answers'][0]['question'] = question.pk
|
||||
del res['positions'][0]['price']
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
o = Order.objects.get(code=resp.data['code'])
|
||||
p = o.positions.first()
|
||||
assert now() - datetime.timedelta(seconds=30) < p.valid_from <= now()
|
||||
assert now() + datetime.timedelta(minutes=29) < p.valid_until < now() + datetime.timedelta(minutes=31)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_manual_validity_precedence(token_client, organizer, event, item, quota, question):
|
||||
item.validity_mode = 'dynamic'
|
||||
item.validity_dynamic_duration_minutes = 30
|
||||
item.save()
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res['positions'][0]['item'] = item.pk
|
||||
res['positions'][0]['answers'][0]['question'] = question.pk
|
||||
res['positions'][0]['valid_from'] = '2022-01-01T09:00:00.000Z'
|
||||
res['positions'][0]['valid_until'] = '2022-01-03T09:00:00.000Z'
|
||||
del res['positions'][0]['price']
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
o = Order.objects.get(code=resp.data['code'])
|
||||
p = o.positions.first()
|
||||
assert p.valid_from.isoformat() == '2022-01-01T09:00:00+00:00'
|
||||
assert p.valid_until.isoformat() == '2022-01-03T09:00:00+00:00'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_auto_validity_with_requested_start(token_client, organizer, event, item, quota, question):
|
||||
item.validity_mode = 'dynamic'
|
||||
item.validity_dynamic_duration_minutes = 30
|
||||
item.validity_dynamic_start_choice = True
|
||||
item.save()
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res['positions'][0]['item'] = item.pk
|
||||
res['positions'][0]['answers'][0]['question'] = question.pk
|
||||
res['positions'][0]['requested_valid_from'] = '2039-01-01T09:00:00.000Z'
|
||||
del res['positions'][0]['price']
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
o = Order.objects.get(code=resp.data['code'])
|
||||
p = o.positions.first()
|
||||
assert p.valid_from.isoformat() == '2039-01-01T09:00:00+00:00'
|
||||
assert p.valid_until.isoformat() == '2039-01-01T09:30:00+00:00'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_auto_validity_with_requested_start_limitation(token_client, organizer, event, item, quota, question):
|
||||
item.validity_mode = 'dynamic'
|
||||
item.validity_dynamic_duration_minutes = 30
|
||||
item.validity_dynamic_start_choice = True
|
||||
item.validity_dynamic_start_choice_day_limit = 24
|
||||
item.save()
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res['positions'][0]['item'] = item.pk
|
||||
res['positions'][0]['answers'][0]['question'] = question.pk
|
||||
res['positions'][0]['requested_valid_from'] = (now() + datetime.timedelta(days=30)).isoformat()
|
||||
del res['positions'][0]['price']
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
o = Order.objects.get(code=resp.data['code'])
|
||||
p = o.positions.first()
|
||||
assert now() + datetime.timedelta(days=23) < p.valid_from <= now() + datetime.timedelta(days=26)
|
||||
assert p.valid_until == p.valid_from + datetime.timedelta(minutes=30)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_auto_pricing(token_client, organizer, event, item, quota, question):
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
import pytz
|
||||
|
||||
from pretix.base.models import Item
|
||||
|
||||
tz = pytz.timezone("Europe/Berlin")
|
||||
|
||||
|
||||
def dt(*args, is_dst=None, **kwargs):
|
||||
return tz.localize(datetime(*args, **kwargs), is_dst=is_dst)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("minutes,hours,days,months,start,expected_end", [
|
||||
# Simple cases
|
||||
(0, 0, 0, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 9, 10, 30, 0)), # zero case
|
||||
(10, 0, 0, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 9, 10, 40, 0)), # "10 minute pass"
|
||||
(0, 1, 0, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 9, 11, 30, 0)), # "hour pass"
|
||||
(10, 1, 0, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 9, 11, 40, 0)), # "1h 10min pass"
|
||||
(0, 0, 1, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 9, 23, 59, 59)), # "day pass"
|
||||
(0, 0, 3, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 11, 23, 59, 59)), # "3-day pass"
|
||||
(30, 6, 3, 0, dt(2023, 2, 9, 10, 30, 0), dt(2023, 2, 12, 6, 29, 59)), # "3-day pass with day end at 6:30"
|
||||
(0, 0, 0, 1, dt(2023, 2, 9, 10, 30, 0), dt(2023, 3, 9, 23, 59, 59)), # "month pass"
|
||||
(0, 0, 3, 1, dt(2023, 2, 9, 10, 30, 0), dt(2023, 3, 12, 23, 59, 59)), # "month pass + 3 days"
|
||||
(30, 6, 0, 1, dt(2023, 2, 9, 10, 30, 0), dt(2023, 3, 10, 6, 29, 59)), # "month pass with day end at 6:30"
|
||||
(30, 6, 1, 1, dt(2023, 2, 9, 10, 30, 0), dt(2023, 3, 11, 6, 29, 59)), # "month pass + 1 day with day end at 6:30"
|
||||
(0, 0, 0, 12, dt(2023, 2, 9, 10, 30, 0), dt(2024, 2, 9, 23, 59, 59)), # "year pass"
|
||||
(30, 6, 0, 12, dt(2023, 2, 9, 10, 30, 0), dt(2024, 2, 10, 6, 29, 59)), # "year pass with day end at 6:30"
|
||||
|
||||
# Calendrical edge cases
|
||||
|
||||
# Multi-day across a DST change
|
||||
(0, 0, 2, 0, dt(2023, 3, 25, 10, 30, 0), dt(2023, 3, 26, 23, 59, 59)),
|
||||
|
||||
# Month + day across a DST change
|
||||
(0, 0, 1, 1, dt(2023, 2, 25, 10, 30, 0), dt(2023, 3, 26, 23, 59, 59)),
|
||||
|
||||
# Day + hour with possibly non-existant end time during DST change
|
||||
(30, 2, 1, 0, dt(2023, 3, 25, 10, 30, 0), dt(2023, 3, 26, 3, 29, 59)),
|
||||
|
||||
# Day + hour with ambiguous end time during DST change
|
||||
(30, 2, 1, 0, dt(2023, 10, 28, 10, 30, 0), dt(2023, 10, 29, 2, 29, 59, is_dst=True)),
|
||||
|
||||
# Month with short month following
|
||||
(0, 0, 0, 1, dt(2023, 1, 31, 10, 30, 0), dt(2023, 2, 28, 23, 59, 59)),
|
||||
|
||||
# Interaction on months and leap days
|
||||
(0, 0, 0, 1, dt(2024, 1, 31, 10, 30, 0), dt(2024, 2, 29, 23, 59, 59)),
|
||||
(0, 0, 0, 12, dt(2024, 2, 29, 10, 30, 0), dt(2025, 2, 28, 23, 59, 59)),
|
||||
(0, 0, 0, 12, dt(2024, 1, 31, 10, 30, 0), dt(2025, 1, 31, 23, 59, 59)),
|
||||
])
|
||||
def test_dynamic_validity(minutes, hours, days, months, start, expected_end):
|
||||
i = Item(
|
||||
validity_mode="dynamic",
|
||||
validity_dynamic_start_choice=True,
|
||||
validity_dynamic_duration_minutes=minutes,
|
||||
validity_dynamic_duration_hours=hours,
|
||||
validity_dynamic_duration_days=days,
|
||||
validity_dynamic_duration_months=months,
|
||||
)
|
||||
assert i.compute_validity(requested_start=start, override_tz=tz) == (start, expected_end)
|
||||
|
||||
|
||||
def test_fixed_validity():
|
||||
i = Item(
|
||||
validity_mode="fixed",
|
||||
validity_fixed_from=dt(2023, 2, 9, 10, 15, 0),
|
||||
validity_fixed_until=dt(2023, 2, 9, 12, 15, 0),
|
||||
)
|
||||
assert i.compute_validity(requested_start=dt(2024, 1, 1, 0, 0, 0), override_tz=tz) == (
|
||||
i.validity_fixed_from, i.validity_fixed_until
|
||||
)
|
||||
|
||||
|
||||
def test_fixed_validity_one_sided():
|
||||
i = Item(
|
||||
validity_mode="fixed",
|
||||
validity_fixed_from=dt(2023, 2, 9, 10, 15, 0),
|
||||
validity_fixed_until=None,
|
||||
)
|
||||
assert i.compute_validity(requested_start=dt(2024, 1, 1, 0, 0, 0), override_tz=tz) == (i.validity_fixed_from, None)
|
||||
i = Item(
|
||||
validity_mode="fixed",
|
||||
validity_fixed_from=None,
|
||||
validity_fixed_until=dt(2023, 2, 9, 10, 15, 0),
|
||||
)
|
||||
assert i.compute_validity(requested_start=dt(2024, 1, 1, 0, 0, 0), override_tz=tz) == (None, i.validity_fixed_until)
|
||||
|
||||
|
||||
def test_default_validity():
|
||||
i = Item(
|
||||
validity_mode=None,
|
||||
validity_fixed_from=dt(2023, 2, 9, 10, 15, 0),
|
||||
validity_fixed_until=dt(2023, 2, 9, 12, 15, 0),
|
||||
)
|
||||
assert i.compute_validity(requested_start=dt(2024, 1, 1, 0, 0, 0), override_tz=tz) == (None, None)
|
||||
@@ -32,11 +32,12 @@
|
||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
|
||||
from django.utils import translation
|
||||
|
||||
from pretix.helpers.daterange import daterange
|
||||
from pretix.base.i18n import language
|
||||
from pretix.helpers.daterange import daterange, datetimerange
|
||||
|
||||
|
||||
def test_same_day_german():
|
||||
@@ -147,3 +148,33 @@ def test_different_dates_other_lang():
|
||||
assert daterange(df, dt) == "01 Şubat 2003 – 03 Nisan 2005"
|
||||
assert daterange(df, dt, as_html=True) == '<time datetime="2003-02-01">01 Şubat 2003</time> – ' \
|
||||
'<time datetime="2005-04-03">03 Nisan 2005</time>'
|
||||
|
||||
|
||||
def test_datetime_same_day():
|
||||
with translation.override('de'):
|
||||
df = datetime(2003, 2, 1, 9, 0)
|
||||
dt = datetime(2003, 2, 1, 10, 0)
|
||||
assert datetimerange(df, dt) == "01.02.2003 09:00 – 10:00"
|
||||
assert datetimerange(df, dt, as_html=True) == '<time datetime="2003-02-01 09:00">01.02.2003 09:00</time> – ' \
|
||||
'<time datetime="2003-02-01 10:00">10:00</time>'
|
||||
with language('en', 'US'):
|
||||
df = datetime(2003, 2, 1, 9, 0)
|
||||
dt = datetime(2003, 2, 1, 10, 0)
|
||||
assert datetimerange(df, dt) == "02/01/2003 9 a.m. – 10 a.m."
|
||||
assert datetimerange(df, dt, as_html=True) == '<time datetime="2003-02-01 09:00">02/01/2003 9 a.m.</time> – ' \
|
||||
'<time datetime="2003-02-01 10:00">10 a.m.</time>'
|
||||
|
||||
|
||||
def test_datetime_different_day():
|
||||
with translation.override('de'):
|
||||
df = datetime(2003, 2, 1, 9, 0)
|
||||
dt = datetime(2003, 2, 2, 10, 0)
|
||||
assert datetimerange(df, dt) == "01.02.2003 09:00 – 02.02.2003 10:00"
|
||||
assert datetimerange(df, dt, as_html=True) == '<time datetime="2003-02-01 09:00">01.02.2003 09:00</time> – ' \
|
||||
'<time datetime="2003-02-02 10:00">02.02.2003 10:00</time>'
|
||||
with language('en', 'US'):
|
||||
df = datetime(2003, 2, 1, 9, 0)
|
||||
dt = datetime(2003, 2, 2, 10, 0)
|
||||
assert datetimerange(df, dt) == "02/01/2003 9 a.m. – 02/02/2003 10 a.m."
|
||||
assert datetimerange(df, dt, as_html=True) == '<time datetime="2003-02-01 09:00">02/01/2003 9 a.m.</time> – ' \
|
||||
'<time datetime="2003-02-02 10:00">02/02/2003 10 a.m.</time>'
|
||||
|
||||
@@ -38,6 +38,7 @@ from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django_countries.fields import Country
|
||||
from django_scopes import scopes_disabled
|
||||
from freezegun import freeze_time
|
||||
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import (
|
||||
@@ -2353,6 +2354,98 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
|
||||
cr1 = CartPosition.objects.get(id=cr1.id)
|
||||
self.assertEqual(cr1.price, 24)
|
||||
|
||||
@freeze_time("2023-01-18 03:00:00+01:00")
|
||||
def test_validity_requested_start_date(self):
|
||||
self.ticket.validity_mode = Item.VALIDITY_MODE_DYNAMIC
|
||||
self.ticket.validity_dynamic_duration_days = 1
|
||||
self.ticket.validity_dynamic_start_choice = True
|
||||
self.ticket.validity_dynamic_start_choice_day_limit = 30
|
||||
self.ticket.save()
|
||||
with scopes_disabled():
|
||||
cr1 = CartPosition.objects.create(
|
||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||
price=42, listed_price=42, price_after_voucher=42, expires=now() + timedelta(minutes=10)
|
||||
)
|
||||
|
||||
# Date too far in the future, expected to fail
|
||||
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
|
||||
'%s-requested_valid_from' % cr1.id: '2024-01-20',
|
||||
'email': 'admin@localhost'
|
||||
}, follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
|
||||
|
||||
# Corrected request
|
||||
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
|
||||
'%s-requested_valid_from' % cr1.id: '2023-01-20',
|
||||
'email': 'admin@localhost'
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
cr1.refresh_from_db()
|
||||
assert cr1.requested_valid_from.isoformat() == '2023-01-20T00:00:00+00:00'
|
||||
|
||||
self._set_payment()
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
with scopes_disabled():
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
self.assertFalse(CartPosition.objects.filter(id=cr1.id).exists())
|
||||
self.assertEqual(Order.objects.count(), 1)
|
||||
self.assertEqual(OrderPosition.objects.count(), 1)
|
||||
op = OrderPosition.objects.get()
|
||||
assert op.valid_from.isoformat() == '2023-01-20T00:00:00+00:00'
|
||||
assert op.valid_until.isoformat() == '2023-01-20T23:59:59+00:00'
|
||||
|
||||
@freeze_time("2023-01-18 03:00:00+01:00")
|
||||
def test_validity_requested_start_date_and_time(self):
|
||||
self.ticket.validity_mode = Item.VALIDITY_MODE_DYNAMIC
|
||||
self.ticket.validity_dynamic_duration_hours = 2
|
||||
self.ticket.validity_dynamic_start_choice = True
|
||||
self.ticket.validity_dynamic_start_choice_day_limit = 30
|
||||
self.ticket.save()
|
||||
with scopes_disabled():
|
||||
cr1 = CartPosition.objects.create(
|
||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||
price=42, listed_price=42, price_after_voucher=42, expires=now() + timedelta(minutes=10)
|
||||
)
|
||||
|
||||
# Date too far in the future, expected to fail
|
||||
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
|
||||
'%s-requested_valid_from_0' % cr1.id: '2024-01-20',
|
||||
'%s-requested_valid_from_1' % cr1.id: '11:00:00',
|
||||
'email': 'admin@localhost'
|
||||
}, follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
|
||||
|
||||
# Corrected request
|
||||
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
|
||||
'%s-requested_valid_from_0' % cr1.id: '2023-01-20',
|
||||
'%s-requested_valid_from_1' % cr1.id: '11:00:00',
|
||||
'email': 'admin@localhost'
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
cr1.refresh_from_db()
|
||||
assert cr1.requested_valid_from.isoformat() == '2023-01-20T11:00:00+00:00'
|
||||
|
||||
self._set_payment()
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
with scopes_disabled():
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
self.assertFalse(CartPosition.objects.filter(id=cr1.id).exists())
|
||||
self.assertEqual(Order.objects.count(), 1)
|
||||
self.assertEqual(OrderPosition.objects.count(), 1)
|
||||
op = OrderPosition.objects.get()
|
||||
assert op.valid_from.isoformat() == '2023-01-20T11:00:00+00:00'
|
||||
assert op.valid_until.isoformat() == '2023-01-20T13:00:00+00:00'
|
||||
|
||||
def test_voucher(self):
|
||||
with scopes_disabled():
|
||||
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event, price_mode='set',
|
||||
|
||||
Reference in New Issue
Block a user