diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index 5cd61772c..684b23696 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -24,6 +24,8 @@ is_public boolean If ``true``, th presale_start datetime The date at which the ticket shop opens (or ``null``) presale_end datetime The date at which the ticket shop closes (or ``null``) location multi-lingual string The event location (or ``null``) +has_subevents boolean ``True`` if the event series feature is active for this + event ===================================== ========================== ======================================================= @@ -67,6 +69,7 @@ Endpoints "presale_start": null, "presale_end": null, "location": null, + "has_subevents": false } ] } @@ -109,6 +112,7 @@ Endpoints "presale_start": null, "presale_end": null, "location": null, + "has_subevents": false } :param organizer: The ``slug`` field of the organizer to fetch diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 98e988281..10427113e 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -6,6 +6,7 @@ Resources and endpoints organizers events + subevents categories items questions diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 8e9f2e6d5..d2ed0ac48 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -71,6 +71,7 @@ tax_rate decimal (string) VAT rate applie tax_value money (string) VAT included in this position secret string Secret code printed on the tickets for validation addon_to integer Internal ID of the position this position is an add-on for (or ``null``) +subevent integer ID of the date inside an event series this position belongs to (or ``null``). checkins list of objects List of check-ins with this ticket └ datetime datetime Time of check-in downloads list of objects List of ticket download options @@ -151,6 +152,7 @@ Order endpoints "tax_value": "0.00", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "addon_to": null, + "subevent": null, "checkins": [ { "datetime": "2017-12-25T12:45:23Z" @@ -254,6 +256,7 @@ Order endpoints "tax_value": "0.00", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "addon_to": null, + "subevent": null, "checkins": [ { "datetime": "2017-12-25T12:45:23Z" @@ -372,6 +375,7 @@ Order position endpoints "tax_value": "0.00", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "addon_to": null, + "subevent": null, "checkins": [ { "datetime": "2017-12-25T12:45:23Z" @@ -407,6 +411,7 @@ Order position endpoints :query string order__status: Only return positions with the given order status. :query bollean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been checked in already. + :query integer subevent: Only return positions of the sub-event with the given ID :query integer addon_to: Only return positions that are add-ons to the position with the given ID. :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch @@ -448,6 +453,7 @@ Order position endpoints "tax_value": "0.00", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "addon_to": null, + "subevent": null, "checkins": [ { "datetime": "2017-12-25T12:45:23Z" diff --git a/doc/api/resources/quotas.rst b/doc/api/resources/quotas.rst index 2b982570e..37130f005 100644 --- a/doc/api/resources/quotas.rst +++ b/doc/api/resources/quotas.rst @@ -17,6 +17,7 @@ name string The internal na size integer The size of the quota or ``null`` for unlimited items list of integers List of item IDs this quota acts on. variations list of integers List of item variation IDs this quota acts on. +subevent integer ID of the date inside an event series this quota belongs to (or ``null``). ===================================== ========================== ======================================================= @@ -53,7 +54,8 @@ Endpoints "name": "Ticket Quota", "size": 200, "items": [1, 2], - "variations": [1, 4, 5, 7] + "variations": [1, 4, 5, 7], + "subevent": null } ] } @@ -61,6 +63,7 @@ Endpoints :query integer page: The page number in case of a multi-page result set, default is 1 :query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``. Default: ``position`` + :query integer subevent: Only return quotas of the sub-event with the given ID :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch :statuscode 200: no error @@ -92,7 +95,8 @@ Endpoints "name": "Ticket Quota", "size": 200, "items": [1, 2], - "variations": [1, 4, 5, 7] + "variations": [1, 4, 5, 7], + "subevent": null } :param organizer: The ``slug`` field of the organizer to fetch diff --git a/doc/api/resources/subevents.rst b/doc/api/resources/subevents.rst new file mode 100644 index 000000000..cd40013f1 --- /dev/null +++ b/doc/api/resources/subevents.rst @@ -0,0 +1,137 @@ +Event series dates / Sub-events +=============================== + +Resource description +-------------------- + +Events can represent whole event series if the ``has_subevents`` property of the event is active. +In this case, many other resources are additionally connected to an event date (also called sub-event). +The sub-event resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the sub-event +name multi-lingual string The sub-event's full name +active boolean If ``true``, the sub-event ticket shop is publicly + available. +date_from datetime The sub-event's start date +date_to datetime The sub-event's end date (or ``null``) +date_admission datetime The sub-event's admission date (or ``null``) +presale_start datetime The sub-date at which the ticket shop opens (or ``null``) +presale_end datetime The sub-date at which the ticket shop closes (or ``null``) +location multi-lingual string The sub-event location (or ``null``) +item_price_overrides list of objects List of items for which this sub-event overrides the + default price +├ item integer The internal item ID +└ price money (string) The price or ``null`` for the default price +variation_price_overrides list of objects List of variations for which this sub-event overrides + the default price +├ variation integer The internal variation ID +└ price money (string) The price or ``null`` for the default price +===================================== ========================== ======================================================= + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/ + + Returns a list of all sub-events of an event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/subevents/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "name": {"en": "First Sample Conference"}, + "active": false, + "date_from": "2017-12-27T10:00:00Z", + "date_to": null, + "date_admission": null, + "presale_start": null, + "presale_end": null, + "location": null, + "item_price_overrides": [ + { + "item": 2, + "price": "12.00" + } + ], + "variation_price_overrides": [] + } + ] + } + + :query page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of a valid organizer + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view it. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/ + + Returns information on one sub-event, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "id": 1, + "name": {"en": "First Sample Conference"}, + "active": false, + "date_from": "2017-12-27T10:00:00Z", + "date_to": null, + "date_admission": null, + "presale_start": null, + "presale_end": null, + "location": null, + "item_price_overrides": [ + { + "item": 2, + "price": "12.00" + } + ], + "variation_price_overrides": [] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``slug`` field of the sub-event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it. diff --git a/doc/api/resources/vouchers.rst b/doc/api/resources/vouchers.rst index e875ede72..d77dd0c3c 100644 --- a/doc/api/resources/vouchers.rst +++ b/doc/api/resources/vouchers.rst @@ -40,6 +40,7 @@ quota integer An ID of a quot for all items without restriction. tag string A string that is used for grouping vouchers comment string An internal comment on the voucher +subevent integer ID of the date inside an event series this voucher belongs to (or ``null``). ===================================== ========================== ======================================================= @@ -85,7 +86,8 @@ Endpoints "variation": null, "quota": null, "tag": "testvoucher", - "comment": "" + "comment": "", + "subevent": null } ] } @@ -107,6 +109,7 @@ Endpoints :query integer variation: If set, only vouchers attached to the variation with the given ID will be shown. :query integer quota: If set, only vouchers attached to the quota with the given ID will be shown. :query string tag: If set, only vouchers with the given tag will be shown. + :query integer subevent: Only return vouchers of the sub-event with the given ID :query string ordering: Manually set the ordering of results. Valid fields to be used are ``id``, ``code``, ``max_usages``, ``valid_until``, and ``value``. Default: ``id`` :param organizer: The ``slug`` field of the organizer to fetch @@ -149,7 +152,8 @@ Endpoints "variation": null, "quota": null, "tag": "testvoucher", - "comment": "" + "comment": "", + "subevent": null } :param organizer: The ``slug`` field of the organizer to fetch diff --git a/doc/api/resources/waitinglist.rst b/doc/api/resources/waitinglist.rst index 653947f66..995b5d575 100644 --- a/doc/api/resources/waitinglist.rst +++ b/doc/api/resources/waitinglist.rst @@ -23,6 +23,7 @@ item integer An ID of an ite variation integer An ID of a variation the user is waiting to be available again (or ``null``) locale string Locale of the waiting user +subevent integer ID of the date inside an event series this entry belongs to (or ``null``). ===================================== ========================== ======================================================= @@ -61,7 +62,8 @@ Endpoints "voucher": null, "item": 2, "variation": null, - "locale": "en" + "locale": "en", + "subevent": null } ] } @@ -73,6 +75,7 @@ Endpoints have not been sent a voucher. :query integer item: If set, only entries of users waiting for the item with the given ID will be shown. :query integer variation: If set, only entries of users waiting for the variation with the given ID will be shown. + :query integer subevent: Only return entries of the sub-event with the given ID :query string ordering: Manually set the ordering of results. Valid fields to be used are ``id``, ``created``, ``email``, ``item``. Default: ``created`` :param organizer: The ``slug`` field of the organizer to fetch @@ -108,7 +111,8 @@ Endpoints "voucher": null, "item": 2, "variation": null, - "locale": "en" + "locale": "en", + "subevent": null } :param organizer: The ``slug`` field of the organizer to fetch diff --git a/doc/development/implementation/models.rst b/doc/development/implementation/models.rst index 21f48a973..6cf60c193 100644 --- a/doc/development/implementation/models.rst +++ b/doc/development/implementation/models.rst @@ -21,7 +21,10 @@ Organizers and events :members: .. autoclass:: pretix.base.models.Event - :members: + :members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running, get_cache, lock, get_plugins, get_mail_backend, payment_term_last, get_payment_providers, get_invoice_renderers, active_subevents, invoice_renderer, settings + +.. autoclass:: pretix.base.models.SubEvent + :members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running .. autoclass:: pretix.base.models.Team :members: @@ -42,6 +45,15 @@ Items .. autoclass:: pretix.base.models.ItemVariation :members: +.. autoclass:: pretix.base.models.SubEventItem + :members: + +.. autoclass:: pretix.base.models.SubEventItemVariation + :members: + +.. autoclass:: pretix.base.models.ItemAddOn + :members: + .. autoclass:: pretix.base.models.Question :members: diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 0d55e4c5f..0678de047 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -1,5 +1,7 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.models import Event +from pretix.base.models.event import SubEvent +from pretix.base.models.items import SubEventItem, SubEventItemVariation class EventSerializer(I18nAwareModelSerializer): @@ -7,4 +9,27 @@ class EventSerializer(I18nAwareModelSerializer): model = Event fields = ('name', 'slug', 'live', 'currency', 'date_from', 'date_to', 'date_admission', 'is_public', 'presale_start', - 'presale_end', 'location') + 'presale_end', 'location', 'has_subevents') + + +class SubEventItemSerializer(I18nAwareModelSerializer): + class Meta: + model = SubEventItem + fields = ('item', 'price') + + +class SubEventItemVariationSerializer(I18nAwareModelSerializer): + class Meta: + model = SubEventItemVariation + fields = ('variation', 'price') + + +class SubEventSerializer(I18nAwareModelSerializer): + item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True) + variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True) + + class Meta: + model = SubEvent + fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission', + 'presale_start', 'presale_end', 'location', + 'item_price_overrides', 'variation_price_overrides') diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index da904726e..b74d51b7b 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -61,4 +61,4 @@ class QuotaSerializer(I18nAwareModelSerializer): class Meta: model = Quota - fields = ('id', 'name', 'size', 'items', 'variations') + fields = ('id', 'name', 'size', 'items', 'variations', 'subevent') diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 749287826..11d332a9d 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -86,7 +86,8 @@ class OrderPositionSerializer(I18nAwareModelSerializer): class Meta: model = OrderPosition fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email', - 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'checkins', 'downloads', 'answers') + 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'downloads', + 'answers') class OrderSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/api/serializers/voucher.py b/src/pretix/api/serializers/voucher.py index a281b7ddf..1feab4961 100644 --- a/src/pretix/api/serializers/voucher.py +++ b/src/pretix/api/serializers/voucher.py @@ -7,4 +7,4 @@ class VoucherSerializer(I18nAwareModelSerializer): model = Voucher fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota', 'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota', - 'tag', 'comment') + 'tag', 'comment', 'subevent') diff --git a/src/pretix/api/serializers/waitinglist.py b/src/pretix/api/serializers/waitinglist.py index a34a842c6..b799cc5eb 100644 --- a/src/pretix/api/serializers/waitinglist.py +++ b/src/pretix/api/serializers/waitinglist.py @@ -6,4 +6,4 @@ class WaitingListSerializer(I18nAwareModelSerializer): class Meta: model = WaitingListEntry - fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale') + fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale', 'subevent') diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index e06afd367..56d9c3954 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -13,6 +13,7 @@ orga_router = routers.DefaultRouter() orga_router.register(r'events', event.EventViewSet) event_router = routers.DefaultRouter() +event_router.register(r'subevents', event.SubEventViewSet) event_router.register(r'items', item.ItemViewSet) event_router.register(r'categories', item.ItemCategoryViewSet) event_router.register(r'questions', item.QuestionViewSet) diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index fbe232bb4..c0879c7fb 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -1,7 +1,9 @@ -from rest_framework import viewsets +from django_filters.rest_framework import DjangoFilterBackend, FilterSet +from rest_framework import filters, viewsets -from pretix.api.serializers.event import EventSerializer -from pretix.base.models import Event +from pretix.api.serializers.event import EventSerializer, SubEventSerializer +from pretix.base.models import Event, ItemCategory +from pretix.base.models.event import SubEvent class EventViewSet(viewsets.ReadOnlyModelViewSet): @@ -12,3 +14,21 @@ class EventViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return self.request.organizer.events.all() + + +class SubEventFilter(FilterSet): + class Meta: + model = SubEvent + fields = ['active'] + + +class SubEventViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = SubEventSerializer + queryset = ItemCategory.objects.none() + filter_backends = (DjangoFilterBackend, filters.OrderingFilter) + filter_class = SubEventFilter + + def get_queryset(self): + return self.request.event.subevents.prefetch_related( + 'subeventitem_set', 'subeventitemvariation_set' + ) diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index 16e6b34b7..8a001663a 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -58,10 +58,17 @@ class QuestionViewSet(viewsets.ReadOnlyModelViewSet): return self.request.event.questions.prefetch_related('options').all() +class QuotaFilter(FilterSet): + class Meta: + model = Quota + fields = ['subevent'] + + class QuotaViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = QuotaSerializer queryset = Quota.objects.none() - filter_backends = (OrderingFilter,) + filter_backends = (DjangoFilterBackend, OrderingFilter,) + filter_class = QuotaFilter ordering_fields = ('id', 'size') ordering = ('id',) diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 0d0d33900..fb30b716f 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -84,7 +84,7 @@ class OrderPositionFilter(FilterSet): class Meta: model = OrderPosition fields = ['item', 'variation', 'attendee_name', 'secret', 'order', 'order__status', 'has_checkin', - 'addon_to'] + 'addon_to', 'subevent'] class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet): diff --git a/src/pretix/api/views/voucher.py b/src/pretix/api/views/voucher.py index 19a3dcff5..45c3a01e4 100644 --- a/src/pretix/api/views/voucher.py +++ b/src/pretix/api/views/voucher.py @@ -16,7 +16,7 @@ class VoucherFilter(FilterSet): class Meta: model = Voucher fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota', - 'price_mode', 'value', 'item', 'variation', 'quota', 'tag'] + 'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'subevent'] def filter_active(self, queryset, name, value): if value: diff --git a/src/pretix/api/views/waitinglist.py b/src/pretix/api/views/waitinglist.py index e520d83d9..44e850310 100644 --- a/src/pretix/api/views/waitinglist.py +++ b/src/pretix/api/views/waitinglist.py @@ -15,7 +15,7 @@ class WaitingListFilter(FilterSet): class Meta: model = WaitingListEntry - fields = ['item', 'variation', 'email', 'locale', 'has_voucher'] + fields = ['item', 'variation', 'email', 'locale', 'has_voucher', 'subevent'] class WaitingListViewSet(viewsets.ReadOnlyModelViewSet): diff --git a/src/pretix/base/forms/__init__.py b/src/pretix/base/forms/__init__.py index 5ac5d2c4a..0af8d1d8b 100644 --- a/src/pretix/base/forms/__init__.py +++ b/src/pretix/base/forms/__init__.py @@ -7,6 +7,7 @@ from django.utils.crypto import get_random_string from hierarkey.forms import HierarkeyForm from pretix.base.models import Event +from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from .validators import PlaceholderValidator # NOQA @@ -56,6 +57,9 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm): kwargs['locales'] = self.locales kwargs['initial'] = self.obj.settings.freeze() super().__init__(*args, **kwargs) + for f in self.fields.values(): + if isinstance(f, (RelativeDateTimeField, RelativeDateField)): + f.set_event(self.obj) def get_new_filename(self, name: str) -> str: nonce = get_random_string(length=8) diff --git a/src/pretix/base/migrations/0066_auto_20170708_2102.py b/src/pretix/base/migrations/0066_auto_20170708_2102.py new file mode 100644 index 000000000..70ecd488d --- /dev/null +++ b/src/pretix/base/migrations/0066_auto_20170708_2102.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.2 on 2017-07-08 21:02 +from __future__ import unicode_literals + +import django.db.models.deletion +import i18nfield.fields +from django.db import migrations, models + +import pretix.base.models.base +import pretix.base.models.event + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0065_auto_20170707_0920'), + ] + + operations = [ + migrations.CreateModel( + name='SubEvent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('active', models.BooleanField(default=False, help_text='Only with this checkbox enabled, this sub-event is visible in the frontend to users.', verbose_name='Active')), + ('name', i18nfield.fields.I18nCharField(max_length=200, verbose_name='Name')), + ('date_from', models.DateTimeField(verbose_name='Event start time')), + ('date_to', models.DateTimeField(blank=True, null=True, verbose_name='Event end time')), + ('date_admission', models.DateTimeField(blank=True, null=True, verbose_name='Admission time')), + ('presale_end', models.DateTimeField(blank=True, help_text='No products will be sold after this date.', null=True, verbose_name='End of presale')), + ('presale_start', models.DateTimeField(blank=True, help_text='No products will be sold before this date.', null=True, verbose_name='Start of presale')), + ('location', i18nfield.fields.I18nTextField(blank=True, max_length=200, null=True, verbose_name='Location')), + ], + options={ + 'verbose_name': 'Sub-Event', + 'verbose_name_plural': 'Sub-Events', + 'ordering': ('date_from', 'name'), + }, + bases=(pretix.base.models.event.EventMixin, models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.CreateModel( + name='SubEventItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Item')), + ('subevent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent')), + ], + ), + migrations.CreateModel( + name='SubEventItemVariation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True)), + ('subevent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent')), + ('variation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.ItemVariation')), + ], + ), + migrations.AddField( + model_name='event', + name='has_subevents', + field=models.BooleanField(default=False, help_text='Only recommended for advanced users. If this feature is enabled, this will not only be a single event but a series of very similar events that are handled within a single shop. The single events inside the series can only differ in prices and quotas, not in other settings, and buying tickets across multiple of these events at the same time is possible.', verbose_name='Event series'), + ), + migrations.AddField( + model_name='subevent', + name='event', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subevents', to='pretixbase.Event'), + ), + migrations.AddField( + model_name='subevent', + name='items', + field=models.ManyToManyField(through='pretixbase.SubEventItem', to='pretixbase.Item'), + ), + migrations.AddField( + model_name='subevent', + name='variations', + field=models.ManyToManyField(through='pretixbase.SubEventItemVariation', to='pretixbase.ItemVariation'), + ), + migrations.AddField( + model_name='cartposition', + name='subevent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Sub-event'), + ), + migrations.AddField( + model_name='orderposition', + name='subevent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Sub-event'), + ), + migrations.AddField( + model_name='quota', + name='subevent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quotas', to='pretixbase.SubEvent', verbose_name='Sub-event'), + ), + migrations.AddField( + model_name='voucher', + name='subevent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Sub-event'), + ), + migrations.AddField( + model_name='waitinglistentry', + name='subevent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Sub-event'), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index ffde2cb67..2da46e9d9 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -3,13 +3,13 @@ from .auth import U2FDevice, User from .base import CachedFile, LoggedModel, cachedfile_name from .checkin import Checkin from .event import ( - Event, Event_SettingsStore, EventLock, RequiredAction, + Event, Event_SettingsStore, EventLock, RequiredAction, SubEvent, generate_invite_token, ) from .invoices import Invoice, InvoiceLine, invoice_filename from .items import ( Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption, - Quota, itempicture_upload_to, + Quota, SubEventItem, SubEventItemVariation, itempicture_upload_to, ) from .log import LogEntry from .orders import ( diff --git a/src/pretix/base/models/base.py b/src/pretix/base/models/base.py index 8f5f57632..7655f48aa 100644 --- a/src/pretix/base/models/base.py +++ b/src/pretix/base/models/base.py @@ -6,7 +6,8 @@ from django.db import models from django.db.models.signals import post_delete from django.dispatch import receiver from django.utils.crypto import get_random_string -from i18nfield.utils import I18nJSONEncoder + +from pretix.helpers.json import CustomJSONEncoder def cachedfile_name(instance, filename: str) -> str: @@ -54,7 +55,7 @@ class LoggingMixin: event = self.event l = LogEntry(content_object=self, user=user, action_type=action, event=event) if data: - l.data = json.dumps(data, cls=I18nJSONEncoder) + l.data = json.dumps(data, cls=CustomJSONEncoder) l.save() diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 4348bfd7b..9ce804fd9 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -1,6 +1,6 @@ import string import uuid -from datetime import date, datetime, time +from datetime import datetime, time import pytz from django.conf import settings @@ -9,14 +9,17 @@ from django.core.files.storage import default_storage from django.core.mail import get_connection from django.core.validators import RegexValidator from django.db import models +from django.db.models import Q from django.template.defaultfilters import date as _date from django.utils.crypto import get_random_string +from django.utils.functional import cached_property from django.utils.timezone import make_aware, now from django.utils.translation import ugettext_lazy as _ from i18nfield.fields import I18nCharField, I18nTextField from pretix.base.email import CustomSMTPBackend from pretix.base.models.base import LoggedModel +from pretix.base.reldate import RelativeDateWrapper from pretix.base.validators import EventSlugBlacklistValidator from pretix.helpers.daterange import daterange @@ -24,8 +27,85 @@ from ..settings import settings_hierarkey from .organizer import Organizer +class EventMixin: + + def clean(self): + if self.presale_start and self.presale_end and self.presale_start > self.presale_end: + raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')}) + if self.date_from and self.date_to and self.date_from > self.date_to: + raise ValidationError({'date_to': _('The end of the event has to be later than its start.')}) + super().clean() + + def get_date_from_display(self, tz=None, show_times=True) -> str: + """ + Returns a formatted string containing the start date of the event with respect + to the current locale and to the ``show_times`` setting. + """ + tz = tz or pytz.timezone(self.settings.timezone) + return _date( + self.date_from.astimezone(tz), + "DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT" + ) + + def get_time_from_display(self, tz=None) -> str: + """ + Returns a formatted string containing the start time of the event, ignoring + the ``show_times`` setting. + """ + tz = tz or pytz.timezone(self.settings.timezone) + return _date( + self.date_from.astimezone(tz), "TIME_FORMAT" + ) + + def get_date_to_display(self, tz=None) -> str: + """ + Returns a formatted string containing the start date of the event with respect + to the current locale and to the ``show_times`` setting. Returns an empty string + if ``show_date_to`` is ``False``. + """ + tz = tz or pytz.timezone(self.settings.timezone) + if not self.settings.show_date_to or not self.date_to: + return "" + return _date( + self.date_to.astimezone(tz), + "DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT" + ) + + def get_date_range_display(self, tz=None) -> str: + """ + Returns a formatted string containing the start date and the event date + of the event with respect to the current locale and to the ``show_times`` and + ``show_date_to`` settings. + """ + tz = tz or pytz.timezone(self.settings.timezone) + if not self.settings.show_date_to or not self.date_to: + return _date(self.date_from.astimezone(tz), "DATE_FORMAT") + return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz)) + + @property + def presale_has_ended(self): + """ + Is true, when ``presale_end`` is set and in the past. + """ + if self.presale_end and now() > self.presale_end: + return True + return False + + @property + def presale_is_running(self): + """ + Is true, when ``presale_end`` is not set or in the future and ``presale_start`` is not + set or in the past. + """ + if self.presale_start and now() < self.presale_start: + return False + if self.presale_end and now() > self.presale_end: + return False + return True + + @settings_hierarkey.add(parent_field='organizer', cache_namespace='event') -class Event(LoggedModel): +class Event(EventMixin, LoggedModel): """ This model represents an event. An event is anything you can buy tickets for. @@ -54,6 +134,8 @@ class Event(LoggedModel): :param plugins: A comma-separated list of plugin names that are active for this event. :type plugins: str + :param has_subevents: Enable event series functionality + :type has_subevents: bool """ settings_namespace = 'event' @@ -116,6 +198,10 @@ class Event(LoggedModel): verbose_name=_("Internal comment"), null=True, blank=True ) + has_subevents = models.BooleanField( + verbose_name=_('Event series'), + default=False + ) class Meta: verbose_name = _("Event") @@ -130,13 +216,6 @@ class Event(LoggedModel): self.get_cache().clear() return obj - def clean(self): - if self.presale_start and self.presale_end and self.presale_start > self.presale_end: - raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')}) - if self.date_from and self.date_to and self.date_from > self.date_to: - raise ValidationError({'date_to': _('The end of the event has to be later than its start.')}) - super().clean() - def get_plugins(self) -> "list[str]": """ Returns the names of the plugins activated for this event as a list. @@ -145,47 +224,6 @@ class Event(LoggedModel): return [] return self.plugins.split(",") - def get_date_from_display(self, tz=None, show_times=True) -> str: - """ - Returns a formatted string containing the start date of the event with respect - to the current locale and to the ``show_times`` setting. - """ - tz = tz or pytz.timezone(self.settings.timezone) - return _date( - self.date_from.astimezone(tz), - "DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT" - ) - - def get_time_from_display(self, tz=None) -> str: - """ - Returns a formatted string containing the start time of the event, ignoring - the ``show_times`` setting. - """ - tz = tz or pytz.timezone(self.settings.timezone) - return _date( - self.date_from.astimezone(tz), "TIME_FORMAT" - ) - - def get_date_to_display(self, tz=None) -> str: - """ - Returns a formatted string containing the start date of the event with respect - to the current locale and to the ``show_times`` setting. Returns an empty string - if ``show_date_to`` is ``False``. - """ - tz = tz or pytz.timezone(self.settings.timezone) - if not self.settings.show_date_to or not self.date_to: - return "" - return _date( - self.date_to.astimezone(tz), - "DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT" - ) - - def get_date_range_display(self, tz=None) -> str: - tz = tz or pytz.timezone(self.settings.timezone) - if not self.settings.show_date_to or not self.date_to: - return _date(self.date_from.astimezone(tz), "DATE_FORMAT") - return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz)) - def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache": """ Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to @@ -197,20 +235,6 @@ class Event(LoggedModel): return ObjectRelatedCache(self) - @property - def presale_has_ended(self): - if self.presale_end and now() > self.presale_end: - return True - return False - - @property - def presale_is_running(self): - if self.presale_start and now() < self.presale_start: - return False - if self.presale_end and now() > self.presale_end: - return False - return True - def lock(self): """ Returns a contextmanager that can be used to lock an event for bookings. @@ -220,6 +244,10 @@ class Event(LoggedModel): return locking.LockManager(self) def get_mail_backend(self, force_custom=False): + """ + Returns an email server connection, either by using the system-wide connection + or by returning a custom one based on the event's settings. + """ if self.settings.smtp_use_custom or force_custom: return CustomSMTPBackend(host=self.settings.smtp_host, port=self.settings.smtp_port, @@ -233,9 +261,12 @@ class Event(LoggedModel): @property def payment_term_last(self): + """ + The last datetime of payments for this event. + """ tz = pytz.timezone(self.settings.timezone) return make_aware(datetime.combine( - self.settings.get('payment_term_last', as_type=date), + self.settings.get('payment_term_last', as_type=RelativeDateWrapper).datetime(self).date(), time(hour=23, minute=59, second=59) ), tz) @@ -277,7 +308,7 @@ class Event(LoggedModel): ia.addon_category = category_map[ia.addon_category.pk] ia.save() - for q in Quota.objects.filter(event=other).prefetch_related('items', 'variations'): + for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'): items = list(q.items.all()) vars = list(q.variations.all()) q.pk = None @@ -318,6 +349,9 @@ class Event(LoggedModel): event_copy_data.send(sender=self, other=other) def get_payment_providers(self) -> dict: + """ + Returns a dictionary of initialized payment providers mapped by their identifiers. + """ from ..signals import register_payment_providers responses = register_payment_providers.send(self) @@ -331,6 +365,9 @@ class Event(LoggedModel): return providers def get_invoice_renderers(self) -> dict: + """ + Returns a dictionary of initialized invoice renderers mapped by their identifiers. + """ from ..signals import register_invoice_renderers responses = register_invoice_renderers.send(self) @@ -345,9 +382,113 @@ class Event(LoggedModel): @property def invoice_renderer(self): + """ + Returns the currently configured invoice renderer. + """ irs = self.get_invoice_renderers() return irs[self.settings.invoice_renderer] + @property + def active_subevents(self): + """ + Returns a queryset of active subevents. + """ + return self.subevents.filter(active=True).order_by('-date_from', 'name') + + @property + def active_future_subevents(self): + return self.subevents.filter( + Q(active=True) & ( + Q(Q(date_to__isnull=True) & Q(date_from__gte=now())) + | Q(date_to__gte=now()) + ) + ).order_by('date_from', 'name') + + +class SubEvent(EventMixin, LoggedModel): + """ + This model represents a date within an event series. + + :param event: The event this belongs to + :type event: Event + :param active: Whether to show the subevent + :type active: bool + :param name: This event's full title + :type name: str + :param date_from: The datetime this event starts + :type date_from: datetime + :param date_to: The datetime this event ends + :type date_to: datetime + :param presale_start: No tickets will be sold before this date. + :type presale_start: datetime + :param presale_end: No tickets will be sold after this date. + :type presale_end: datetime + :param location: venue + :type location: str + """ + + event = models.ForeignKey(Event, related_name="subevents", on_delete=models.PROTECT) + active = models.BooleanField(default=False, verbose_name=_("Active"), + help_text=_("Only with this checkbox enabled, this date is visible in the " + "frontend to users.")) + name = I18nCharField( + max_length=200, + verbose_name=_("Name"), + ) + date_from = models.DateTimeField(verbose_name=_("Event start time")) + date_to = models.DateTimeField(null=True, blank=True, + verbose_name=_("Event end time")) + date_admission = models.DateTimeField(null=True, blank=True, + verbose_name=_("Admission time")) + presale_end = models.DateTimeField( + null=True, blank=True, + verbose_name=_("End of presale"), + help_text=_("No products will be sold after this date."), + ) + presale_start = models.DateTimeField( + null=True, blank=True, + verbose_name=_("Start of presale"), + help_text=_("No products will be sold before this date."), + ) + location = I18nTextField( + null=True, blank=True, + max_length=200, + verbose_name=_("Location"), + ) + + items = models.ManyToManyField('Item', through='SubEventItem') + variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation') + + class Meta: + verbose_name = _("Date in event series") + verbose_name_plural = _("Dates in event series") + ordering = ("date_from", "name") + + def __str__(self): + return '{} - {}'.format(self.name, self.get_date_range_display()) + + @cached_property + def settings(self): + return self.event.settings + + @cached_property + def item_price_overrides(self): + from .items import SubEventItem + + return { + si.item_id: si.price + for si in SubEventItem.objects.filter(subevent=self, price__isnull=False) + } + + @cached_property + def var_price_overrides(self): + from .items import SubEventItemVariation + + return { + si.variation_id: si.price + for si in SubEventItemVariation.objects.filter(subevent=self, price__isnull=False) + } + def generate_invite_token(): return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index af8208192..69a5c0baf 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -10,13 +10,13 @@ from django.db import models from django.db.models import F, Func, Q, Sum from django.utils.functional import cached_property from django.utils.timezone import now -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from i18nfield.fields import I18nCharField, I18nTextField from pretix.base.decimal import round_decimal from pretix.base.models.base import LoggedModel -from .event import Event +from .event import Event, SubEvent class ItemCategory(LoggedModel): @@ -88,6 +88,40 @@ def itempicture_upload_to(instance, filename: str) -> str: ) +class SubEventItem(models.Model): + """ + This model can be used to change the price of a product for a single subevent (i.e. a + date in an event series). + + :param subevent: The date this belongs to + :type subevent: SubEvent + :param item: The item to modify the price for + :type item: Item + :param price: The modified price (or ``None`` for the original price) + :type price: Decimal + """ + subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE) + item = models.ForeignKey('Item', on_delete=models.CASCADE) + price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True) + + +class SubEventItemVariation(models.Model): + """ + This model can be used to change the price of a product variation for a single + subevent (i.e. a date in an event series). + + :param subevent: The date this belongs to + :type subevent: SubEvent + :param variation: The variation to modify the price for + :type variation: ItemVariation + :param price: The modified price (or ``None`` for the original price) + :type price: Decimal + """ + subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE) + variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE) + price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True) + + class Item(LoggedModel): """ An item is a thing which can be sold. It belongs to an event and may or may not belong to a category. @@ -271,7 +305,7 @@ class Item(LoggedModel): return False return True - def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None): + def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None): """ This method is used to determine whether this Item is currently available for sale. @@ -285,12 +319,18 @@ class Item(LoggedModel): :raises ValueError: if you call this on an item which has variations associated with it. Please use the method on the ItemVariation object you are interested in. """ - check_quotas = set(self.quotas.all()) + check_quotas = set(getattr( + self, '_subevent_quotas', # Utilize cache in product list + self.quotas.select_related('subevent').filter(subevent=subevent) + if subevent else self.quotas.all() + )) + if not subevent and self.event.has_subevents: + raise TypeError('You need to supply a subevent.') if ignored_quotas: check_quotas -= set(ignored_quotas) if not check_quotas: return Quota.AVAILABILITY_OK, sys.maxsize - if self.variations.count() > 0: # NOQA + if self.has_variations: # NOQA raise ValueError('Do not call this directly on items which have variations ' 'but call this on their ItemVariation objects') return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas], @@ -371,7 +411,7 @@ class ItemVariation(models.Model): if self.item: self.item.event.get_cache().clear() - def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]: + def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None) -> Tuple[int, int]: """ This method is used to determine whether this ItemVariation is currently available for sale in terms of quotas. @@ -383,9 +423,15 @@ class ItemVariation(models.Model): :param count_waitinglist: If ``False``, waiting list entries will be ignored for quota calculation. :returns: any of the return codes of :py:meth:`Quota.availability()`. """ - check_quotas = set(self.quotas.all()) + check_quotas = set(getattr( + self, '_subevent_quotas', # Utilize cache in product list + self.quotas.filter(subevent=subevent).select_related('subevent') + if subevent else self.quotas.all() + )) if ignored_quotas: check_quotas -= set(ignored_quotas) + if not subevent and self.item.event.has_subevents: # NOQA + raise TypeError('You need to supply a subevent.') if not check_quotas: return Quota.AVAILABILITY_OK, sys.maxsize return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas], @@ -402,6 +448,17 @@ class ItemAddOn(models.Model): An instance of this model indicates that buying a ticket of the time ``base_item`` allows you to add up to ``max_count`` items from the category ``addon_category`` to your order that will be associated with the base item. + + :param base_item: The base item the add-ons are attached to + :type base_item: Item + :param addon_category: The category the add-on can be chosen from + :type addon_category: ItemCategory + :param min_count: The minimal number of add-ons to be chosen + :type min_count: int + :param max_count: The maximal number of add-ons to be chosen + :type max_count: int + :param position: An integer used for sorting + :type position: int """ base_item = models.ForeignKey( Item, @@ -574,6 +631,8 @@ class Quota(LoggedModel): :param event: The event this belongs to :type event: Event + :param subevent: The event series date this belongs to, if event series are enabled + :type subevent: SubEvent :param name: This quota's name :type name: str :param size: The number of items in this quota @@ -593,6 +652,13 @@ class Quota(LoggedModel): related_name="quotas", verbose_name=_("Event"), ) + subevent = models.ForeignKey( + SubEvent, + null=True, blank=True, + on_delete=models.CASCADE, + related_name="quotas", + verbose_name=pgettext_lazy('subevent', "Date"), + ) name = models.CharField( max_length=200, verbose_name=_("Name") @@ -687,11 +753,11 @@ class Quota(LoggedModel): now_dt = now_dt or now() if 'sqlite3' in settings.DATABASES['default']['ENGINE']: func = 'MAX' - else: + else: # NOQA func = 'GREATEST' return Voucher.objects.filter( - Q(event=self.event) & + Q(event=self.event) & Q(subevent=self.subevent) & Q(block_quota=True) & Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now_dt)) & Q(Q(self._position_lookup) | Q(quota=self)) @@ -702,7 +768,7 @@ class Quota(LoggedModel): def count_waiting_list_pending(self) -> int: from pretix.base.models import WaitingListEntry return WaitingListEntry.objects.filter( - Q(voucher__isnull=True) & + Q(voucher__isnull=True) & Q(subevent=self.subevent) & self._position_lookup ).distinct().count() @@ -711,7 +777,7 @@ class Quota(LoggedModel): now_dt = now_dt or now() return CartPosition.objects.filter( - Q(event=self.event) & + Q(event=self.event) & Q(subevent=self.subevent) & Q(expires__gte=now_dt) & ~Q( Q(voucher__isnull=False) & Q(voucher__block_quota=True) @@ -725,14 +791,14 @@ class Quota(LoggedModel): # This query has beeen benchmarked against a Count('id', distinct=True) aggregate and won by a small margin. return OrderPosition.objects.filter( - self._position_lookup, order__status=Order.STATUS_PENDING, order__event=self.event + self._position_lookup, order__status=Order.STATUS_PENDING, order__event=self.event, subevent=self.subevent ).values('id').distinct().count() def count_paid_orders(self): from pretix.base.models import Order, OrderPosition return OrderPosition.objects.filter( - self._position_lookup, order__status=Order.STATUS_PAID, order__event=self.event + self._position_lookup, order__status=Order.STATUS_PAID, order__event=self.event, subevent=self.subevent ).values('id').distinct().count() @cached_property diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index c01ef64c6..d850f5540 100644 --- a/src/pretix/base/models/log.py +++ b/src/pretix/base/models/log.py @@ -5,7 +5,9 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse from django.utils.functional import cached_property -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import pgettext_lazy, ugettext_lazy as _ + +from pretix.base.models.event import SubEvent class LogEntry(models.Model): @@ -88,6 +90,16 @@ class LogEntry(models.Model): }), 'val': co.name, } + elif isinstance(co, SubEvent): + a_text = pgettext_lazy('subevent', 'Date {val}') + a_map = { + 'href': reverse('control:event.subevent', kwargs={ + 'event': self.event.slug, + 'organizer': self.event.organizer.slug, + 'subevent': co.id + }), + 'val': str(co) + } elif isinstance(co, Quota): a_text = _('Quota {val}') a_map = { diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 835010d1d..6fc144ee1 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -2,10 +2,11 @@ import copy import json import os import string -from datetime import datetime +from datetime import datetime, time from decimal import Decimal from typing import List, Union +import pytz from django.conf import settings from django.db import models from django.db.models import F, Sum @@ -16,12 +17,14 @@ from django.utils.encoding import escape_uri_path from django.utils.functional import cached_property from django.utils.html import escape from django.utils.safestring import mark_safe -from django.utils.timezone import now -from django.utils.translation import ugettext_lazy as _ +from django.utils.timezone import make_aware, now +from django.utils.translation import pgettext_lazy, ugettext_lazy as _ + +from pretix.base.reldate import RelativeDateWrapper from ..decimal import round_decimal from .base import LoggedModel -from .event import Event +from .event import Event, SubEvent from .items import Item, ItemVariation, Question, QuestionOption, Quota @@ -267,7 +270,16 @@ class Order(LoggedModel): """ if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED): return False - modify_deadline = self.event.settings.get('last_order_modification_date', as_type=datetime) + + modify_deadline = self.event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper) + if self.event.has_subevents and modify_deadline: + modify_deadline = min([ + modify_deadline.datetime(se) + for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True)) + ]) + elif modify_deadline: + modify_deadline = modify_deadline.datetime(self.event) + if modify_deadline is not None and now() > modify_deadline: return False if self.event.settings.get('invoice_address_asked', as_type=bool): @@ -292,6 +304,37 @@ class Order(LoggedModel): and not self.event.settings.get('payment_term_expire_automatically') ) + @property + def ticket_download_date(self): + dl_date = self.event.settings.get('ticket_download_date', as_type=RelativeDateWrapper) + if dl_date: + if self.event.has_subevents: + dl_date = min([ + dl_date.datetime(se) + for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True)) + ]) + else: + dl_date = dl_date.datetime(self.event) + return dl_date + + @property + def payment_term_last(self): + tz = pytz.timezone(self.event.settings.timezone) + term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) + if term_last: + if self.event.has_subevents: + term_last = min([ + term_last.datetime(se).date() + for se in self.event.subevents.filter(id__in=self.positions.values_list('subevent', flat=True)) + ]) + else: + term_last = term_last.datetime(self.event).date() + term_last = make_aware(datetime.combine( + term_last, + time(hour=23, minute=59, second=59) + ), tz) + return term_last + def _can_be_paid(self) -> Union[bool, str]: error_messages = { 'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the " @@ -299,9 +342,9 @@ class Order(LoggedModel): 'late': _("The payment can not be accepted as it the order is expired and you configured that no late " "payments should be accepted in the payment settings."), } - - if self.event.settings.get('payment_term_last'): - if now() > self.event.payment_term_last: + term_last = self.payment_term_last + if term_last: + if now() > term_last: return error_messages['late_lastdate'] if self.status == self.STATUS_PENDING: @@ -320,9 +363,11 @@ class Order(LoggedModel): quota_cache = {} try: for i, op in enumerate(positions): - quotas = list(op.item.quotas.all()) if op.variation is None else list(op.variation.quotas.all()) + quotas = list(op.quotas) if len(quotas) == 0: - raise Quota.QuotaExceededException(error_messages['unavailable']) + raise Quota.QuotaExceededException(error_messages['unavailable'].format( + item=str(op.item) + (' - ' + str(op.variation) if op.variation else '') + )) for quota in quotas: if quota.id not in quota_cache: @@ -430,6 +475,8 @@ class AbstractPosition(models.Model): """ A position can either be one line of an order or an item placed in a cart. + :param subevent: The date in the event series, if event series are enabled + :type subevent: SubEvent :param item: The selected item :type item: Item :param variation: The selected ItemVariation or null, if the item has no variations @@ -449,6 +496,12 @@ class AbstractPosition(models.Model): :param meta_info: Additional meta information on the position, JSON-encoded. :type meta_info: str """ + subevent = models.ForeignKey( + SubEvent, + null=True, blank=True, + on_delete=models.CASCADE, + verbose_name=pgettext_lazy("subevent", "Date"), + ) item = models.ForeignKey( Item, verbose_name=_("Item"), @@ -520,6 +573,12 @@ class AbstractPosition(models.Model): def net_price(self): return self.price - self.tax_value + @property + def quotas(self): + return (self.item.quotas.filter(subevent=self.subevent) + if self.variation is None + else self.variation.quotas.filter(subevent=self.subevent)) + class OrderPosition(AbstractPosition): """ diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py index 223271419..1b5855aff 100644 --- a/src/pretix/base/models/vouchers.py +++ b/src/pretix/base/models/vouchers.py @@ -5,11 +5,11 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils.crypto import get_random_string from django.utils.timezone import now -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from ..decimal import round_decimal from .base import LoggedModel -from .event import Event +from .event import Event, SubEvent from .items import Item, ItemVariation, Quota @@ -33,6 +33,8 @@ class Voucher(LoggedModel): :param event: The event this voucher is valid for :type event: Event + :param subevent: The date in the event series, if event series are enabled + :type subevent: SubEvent :param code: The secret voucher code :type code: str :param max_usages: The number of times this voucher can be redeemed @@ -80,6 +82,12 @@ class Voucher(LoggedModel): related_name="vouchers", verbose_name=_("Event"), ) + subevent = models.ForeignKey( + SubEvent, + null=True, blank=True, + on_delete=models.CASCADE, + verbose_name=pgettext_lazy("subevent", "Date"), + ) code = models.CharField( verbose_name=_("Voucher code"), max_length=255, default=generate_code, @@ -186,6 +194,8 @@ class Voucher(LoggedModel): 'Otherwise it might be unclear which quotas to block.')) else: raise ValidationError(_('You need to specify either a quota or a product.')) + if self.event.has_subevents and self.block_quota and not self.subevent: + raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.')) def save(self, *args, **kwargs): self.code = self.code.upper() diff --git a/src/pretix/base/models/waitinglist.py b/src/pretix/base/models/waitinglist.py index 6f25447f9..29fb636ae 100644 --- a/src/pretix/base/models/waitinglist.py +++ b/src/pretix/base/models/waitinglist.py @@ -3,7 +3,7 @@ from datetime import timedelta from django.core.exceptions import ValidationError from django.db import models, transaction from django.utils.timezone import now -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from pretix.base.i18n import language from pretix.base.models import Voucher @@ -11,7 +11,7 @@ from pretix.base.services.mail import mail from pretix.multidomain.urlreverse import build_absolute_uri from .base import LoggedModel -from .event import Event +from .event import Event, SubEvent from .items import Item, ItemVariation @@ -26,6 +26,12 @@ class WaitingListEntry(LoggedModel): related_name="waitinglistentries", verbose_name=_("Event"), ) + subevent = models.ForeignKey( + SubEvent, + null=True, blank=True, + on_delete=models.CASCADE, + verbose_name=pgettext_lazy("subevent", "Date"), + ) created = models.DateTimeField( verbose_name=_("On waiting list since"), auto_now_add=True @@ -77,9 +83,9 @@ class WaitingListEntry(LoggedModel): def send_voucher(self, quota_cache=None, user=None): availability = ( - self.variation.check_quotas(count_waitinglist=False, _cache=quota_cache) + self.variation.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache) if self.variation - else self.item.check_quotas(count_waitinglist=False, _cache=quota_cache) + else self.item.check_quotas(count_waitinglist=False, subevent=self.subevent, _cache=quota_cache) ) if availability[1] < 1: raise WaitingListException(_('This product is currently not available.')) @@ -98,6 +104,7 @@ class WaitingListEntry(LoggedModel): email=self.email ), block_quota=True, + subevent=self.subevent, ) v.log_action('pretix.voucher.added.waitinglist', { 'item': self.item.pk, @@ -107,7 +114,8 @@ class WaitingListEntry(LoggedModel): 'valid_until': v.valid_until.isoformat(), 'max_usages': 1, 'email': self.email, - 'waitinglistentry': self.pk + 'waitinglistentry': self.pk, + 'subevent': self.subevent.pk if self.subevent else None, }, user=user) self.log_action('pretix.waitinglist.voucher', user=user) self.voucher = v diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index d236f55e5..2b32f794d 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -1,5 +1,5 @@ +import logging from collections import OrderedDict -from datetime import date from decimal import Decimal from typing import Any, Dict, Union @@ -16,11 +16,14 @@ from i18nfield.forms import I18nFormField, I18nTextarea from i18nfield.strings import LazyI18nString from pretix.base.decimal import round_decimal -from pretix.base.models import Event, Order, Quota +from pretix.base.models import CartPosition, Event, Order, Quota +from pretix.base.reldate import RelativeDateField, RelativeDateWrapper from pretix.base.settings import SettingsSandbox from pretix.base.signals import register_payment_providers from pretix.presale.views import get_cart_total +logger = logging.getLogger(__name__) + class PaymentProviderForm(Form): def clean(self): @@ -150,11 +153,10 @@ class BasePaymentProvider: required=False )), ('_availability_date', - forms.DateField( + RelativeDateField( label=_('Available until'), help_text=_('Users will not be able to choose this payment provider after the given date.'), required=False, - widget=forms.DateInput(attrs={'class': 'datepickerfield'}) )), ('_fee_reverse_calc', forms.BooleanField( @@ -230,12 +232,36 @@ class BasePaymentProvider: return form - def _is_still_available(self, now_dt=None): + def _is_still_available(self, now_dt=None, cart_id=None, order=None): now_dt = now_dt or now() tz = pytz.timezone(self.event.settings.timezone) - availability_date = self.settings.get('_availability_date', as_type=date) + + availability_date = self.settings.get('_availability_date', as_type=RelativeDateWrapper) if availability_date: + if self.event.has_subevents and cart_id: + availability_date = min([ + availability_date.datetime(se).date() + for se in self.event.subevents.filter( + id__in=CartPosition.objects.filter( + cart_id=cart_id, event=self.event + ).values_list('subevent', flat=True) + ) + ]) + elif self.event.has_subevents and order: + availability_date = min([ + availability_date.datetime(se).date() + for se in self.event.subevents.filter( + id__in=order.positions.values_list('subevent', flat=True) + ) + ]) + elif self.event.has_subevents: + logger.error('Payment provider is not subevent-ready.') + return False + else: + availability_date = availability_date.datetime(self.event).date() + return availability_date >= now_dt.astimezone(tz).date() + return True def is_allowed(self, request: HttpRequest) -> bool: @@ -247,7 +273,7 @@ class BasePaymentProvider: The default implementation checks for the _availability_date setting to be either unset or in the future. """ - return self._is_still_available() + return self._is_still_available(cart_id=request.session.session_key) def payment_form_render(self, request: HttpRequest) -> str: """ @@ -385,7 +411,7 @@ class BasePaymentProvider: :param order: The order object """ - return self._is_still_available() + return self._is_still_available(order=order) def order_can_retry(self, order: Order) -> bool: """ diff --git a/src/pretix/base/reldate.py b/src/pretix/base/reldate.py new file mode 100644 index 000000000..a19ed7c68 --- /dev/null +++ b/src/pretix/base/reldate.py @@ -0,0 +1,254 @@ +import datetime +from collections import namedtuple +from typing import Union + +import pytz +from dateutil import parser +from django import forms +from django.core.exceptions import ValidationError +from django.utils.translation import ugettext_lazy as _ + +BASE_CHOICES = ( + ('date_from', _('Event start')), + ('date_to', _('Event end')), + ('date_admission', _('Event admission')), + ('presale_start', _('Presale start')), + ('presale_end', _('Presale end')), +) + +RelativeDate = namedtuple('RelativeDate', ['days_before', 'time', 'base_date_name']) + + +class RelativeDateWrapper: + """ + This contains information on a date that might be relative to an event. This means + that the underlying data is either a fixed date or a number of days and a wall clock + time to calculate the date based on a base point. + + The base point can be the date_from, date_to, date_admission, presale_start or presale_end + attribute of an event or subevent. If the respective attribute is not set, ``date_from`` + will be used. + """ + + def __init__(self, data: Union[datetime.datetime, RelativeDate]): + self.data = data + + def datetime(self, event) -> datetime.datetime: + from .models import SubEvent + + if isinstance(self.data, (datetime.datetime, datetime.date)): + return self.data + else: + tz = pytz.timezone(event.settings.timezone) + if isinstance(event, SubEvent): + base_date = ( + getattr(event, self.data.base_date_name) + or getattr(event.event, self.data.base_date_name) + or event.date_from + ) + else: + base_date = getattr(event, self.data.base_date_name) or event.date_from + + new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before) + if self.data.time: + new_date = new_date.replace( + hour=self.data.time.hour, + minute=self.data.time.minute, + second=self.data.time.second + ) + return new_date + + def to_string(self) -> str: + if isinstance(self.data, (datetime.datetime, datetime.date)): + return self.data.isoformat() + else: + return 'RELDATE/{}/{}/{}/'.format( # + self.data.days_before, + self.data.time.strftime('%H:%M:%S') if self.data.time else '-', + self.data.base_date_name + ) + + @classmethod + def from_string(cls, input: str): + if input.startswith('RELDATE/'): + parts = input.split('/') + if parts[2] == '-': + time = None + else: + timeparts = parts[2].split(':') + time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2])) + data = RelativeDate( + days_before=int(parts[1]), + base_date_name=parts[3], + time=time + ) + else: + data = parser.parse(input) + return RelativeDateWrapper(data) + + +class RelativeDateTimeWidget(forms.MultiWidget): + template_name = 'pretixbase/forms/widgets/reldatetime.html' + + def __init__(self, *args, **kwargs): + self.status_choices = kwargs.pop('status_choices') + widgets = ( + forms.RadioSelect(choices=self.status_choices), + forms.DateTimeInput( + attrs={'class': 'datetimepicker'} + ), + forms.NumberInput(), + forms.Select(choices=kwargs.pop('base_choices')), + forms.TimeInput(attrs={'placeholder': _('Time'), 'class': 'timepickerfield'}) + ) + super().__init__(widgets=widgets, *args, **kwargs) + + def decompress(self, value): + if not value: + return ['unset', None, 1, 'date_from', None] + elif isinstance(value.data, (datetime.datetime, datetime.date)): + return ['absolute', value.data, 1, 'date_from', None] + return ['relative', None, value.data.days_before, value.data.base_date_name, value.data.time] + + def get_context(self, name, value, attrs): + ctx = super().get_context(name, value, attrs) + ctx['required'] = self.status_choices[0][0] == 'unset' + return ctx + + +class RelativeDateTimeField(forms.MultiValueField): + def __init__(self, *args, **kwargs): + status_choices = [ + ('absolute', _('Fixed date:')), + ('relative', _('Relative date:')), + ] + if not kwargs.get('required', True): + status_choices.insert(0, ('unset', _('Not set'))) + fields = ( + forms.ChoiceField( + choices=status_choices, + required=True + ), + forms.DateTimeField( + required=False + ), + forms.IntegerField( + required=False + ), + forms.ChoiceField( + choices=BASE_CHOICES, + required=False + ), + forms.TimeField( + required=False, + ), + ) + if 'widget' not in kwargs: + kwargs['widget'] = RelativeDateTimeWidget(status_choices=status_choices, base_choices=BASE_CHOICES) + super().__init__( + fields=fields, require_all_fields=False, *args, **kwargs + ) + + def set_event(self, event): + self.widget.widgets[3].choices = [ + (k, v) for k, v in BASE_CHOICES if getattr(event, k, None) + ] + + def compress(self, data_list): + if not data_list: + return None + if data_list[0] == 'absolute': + return RelativeDateWrapper(data_list[1]) + elif data_list[0] == 'unset': + return None + else: + return RelativeDateWrapper(RelativeDate( + days_before=data_list[2], + base_date_name=data_list[3], + time=data_list[4] + )) + + def clean(self, value): + if value[0] == 'absolute' and not value[1]: + raise ValidationError(self.error_messages['incomplete']) + elif value[0] == 'relative' and (value[2] is None or not value[3]): + raise ValidationError(self.error_messages['incomplete']) + + return super().clean(value) + + +class RelativeDateWidget(RelativeDateTimeWidget): + template_name = 'pretixbase/forms/widgets/reldate.html' + + def __init__(self, *args, **kwargs): + self.status_choices = kwargs.pop('status_choices') + widgets = ( + forms.RadioSelect(choices=self.status_choices), + forms.DateInput( + attrs={'class': 'datepickerfield'} + ), + forms.NumberInput(), + forms.Select(choices=kwargs.pop('base_choices')), + ) + forms.MultiWidget.__init__(self, widgets=widgets, *args, **kwargs) + + def decompress(self, value): + if not value: + return ['unset', None, 1, 'date_from'] + elif isinstance(value.data, (datetime.datetime, datetime.date)): + return ['absolute', value.data, 1, 'date_from'] + return ['relative', None, value.data.days_before, value.data.base_date_name] + + +class RelativeDateField(RelativeDateTimeField): + + def __init__(self, *args, **kwargs): + status_choices = [ + ('absolute', _('Fixed date:')), + ('relative', _('Relative date:')), + ] + if not kwargs.get('required', True): + status_choices.insert(0, ('unset', _('Not set'))) + fields = ( + forms.ChoiceField( + choices=status_choices, + required=True + ), + forms.DateField( + required=False + ), + forms.IntegerField( + required=False + ), + forms.ChoiceField( + choices=BASE_CHOICES, + required=False + ), + ) + if 'widget' not in kwargs: + kwargs['widget'] = RelativeDateWidget(status_choices=status_choices, base_choices=BASE_CHOICES) + forms.MultiValueField.__init__( + self, fields=fields, require_all_fields=False, *args, **kwargs + ) + + def compress(self, data_list): + if not data_list: + return None + if data_list[0] == 'absolute': + return RelativeDateWrapper(data_list[1]) + elif data_list[0] == 'unset': + return None + else: + return RelativeDateWrapper(RelativeDate( + days_before=data_list[2], + base_date_name=data_list[3], + time=None + )) + + def clean(self, value): + if value[0] == 'absolute' and not value[1]: + raise ValidationError(self.error_messages['incomplete']) + elif value[0] == 'relative' and (value[2] is None or not value[3]): + raise ValidationError(self.error_messages['incomplete']) + + return super().clean(value) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 03ff311de..99eac4aee 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -7,15 +7,16 @@ from celery.exceptions import MaxRetriesExceededError from django.db import transaction from django.db.models import Q from django.utils.timezone import now -from django.utils.translation import ugettext as _ +from django.utils.translation import pgettext_lazy, ugettext as _ -from pretix.base.decimal import round_decimal from pretix.base.i18n import LazyLocaleException, language from pretix.base.models import ( CartPosition, Event, Item, ItemVariation, Voucher, ) +from pretix.base.models.event import SubEvent from pretix.base.services.async import ProfiledTask from pretix.base.services.locking import LockTimeoutException +from pretix.base.services.pricing import get_price from pretix.celery_app import app @@ -28,6 +29,7 @@ error_messages = { 'server was too busy. Please try again.'), 'empty': _('You did not select any products.'), 'unknown_position': _('Unknown cart position.'), + 'subevent_required': pgettext_lazy('subevent', 'No date was specified.'), 'not_for_sale': _('You selected a product which is not available for sale.'), 'unavailable': _('Some of the products you selected are no longer available. ' 'Please see below for details.'), @@ -39,7 +41,11 @@ error_messages = { 'min_items_per_product_removed': _("We removed %(product)s from your cart as you can not buy less than " "%(min)s items of it."), 'not_started': _('The presale period for this event has not yet started.'), - 'ended': _('The presale period has ended.'), + 'ended': _('The presale period for this event has ended.'), + 'some_subevent_not_started': _('The presale period for this event has not yet started. The affected positions ' + 'have been removed from your cart.'), + 'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected ' + 'positions have been removed from your cart.'), 'price_too_high': _('The entered price is to high.'), 'voucher_invalid': _('This voucher code is not known in our database.'), 'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'), @@ -48,7 +54,9 @@ error_messages = { 'cart if you want to use it for a different product.'), 'voucher_expired': _('This voucher is expired.'), 'voucher_invalid_item': _('This voucher is not valid for this product.'), + 'voucher_invalid_subevent': pgettext_lazy('subevent', 'This voucher is not valid for this event date.'), 'voucher_required': _('You need a valid voucher code to order this product.'), + 'inactive_subevent': pgettext_lazy('subevent', 'The selected event date is not active.'), 'addon_invalid_base': _('You can not select an add-on for the selected product.'), 'addon_duplicate_item': _('You can not select two variations of the same add-on product.'), 'addon_max_count': _('You can select at most %(max)s add-ons from the category %(cat)s for the product %(base)s.'), @@ -60,10 +68,10 @@ error_messages = { class CartManager: AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas', - 'addon_to')) + 'addon_to', 'subevent')) RemoveOperation = namedtuple('RemoveOperation', ('position',)) ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher', - 'quotas')) + 'quotas', 'subevent')) order = { RemoveOperation: 10, ExtendOperation: 20, @@ -78,6 +86,7 @@ class CartManager: self._quota_diff = Counter() self._voucher_use_diff = Counter() self._items_cache = {} + self._subevents_cache = {} self._variations_cache = {} self._expiry = None @@ -85,7 +94,7 @@ class CartManager: def positions(self): return CartPosition.objects.filter( Q(cart_id=self.cart_id) & Q(event=self.event) - ).select_related('item') + ).select_related('item', 'subevent') def _calculate_expiry(self): self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int)) @@ -101,31 +110,41 @@ class CartManager: # We can extend the reservation of items which are not yet expired without risk self.positions.filter(expires__gt=self.now_dt).update(expires=self._expiry) - def _delete_expired(self, expired: List[CartPosition]): - for cp in expired: - if cp.expires <= self.now_dt: + def _delete_out_of_timeframe(self): + err = None + for cp in self.positions: + if cp.subevent and cp.subevent.presale_start and self.now_dt < cp.subevent.presale_start: + err = error_messages['some_subevent_not_started'] cp.delete() + if cp.subevent and cp.subevent.presale_end and self.now_dt > cp.subevent.presale_end: + err = error_messages['some_subevent_ended'] + cp.delete() + return err + + def _update_subevents_cache(self, se_ids: List[int]): + self._subevents_cache.update({ + i.pk: i + for i in self.event.subevents.filter(id__in=[i for i in se_ids if i and i not in self._items_cache]) + }) + def _update_items_cache(self, item_ids: List[int], variation_ids: List[int]): - self._items_cache.update( - { - i.pk: i - for i - in self.event.items.select_related('category').prefetch_related( - 'addons', 'addons__addon_category', 'quotas' - ).filter( - id__in=[i for i in item_ids if i and i not in self._items_cache] - ) - } - ) - self._variations_cache.update( - {v.pk: v for v in - ItemVariation.objects.filter(item__event=self.event).prefetch_related( - 'quotas' - ).select_related('item', 'item__event').filter( - id__in=[i for i in variation_ids if i and i not in self._variations_cache] - )} - ) + self._items_cache.update({ + i.pk: i + for i in self.event.items.select_related('category').prefetch_related( + 'addons', 'addons__addon_category', 'quotas' + ).filter( + id__in=[i for i in item_ids if i and i not in self._items_cache] + ) + }) + self._variations_cache.update({ + v.pk: v + for v in ItemVariation.objects.filter(item__event=self.event).prefetch_related( + 'quotas' + ).select_related('item', 'item__event').filter( + id__in=[i for i in variation_ids if i and i not in self._variations_cache] + ) + }) def _check_max_cart_size(self): cartsize = self.positions.filter(addon_to__isnull=True).count() @@ -150,6 +169,18 @@ class CartManager: if op.voucher and not op.voucher.applies_to(op.item, op.variation): raise CartError(error_messages['voucher_invalid_item']) + if op.voucher and op.voucher.subevent_id and op.voucher.subevent_id != op.subevent.pk: + raise CartError(error_messages['voucher_invalid_subevent']) + + if op.subevent and not op.subevent.active: + raise CartError(error_messages['inactive_subevent']) + + if op.subevent and op.subevent.presale_start and self.now_dt < op.subevent.presale_start: + raise CartError(error_messages['not_started']) + + if op.subevent and op.subevent.presale_end and self.now_dt > op.subevent.presale_end: + raise CartError(error_messages['ended']) + if isinstance(op, self.AddOperation): if op.item.category and op.item.category.is_addon and not op.addon_to: raise CartError(error_messages['addon_only']) @@ -181,34 +212,24 @@ class CartManager: ) def _get_price(self, item: Item, variation: Optional[ItemVariation], - voucher: Optional[Voucher], custom_price: Optional[Decimal]): - price = item.default_price if variation is None else ( - variation.default_price if variation.default_price is not None else item.default_price - ) - if voucher: - price = voucher.calculate_price(price) - - if item.free_price and custom_price is not None and custom_price != "": - if not isinstance(custom_price, Decimal): - custom_price = Decimal(custom_price.replace(",", ".")) - if custom_price > 100000000: - raise CartError(error_messages['price_too_high']) - if self.event.settings.display_net_prices: - custom_price = round_decimal(custom_price * (100 + item.tax_rate) / 100) - price = max(custom_price, price) - - return price + voucher: Optional[Voucher], custom_price: Optional[Decimal], + subevent: Optional[SubEvent]): + return get_price(item, variation, voucher, custom_price, subevent, self.event.settings.display_net_prices) def extend_expired_positions(self): expired = self.positions.filter(expires__lte=self.now_dt).select_related( 'item', 'variation', 'voucher' ).prefetch_related('item__quotas', 'variation__quotas') + err = None for cp in expired: - price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price) + price = self._get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent) - quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all()) + quotas = list(cp.quotas) if not quotas: - raise CartError(error_messages['unavailable']) + self._operations.append(self.RemoveOperation(position=cp)) + continue + err = error_messages['unavailable'] + if not cp.voucher or (not cp.voucher.allow_ignore_quota and not cp.voucher.block_quota): for quota in quotas: self._quota_diff[quota] += 1 @@ -217,7 +238,7 @@ class CartManager: op = self.ExtendOperation( position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1, - price=price, quotas=quotas + price=price, quotas=quotas, subevent=cp.subevent ) self._check_item_constraints(op) @@ -225,10 +246,12 @@ class CartManager: self._voucher_use_diff[cp.voucher] += 1 self._operations.append(op) + return err def add_new_items(self, items: List[dict]): # Fetch items from the database self._update_items_cache([i['item'] for i in items], [i['variation'] for i in items]) + self._update_subevents_cache([i['subevent'] for i in items if i.get('subevent')]) quota_diff = Counter() voucher_use_diff = Counter() operations = [] @@ -240,6 +263,13 @@ class CartManager: if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache): raise CartError(error_messages['not_for_sale']) + if self.event.has_subevents: + if not i.get('subevent'): + raise CartError(error_messages['subevent_required']) + subevent = self._subevents_cache[int(i.get('subevent'))] + else: + subevent = None + item = self._items_cache[i['item']] variation = self._variations_cache[i['variation']] if i['variation'] is not None else None voucher = None @@ -253,8 +283,8 @@ class CartManager: voucher_use_diff[voucher] += i['count'] # Fetch all quotas. If there are no quotas, this item is not allowed to be sold. - - quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all()) + quotas = list(item.quotas.filter(subevent=subevent) + if variation is None else variation.quotas.filter(subevent=subevent)) if not quotas: raise CartError(error_messages['unavailable']) if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota): @@ -263,10 +293,10 @@ class CartManager: else: quotas = [] - price = self._get_price(item, variation, voucher, i.get('price')) + price = self._get_price(item, variation, voucher, i.get('price'), subevent) op = self.AddOperation( count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas, - addon_to=False + addon_to=False, subevent=subevent ) self._check_item_constraints(op) operations.append(op) @@ -345,7 +375,8 @@ class CartManager: raise CartError(error_messages['addon_invalid_base']) # Fetch all quotas. If there are no quotas, this item is not allowed to be sold. - quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all()) + quotas = list(item.quotas.filter(subevent=cp.subevent) + if variation is None else variation.quotas.filter(subevent=cp.subevent)) if not quotas: raise CartError(error_messages['unavailable']) @@ -361,11 +392,11 @@ class CartManager: for quota in quotas: quota_diff[quota] += 1 - price = self._get_price(item, variation, None, None) + price = self._get_price(item, variation, None, None, cp.subevent) op = self.AddOperation( count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas, - addon_to=cp + addon_to=cp, subevent=cp.subevent ) self._check_item_constraints(op) operations.append(op) @@ -403,7 +434,7 @@ class CartManager: for k, v in al.items(): if k not in input_addons[cp.id]: if v.expires > self.now_dt: - quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all()) + quotas = list(cp.quotas) for quota in quotas: quota_diff[quota] -= 1 @@ -523,7 +554,8 @@ class CartManager: event=self.event, item=op.item, variation=op.variation, price=op.price, expires=self._expiry, cart_id=self.cart_id, voucher=op.voucher, - addon_to=op.addon_to if op.addon_to else None + addon_to=op.addon_to if op.addon_to else None, + subevent=op.subevent )) elif isinstance(op, self.ExtendOperation): if available_count == 1: @@ -547,8 +579,9 @@ class CartManager: with transaction.atomic(): self.now_dt = now_dt self._extend_expiry_of_valid_existing_positions() - self.extend_expired_positions() - err = self._perform_operations() + err = self._delete_out_of_timeframe() + err = self.extend_expired_positions() or err + err = self._perform_operations() or err if err: raise CartError(err) @@ -559,8 +592,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo Adds a list of items to a user's cart. :param event: The event ID in question :param items: A list of dicts with the keys item, variation, number, custom_price, voucher - :param session: Session ID of a guest - :param coupon: A coupon that should also be reeemed + :param cart_id: Session ID of a guest :raises CartError: On any error that occured """ with language(locale): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 3f9db5a7c..2b147e26e 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -22,14 +22,17 @@ from pretix.base.models import ( CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota, User, Voucher, ) +from pretix.base.models.event import SubEvent from pretix.base.models.orders import CachedTicket, InvoiceAddress from pretix.base.payment import BasePaymentProvider +from pretix.base.reldate import RelativeDateWrapper from pretix.base.services.async import ProfiledTask from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_qualified, ) from pretix.base.services.locking import LockTimeoutException from pretix.base.services.mail import SendMailException, mail +from pretix.base.services.pricing import get_price from pretix.base.signals import order_paid, order_placed, periodic_task from pretix.celery_app import app from pretix.multidomain.urlreverse import build_absolute_uri @@ -58,6 +61,10 @@ error_messages = { 'removed this item from your cart.'), 'voucher_required': _('You need a valid voucher code to order one of the products in your cart. We removed this ' 'item from your cart.'), + 'some_subevent_not_started': _('The presale period for one of the events in your cart has not yet started. The ' + 'affected positions have been removed from your cart.'), + 'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected ' + 'positions have been removed from your cart.'), } logger = logging.getLogger(__name__) @@ -230,7 +237,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio err = err or error_messages['unavailable'] cp.delete() continue - quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all()) + quotas = list(cp.quotas) products_seen[cp.item] += 1 if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order: @@ -250,9 +257,19 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio cp.delete() # Sorry! continue + if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start: + err = err or error_messages['some_subevent_not_started'] + cp.delete() + break + + if cp.subevent and cp.subevent.presale_end and now_dt > cp.subevent.presale_end: + err = err or error_messages['some_subevent_ended'] + cp.delete() + break + if cp.item.require_voucher and cp.voucher is None: cp.delete() - err = error_messages['voucher_required'] + err = err or error_messages['voucher_required'] break if cp.item.hide_without_voucher and (cp.voucher is None or cp.voucher.item is None @@ -265,8 +282,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio # Other checks are not necessary continue - price = cp.item.default_price if cp.variation is None else ( - cp.variation.default_price if cp.variation.default_price is not None else cp.item.default_price) + price = get_price(cp.item, cp.variation, cp.voucher, cp.price, cp.subevent, custom_price_is_net=False) if price is False or len(quotas) == 0: err = err or error_messages['unavailable'] @@ -278,7 +294,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio err = err or error_messages['voucher_expired'] cp.delete() continue - price = cp.voucher.calculate_price(price) if price != cp.price and not (cp.item.free_price and cp.price > price): positions[i] = cp @@ -317,7 +332,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, payment_provider: BasePaymentProvider, locale: str=None, address: int=None, meta_info: dict=None): - from datetime import date, time + from datetime import time total = sum([c.price for c in positions]) payment_fee = payment_provider.calculate_fee(total) @@ -334,13 +349,21 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d expires = exp_by_date - if event.settings.get('payment_term_last'): - last_date = make_aware(datetime.combine( - event.settings.get('payment_term_last', as_type=date), + term_last = event.settings.get('payment_term_last', as_type=RelativeDateWrapper) + if term_last: + if event.has_subevents: + term_last = min([ + term_last.datetime(se).date() + for se in event.subevents.filter(id__in=[p.subevent_id for p in positions]) + ]) + else: + term_last = term_last.datetime(event).date() + term_last = make_aware(datetime.combine( + term_last, time(hour=23, minute=59, second=59) ), tz) - if last_date < expires: - expires = last_date + if term_last < expires: + expires = term_last with transaction.atomic(): order = Order.objects.create( @@ -385,7 +408,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str], with event.lock() as now_dt: positions = list(CartPosition.objects.filter( - id__in=position_ids).select_related('item', 'variation')) + id__in=position_ids).select_related('item', 'variation', 'subevent')) if len(positions) == 0: raise OrderError(error_messages['empty']) if len(position_ids) != len(positions): @@ -497,6 +520,7 @@ class OrderChangeManager: 'free_to_paid': _('You cannot change a free order to a paid order.'), 'product_without_variation': _('You need to select a variation of the product.'), 'quota': _('The quota {name} does not have enough capacity left to perform the operation.'), + 'quota_missing': _('There is no quota defined that allows this operation.'), 'product_invalid': _('The selected product is not active or has no price set.'), 'complete_cancel': _('This operation would leave the order empty. Please cancel the order itself instead.'), 'not_pending_or_paid': _('Only pending or paid orders can be changed.'), @@ -506,11 +530,13 @@ class OrderChangeManager: 'price of the order as partial payments or refunds are not yet supported.'), 'addon_to_required': _('This is an addon product, please select the base position it should be added to.'), 'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'), + 'subevent_required': _('You need to choose a subevent for the new position.'), } ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation', 'price')) + SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent', 'price')) PriceOperation = namedtuple('PriceOperation', ('position', 'price')) CancelOperation = namedtuple('CancelOperation', ('position',)) - AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to')) + AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent')) def __init__(self, order: Order, user): self.order = order @@ -522,26 +548,51 @@ class OrderChangeManager: def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]): if (not variation and item.has_variations) or (variation and variation.item_id != item.pk): raise OrderError(self.error_messages['product_without_variation']) - price = item.default_price if variation is None else variation.price - if price is None: + + price = get_price(item, variation, voucher=position.voucher, subevent=position.subevent) + + if price is None: # NOQA raise OrderError(self.error_messages['product_invalid']) + + new_quotas = (variation.quotas.filter(subevent=position.subevent) + if variation else item.quotas.filter(subevent=position.subevent)) + if not new_quotas: + raise OrderError(self.error_messages['quota_missing']) + self._totaldiff = price - position.price - self._quotadiff.update(variation.quotas.all() if variation else item.quotas.all()) - self._quotadiff.subtract(position.variation.quotas.all() if position.variation else position.item.quotas.all()) + self._quotadiff.update(new_quotas) + self._quotadiff.subtract(position.quotas) self._operations.append(self.ItemOperation(position, item, variation, price)) + def change_subevent(self, position: OrderPosition, subevent: SubEvent): + price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent) + + if price is None: # NOQA + raise OrderError(self.error_messages['product_invalid']) + + new_quotas = (position.variation.quotas.filter(subevent=subevent) + if position.variation else position.item.quotas.filter(subevent=subevent)) + if not new_quotas: + raise OrderError(self.error_messages['quota_missing']) + + self._totaldiff = price - position.price + self._quotadiff.update(new_quotas) + self._quotadiff.subtract(position.quotas) + self._operations.append(self.SubeventOperation(position, subevent, price)) + def change_price(self, position: OrderPosition, price: Decimal): self._totaldiff = price - position.price self._operations.append(self.PriceOperation(position, price)) def cancel(self, position: OrderPosition): self._totaldiff = -position.price - self._quotadiff.subtract(position.variation.quotas.all() if position.variation else position.item.quotas.all()) + self._quotadiff.subtract(position.quotas) self._operations.append(self.CancelOperation(position)) - def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order): + def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None, + subevent: SubEvent = None): if price is None: - price = item.default_price if variation is None else variation.price + price = get_price(item, variation, subevent=subevent) if price is None: raise OrderError(self.error_messages['product_invalid']) if not addon_to and item.category and item.category.is_addon: @@ -549,10 +600,17 @@ class OrderChangeManager: if addon_to: if not item.category or item.category_id not in addon_to.item.addons.values_list('addon_category', flat=True): raise OrderError(self.error_messages['addon_invalid']) + if self.order.event.has_subevents and not subevent: + raise OrderError(self.error_messages['subevent_required']) + + new_quotas = (variation.quotas.filter(subevent=subevent) + if variation else item.quotas.filter(subevent=subevent)) + if not new_quotas: + raise OrderError(self.error_messages['quota_missing']) self._totaldiff = price - self._quotadiff.update(variation.quotas.all() if variation else item.quotas.all()) - self._operations.append(self.AddOperation(item, variation, price, addon_to)) + self._quotadiff.update(new_quotas) + self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent)) def _check_quotas(self): for quota, diff in self._quotadiff.items(): @@ -597,6 +655,19 @@ class OrderChangeManager: op.position.price = op.price op.position._calculate_tax() op.position.save() + elif isinstance(op, self.SubeventOperation): + self.order.log_action('pretix.event.order.changed.subevent', user=self.user, data={ + 'position': op.position.pk, + 'positionid': op.position.positionid, + 'old_subevent': op.position.subevent.pk, + 'new_subevent': op.subevent.pk, + 'old_price': op.position.price, + 'new_price': op.price + }) + op.position.subevent = op.subevent + op.position.price = op.price + op.position._calculate_tax() + op.position.save() elif isinstance(op, self.PriceOperation): self.order.log_action('pretix.event.order.changed.price', user=self.user, data={ 'position': op.position.pk, @@ -631,7 +702,7 @@ class OrderChangeManager: pos = OrderPosition.objects.create( item=op.item, variation=op.variation, addon_to=op.addon_to, price=op.price, order=self.order, - positionid=nextposid + positionid=nextposid, subevent=op.subevent ) nextposid += 1 self.order.log_action('pretix.event.order.changed.add', user=self.user, data={ @@ -640,7 +711,8 @@ class OrderChangeManager: 'variation': op.variation.pk if op.variation else None, 'addon_to': op.addon_to.pk if op.addon_to else None, 'price': op.price, - 'positionid': pos.positionid + 'positionid': pos.positionid, + 'subevent': op.subevent.pk if op.subevent else None, }) def _recalculate_total_and_payment_fee(self): diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py new file mode 100644 index 000000000..4fe02b6de --- /dev/null +++ b/src/pretix/base/services/pricing.py @@ -0,0 +1,33 @@ +from decimal import Decimal + +from pretix.base.decimal import round_decimal +from pretix.base.models import Item, ItemVariation, Voucher +from pretix.base.models.event import SubEvent + + +def get_price(item: Item, variation: ItemVariation = None, + voucher: Voucher = None, custom_price: Decimal = None, + subevent: SubEvent = None, custom_price_is_net: bool = False): + price = item.default_price + if subevent and item.pk in subevent.item_price_overrides: + price = subevent.item_price_overrides[item.pk] + + if variation is not None: + if variation.default_price is not None: + price = variation.default_price + if subevent and variation.pk in subevent.var_price_overrides: + price = subevent.var_price_overrides[variation.pk] + + if voucher: + price = voucher.calculate_price(price) + + if item.free_price and custom_price is not None and custom_price != "": + if not isinstance(custom_price, Decimal): + custom_price = Decimal(str(custom_price).replace(",", ".")) + if custom_price > 100000000: + raise ValueError('price_too_high') + if custom_price_is_net: + custom_price = round_decimal(custom_price * (100 + item.tax_rate) / 100) + price = max(custom_price, price) + + return price diff --git a/src/pretix/base/services/stats.py b/src/pretix/base/services/stats.py index 0b84414c8..11d8e8793 100644 --- a/src/pretix/base/services/stats.py +++ b/src/pretix/base/services/stats.py @@ -5,6 +5,7 @@ from django.db.models import Count, Sum from django.utils.translation import ugettext_lazy as _ from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition +from pretix.base.models.event import SubEvent class DummyObject: @@ -67,14 +68,18 @@ def dictsum(*dicts) -> dict: return res -def order_overview(event: Event) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]: +def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[ItemCategory, List[Item]]], + Dict[str, Tuple[Decimal, Decimal]]]: items = event.items.all().select_related( 'category', # for re-grouping ).prefetch_related( 'variations' ).order_by('category__position', 'category_id', 'name') - counters = OrderPosition.objects.filter( + qs = OrderPosition.objects + if subevent: + qs = qs.filter(subevent=subevent) + counters = qs.filter( order__event=event ).values( 'item', 'variation', 'order__status' @@ -155,71 +160,72 @@ def order_overview(event: Event) -> Tuple[List[Tuple[ItemCategory, List[Item]]], payment_cat_obj.name = _('Payment method fees') payment_items = [] - counters = event.orders.values('payment_provider', 'status').annotate( - cnt=Count('id'), payment_fee=Sum('payment_fee'), tax_value=Sum('payment_fee_tax_value') - ).order_by() + if not subevent: + counters = event.orders.values('payment_provider', 'status').annotate( + cnt=Count('id'), payment_fee=Sum('payment_fee'), tax_value=Sum('payment_fee_tax_value') + ).order_by() - num_canceled = { - o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) - for o in counters if o['status'] == Order.STATUS_CANCELED - } - num_refunded = { - o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) - for o in counters if o['status'] == Order.STATUS_REFUNDED - } - num_pending = { - o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) - for o in counters if o['status'] == Order.STATUS_PENDING - } - num_expired = { - o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) - for o in counters if o['status'] == Order.STATUS_EXPIRED - } - num_paid = { - o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) - for o in counters if o['status'] == Order.STATUS_PAID - } - num_total = dictsum(num_pending, num_paid) + num_canceled = { + o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) + for o in counters if o['status'] == Order.STATUS_CANCELED + } + num_refunded = { + o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) + for o in counters if o['status'] == Order.STATUS_REFUNDED + } + num_pending = { + o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) + for o in counters if o['status'] == Order.STATUS_PENDING + } + num_expired = { + o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) + for o in counters if o['status'] == Order.STATUS_EXPIRED + } + num_paid = { + o['payment_provider']: (o['cnt'], o['payment_fee'], o['payment_fee'] - o['tax_value']) + for o in counters if o['status'] == Order.STATUS_PAID + } + num_total = dictsum(num_pending, num_paid) - provider_names = { - k: v.verbose_name - for k, v in event.get_payment_providers().items() - } + provider_names = { + k: v.verbose_name + for k, v in event.get_payment_providers().items() + } - for pprov, total in num_total.items(): - ppobj = DummyObject() - ppobj.name = provider_names.get(pprov, pprov) - ppobj.provider = pprov - ppobj.has_variations = False - ppobj.num_total = total - ppobj.num_canceled = num_canceled.get(pprov, (0, 0, 0)) - ppobj.num_refunded = num_refunded.get(pprov, (0, 0, 0)) - ppobj.num_expired = num_expired.get(pprov, (0, 0, 0)) - ppobj.num_pending = num_pending.get(pprov, (0, 0, 0)) - ppobj.num_paid = num_paid.get(pprov, (0, 0, 0)) - payment_items.append(ppobj) + for pprov, total in num_total.items(): + ppobj = DummyObject() + ppobj.name = provider_names.get(pprov, pprov) + ppobj.provider = pprov + ppobj.has_variations = False + ppobj.num_total = total + ppobj.num_canceled = num_canceled.get(pprov, (0, 0, 0)) + ppobj.num_refunded = num_refunded.get(pprov, (0, 0, 0)) + ppobj.num_expired = num_expired.get(pprov, (0, 0, 0)) + ppobj.num_pending = num_pending.get(pprov, (0, 0, 0)) + ppobj.num_paid = num_paid.get(pprov, (0, 0, 0)) + payment_items.append(ppobj) - payment_cat_obj.num_total = ( - Dontsum(''), sum(i.num_total[1] for i in payment_items), sum(i.num_total[2] for i in payment_items) - ) - payment_cat_obj.num_canceled = ( - Dontsum(''), sum(i.num_canceled[1] for i in payment_items), sum(i.num_canceled[2] for i in payment_items) - ) - payment_cat_obj.num_refunded = ( - Dontsum(''), sum(i.num_refunded[1] for i in payment_items), sum(i.num_refunded[2] for i in payment_items) - ) - payment_cat_obj.num_expired = ( - Dontsum(''), sum(i.num_expired[1] for i in payment_items), sum(i.num_expired[2] for i in payment_items) - ) - payment_cat_obj.num_pending = ( - Dontsum(''), sum(i.num_pending[1] for i in payment_items), sum(i.num_pending[2] for i in payment_items) - ) - payment_cat_obj.num_paid = ( - Dontsum(''), sum(i.num_paid[1] for i in payment_items), sum(i.num_paid[2] for i in payment_items) - ) - payment_cat = (payment_cat_obj, payment_items) + payment_cat_obj.num_total = ( + Dontsum(''), sum(i.num_total[1] for i in payment_items), sum(i.num_total[2] for i in payment_items) + ) + payment_cat_obj.num_canceled = ( + Dontsum(''), sum(i.num_canceled[1] for i in payment_items), sum(i.num_canceled[2] for i in payment_items) + ) + payment_cat_obj.num_refunded = ( + Dontsum(''), sum(i.num_refunded[1] for i in payment_items), sum(i.num_refunded[2] for i in payment_items) + ) + payment_cat_obj.num_expired = ( + Dontsum(''), sum(i.num_expired[1] for i in payment_items), sum(i.num_expired[2] for i in payment_items) + ) + payment_cat_obj.num_pending = ( + Dontsum(''), sum(i.num_pending[1] for i in payment_items), sum(i.num_pending[2] for i in payment_items) + ) + payment_cat_obj.num_paid = ( + Dontsum(''), sum(i.num_paid[1] for i in payment_items), sum(i.num_paid[2] for i in payment_items) + ) + payment_cat = (payment_cat_obj, payment_items) - items_by_category.append(payment_cat) + items_by_category.append(payment_cat) total = { 'num_total': tuplesum(c.num_total for c, i in items_by_category), diff --git a/src/pretix/base/services/waitinglist.py b/src/pretix/base/services/waitinglist.py index 62e34ce7e..49ce65806 100644 --- a/src/pretix/base/services/waitinglist.py +++ b/src/pretix/base/services/waitinglist.py @@ -8,7 +8,7 @@ from pretix.celery_app import app @app.task(base=ProfiledTask) -def assign_automatically(event_id: int, user_id: int=None): +def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None): event = Event.objects.get(id=event_id) if user_id: user = User.objects.get(id=user_id) @@ -21,17 +21,24 @@ def assign_automatically(event_id: int, user_id: int=None): qs = WaitingListEntry.objects.filter( event=event, voucher__isnull=True ).select_related('item', 'variation').prefetch_related('item__quotas', 'variation__quotas').order_by('created') + + if subevent_id and event.has_subevents: + subevent = event.subevents.get(id=subevent_id) + qs = qs.filter(subevent=subevent) + sent = 0 for wle in qs: if (wle.item, wle.variation) in gone: continue - quotas = wle.variation.quotas.all() if wle.variation else wle.item.quotas.all() + quotas = (wle.variation.quotas.filter(subevent=wle.subevent) + if wle.variation + else wle.item.quotas.filter(subevent=wle.subevent)) availability = ( - wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache) + wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache, subevent=wle.subevent) if wle.variation - else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache) + else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache, subevent=wle.subevent) ) if availability[1] > 0: try: diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 9116547e0..3dd5e3d4a 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -10,6 +10,8 @@ from hierarkey.models import GlobalSettingsBase, Hierarkey from i18nfield.strings import LazyI18nString from typing import Any +from pretix.base.reldate import RelativeDateWrapper + DEFAULTS = { 'max_items_per_order': { 'default': '10', @@ -69,7 +71,7 @@ DEFAULTS = { }, 'payment_term_last': { 'default': None, - 'type': datetime, + 'type': RelativeDateWrapper, }, 'payment_term_weekdays': { 'default': 'True', @@ -165,7 +167,7 @@ DEFAULTS = { }, 'ticket_download_date': { 'default': None, - 'type': datetime + 'type': RelativeDateWrapper }, 'ticket_download_addons': { 'default': 'False', @@ -181,7 +183,7 @@ DEFAULTS = { }, 'last_order_modification_date': { 'default': None, - 'type': datetime + 'type': RelativeDateWrapper }, 'cancel_allow_user': { 'default': 'True', @@ -437,6 +439,9 @@ def i18n_uns(v): settings_hierarkey.add_type(LazyI18nString, serialize=lambda s: json.dumps(s.data), unserialize=i18n_uns) +settings_hierarkey.add_type(RelativeDateWrapper, + serialize=lambda rdw: rdw.to_string(), + unserialize=lambda s: RelativeDateWrapper.from_string(s)) @settings_hierarkey.set_global(cache_namespace='global') diff --git a/src/pretix/base/templates/pretixbase/forms/widgets/reldate.html b/src/pretix/base/templates/pretixbase/forms/widgets/reldate.html new file mode 100644 index 000000000..99772f3a7 --- /dev/null +++ b/src/pretix/base/templates/pretixbase/forms/widgets/reldate.html @@ -0,0 +1,21 @@ +{% load i18n %} +