diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 569feed828..d0fd427dce 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -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"}, diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 7a5607e304..b0d12ad5cf 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -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`` diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index ff7d54c2ff..eade1cd9f9 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -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): diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 185db6b5e6..1b29a53275 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -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'): diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index e6419fe758..edbd89203b 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -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: diff --git a/src/pretix/base/migrations/0231_auto_20230208_1546.py b/src/pretix/base/migrations/0231_auto_20230208_1546.py new file mode 100644 index 0000000000..b56ab83f46 --- /dev/null +++ b/src/pretix/base/migrations/0231_auto_20230208_1546.py @@ -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), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index bd82e00e0d..c58103a690 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -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 diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index fd99704284..be6f430a1e 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -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) diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index ae9607a5ff..bb08e7838b 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -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"), diff --git a/src/pretix/base/templatetags/daterange.py b/src/pretix/base/templatetags/daterange.py new file mode 100644 index 0000000000..e51bd517d1 --- /dev/null +++ b/src/pretix/base/templatetags/daterange.py @@ -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 . +# +# 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 +# . +# +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 + ) diff --git a/src/pretix/base/views/mixins.py b/src/pretix/base/views/mixins.py index efd879a86d..3bebd53588 100644 --- a/src/pretix/base/views/mixins.py +++ b/src/pretix/base/views/mixins.py @@ -19,6 +19,7 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +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'): diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index c2b35f1978..20bbc77ea5 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -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' }), diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index 12a4204c62..8879bff283 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -176,14 +176,55 @@ {% endfor %}
- {% trans "Tickets & check-in" %} + {% trans "Tickets & Badges" %} {% bootstrap_field form.generate_tickets layout="control" %} + {% for f in plugin_forms %} + {% if f.is_layouts %} + {% bootstrap_form f layout="control" %} + {% endif %} + {% endfor %} +
+
+ {% trans "Check-in & Validity" %} {% bootstrap_field form.checkin_attention layout="control" %} + {% bootstrap_field form.validity_mode layout="control" %} +
+ {% bootstrap_field form.validity_fixed_from layout="control" %} + {% bootstrap_field form.validity_fixed_until layout="control" %} +
+
+ + {% bootstrap_field form.validity_dynamic_start_choice layout="control" %} +
+ {% trans "days" as t_days %} + {% bootstrap_field form.validity_dynamic_start_choice_day_limit addon_after=t_days layout="control" %} +
+
{% trans "Additional settings" %} {% 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" %}
@@ -206,8 +247,11 @@
{% 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 %}
diff --git a/src/pretix/control/templates/pretixcontrol/order/change.html b/src/pretix/control/templates/pretixcontrol/order/change.html index 6d59718990..1bb114486f 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change.html +++ b/src/pretix/control/templates/pretixcontrol/order/change.html @@ -236,7 +236,7 @@ {% if position.valid_from %}
{% 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 %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 19393d7ff1..bfc87ebef1 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -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 @@
{% 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" %} diff --git a/src/pretix/helpers/daterange.py b/src/pretix/helpers/daterange.py index 98fed992fe..eadeeabd5d 100644 --- a/src/pretix/helpers/daterange.py +++ b/src/pretix/helpers/daterange.py @@ -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("{{}}", _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")) diff --git a/src/pretix/plugins/badges/forms.py b/src/pretix/plugins/badges/forms.py index c9d09ce75e..970a96f4c9 100644 --- a/src/pretix/plugins/badges/forms.py +++ b/src/pretix/plugins/badges/forms.py @@ -61,6 +61,7 @@ class BadgeLayoutChoiceField(forms.ModelChoiceField): class BadgeItemForm(forms.ModelForm): + is_layouts = True layout = BadgeLayoutChoiceField(queryset=BadgeLayout.objects.none()) class Meta: diff --git a/src/pretix/plugins/ticketoutputpdf/forms.py b/src/pretix/plugins/ticketoutputpdf/forms.py index 826b96adc4..318ab28597 100644 --- a/src/pretix/plugins/ticketoutputpdf/forms.py +++ b/src/pretix/plugins/ticketoutputpdf/forms.py @@ -34,6 +34,8 @@ class TicketLayoutForm(forms.ModelForm): class TicketLayoutItemForm(forms.ModelForm): + is_layouts = True + class Meta: model = TicketLayoutItem fields = ('layout',) diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index fcd310a715..898e867680 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -1,5 +1,6 @@ {% load i18n %} {% load eventurl %} +{% load daterange %} {% load safelink %} {% load rich_text %} {% load money %} @@ -103,8 +104,8 @@
{% 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" %} diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index bc6a910b63..fea58f4dac 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -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, } diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index 78ddd2bb89..16218aa63e 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -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) diff --git a/src/tests/base/test_item_validity.py b/src/tests/base/test_item_validity.py new file mode 100644 index 0000000000..85f8320f1d --- /dev/null +++ b/src/tests/base/test_item_validity.py @@ -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 . +# +# 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 +# . +# +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) diff --git a/src/tests/helpers/test_daterange.py b/src/tests/helpers/test_daterange.py index cd17f15c27..c1b2c1ba25 100644 --- a/src/tests/helpers/test_daterange.py +++ b/src/tests/helpers/test_daterange.py @@ -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) == ' – ' \ '' + + +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) == ' – ' \ + '' + 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) == ' – ' \ + '' + + +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) == ' – ' \ + '' + 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) == ' – ' \ + '' diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 83d1feddbc..551eb1d327 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -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',