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 %} +
+ {% for group_name, group_choices, group-index in widget.subwidgets.0.optgroups %} + {% for selopt in group_choices %} +
+ + {% if selopt.value == "absolute" %} + {% include widget.subwidgets.1.template_name with widget=widget.subwidgets.1 %} + {% elif selopt.value == "relative" %} + {% include widget.subwidgets.2.template_name with widget=widget.subwidgets.2 %} + {% trans "days before" %} + {% include widget.subwidgets.3.template_name with widget=widget.subwidgets.3 %} + {% endif %} +
+ {% endfor %} + {% endfor %} +
diff --git a/src/pretix/base/templates/pretixbase/forms/widgets/reldatetime.html b/src/pretix/base/templates/pretixbase/forms/widgets/reldatetime.html new file mode 100644 index 000000000..b35b330aa --- /dev/null +++ b/src/pretix/base/templates/pretixbase/forms/widgets/reldatetime.html @@ -0,0 +1,23 @@ +{% load i18n %} +
+ {% for group_name, group_choices, group-index in widget.subwidgets.0.optgroups %} + {% for selopt in group_choices %} +
+ + {% if selopt.value == "absolute" %} + {% include widget.subwidgets.1.template_name with widget=widget.subwidgets.1 %} + {% elif selopt.value == "relative" %} + {% include widget.subwidgets.2.template_name with widget=widget.subwidgets.2 %} + {% trans "days before" %} + {% include widget.subwidgets.3.template_name with widget=widget.subwidgets.3 %} + {% trans "at" %} + {% include widget.subwidgets.4.template_name with widget=widget.subwidgets.4 %} + {% endif %} +
+ {% endfor %} + {% endfor %} +
diff --git a/src/pretix/control/context.py b/src/pretix/control/context.py index d7db605fb..e3dcd03b9 100644 --- a/src/pretix/control/context.py +++ b/src/pretix/control/context.py @@ -74,6 +74,7 @@ def contextprocessor(request): ctx['js_datetime_format'] = get_javascript_format('DATETIME_INPUT_FORMATS') ctx['js_date_format'] = get_javascript_format('DATE_INPUT_FORMATS') + ctx['js_time_format'] = get_javascript_format('TIME_INPUT_FORMATS') ctx['js_locale'] = get_moment_locale() if settings.DEBUG and 'runserver' not in sys.argv: diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 92373b539..780b36e1a 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -10,6 +10,7 @@ from pytz import common_timezones, timezone from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm from pretix.base.models import Event, Organizer +from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.control.forms import ExtFileField @@ -20,6 +21,15 @@ class EventWizardFoundationForm(forms.Form): widget=forms.CheckboxSelectMultiple, help_text=_('Choose all languages that your event should be available in.') ) + has_subevents = forms.BooleanField( + label=_("This is an event series"), + 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 date, time, location, prices and ' + 'quotas, but not in other settings, and buying tickets across multiple of these events at ' + 'the same time is possible. You cannot change this setting for this event later.'), + required=False, + ) def __init__(self, *args, **kwargs): self.user = kwargs.pop('user') @@ -72,10 +82,15 @@ class EventWizardBasicsForm(I18nModelForm): def __init__(self, *args, **kwargs): self.organizer = kwargs.pop('organizer') self.locales = kwargs.get('locales') + self.has_subevents = kwargs.pop('has_subevents') kwargs.pop('user') super().__init__(*args, **kwargs) self.initial['timezone'] = get_current_timezone_name() self.fields['locale'].choices = [(a, b) for a, b in settings.LANGUAGES if a in self.locales] + self.fields['location'].widget.attrs['rows'] = '3' + if self.has_subevents: + del self.fields['presale_start'] + del self.fields['presale_end'] def clean(self): data = super().clean() @@ -125,11 +140,12 @@ class EventWizardCopyForm(forms.Form): def __init__(self, *args, **kwargs): kwargs.pop('organizer') kwargs.pop('locales') + has_subevents = kwargs.pop('has_subevents') self.user = kwargs.pop('user') super().__init__(*args, **kwargs) self.fields['copy_from_event'] = forms.ModelChoiceField( label=_("Copy configuration from"), - queryset=EventWizardCopyForm.copy_from_queryset(self.user), + queryset=EventWizardCopyForm.copy_from_queryset(self.user).filter(has_subevents=has_subevents), widget=forms.RadioSelect, empty_label=_('Do not copy'), required=False @@ -195,15 +211,15 @@ class EventSettingsForm(SettingsForm): presale_start_show_date = forms.BooleanField( label=_("Show start date"), help_text=_("Show the presale start date before presale has started."), - widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_presale_start'}), + widget=forms.CheckboxInput, required=False ) - last_order_modification_date = forms.DateTimeField( + last_order_modification_date = RelativeDateTimeField( label=_('Last date of modifications'), help_text=_("The last date users can modify details of their orders, such as attendee names or " - "answers to questions."), + "answers to questions. If you use the event series feature and an order contains tickest for " + "multiple event dates, the earliest date will be used."), required=False, - widget=forms.DateTimeInput(attrs={'class': 'datetimepicker'}), ) timezone = forms.ChoiceField( choices=((a, a) for a in common_timezones), @@ -327,12 +343,12 @@ class PaymentSettingsForm(SettingsForm): label=_('Payment term in days'), help_text=_("The number of days after placing an order the user has to pay to preserve his reservation."), ) - payment_term_last = forms.DateField( + payment_term_last = RelativeDateField( label=_('Last date of payments'), help_text=_("The last date any payments are accepted. This has precedence over the number of " - "days configured above."), + "days configured above. If you use the event series feature and an order contains tickets for " + "multiple dates, the earliest date will be used."), required=False, - widget=forms.DateInput(attrs={'class': 'datepickerfield'}) ) payment_term_weekdays = forms.BooleanField( label=_('Only end payment terms on weekdays'), @@ -364,8 +380,10 @@ class PaymentSettingsForm(SettingsForm): def clean(self): cleaned_data = super().clean() payment_term_last = cleaned_data.get('payment_term_last') + print(payment_term_last) if payment_term_last and self.obj.presale_end: - if payment_term_last < self.obj.presale_end.date(): + print(payment_term_last, payment_term_last.datetime(self.obj), self.obj.presale_end.date()) + if payment_term_last.datetime(self.obj) < self.obj.presale_end.date(): self.add_error( 'payment_term_last', _('The last payment date cannot be before the end of presale.'), @@ -392,6 +410,8 @@ class ProviderForm(SettingsForm): v._required = v.one_required v.one_required = False v.widget.enabled_locales = self.locales + elif isinstance(v, (RelativeDateTimeField, RelativeDateField)): + v.set_event(self.obj) def clean(self): cleaned_data = super().clean() @@ -658,12 +678,12 @@ class TicketSettingsForm(SettingsForm): help_text=_("Use pretix to generate tickets for the user to download and print out."), required=False ) - ticket_download_date = forms.DateTimeField( + ticket_download_date = RelativeDateTimeField( label=_("Download date"), - help_text=_("Ticket download will be offered after this date."), - required=True, - widget=forms.DateTimeInput(attrs={'class': 'datetimepicker', - 'data-display-dependency': '#id_ticket_download'}), + help_text=_("Ticket download will be offered after this date. If you use the event series feature and an order " + "contains tickets for multiple event dates, download of all tickets will be available if at least " + "one of the event dates allows it."), + required=False, ) ticket_download_addons = forms.BooleanField( label=_("Offer to download tickets separately for add-on products"), diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index d26fec04a..310b9acf9 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -1,9 +1,9 @@ from django import forms from django.db.models import Q 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.models import Item, Order, Organizer +from pretix.base.models import Item, Order, Organizer, SubEvent from pretix.base.signals import register_payment_providers from pretix.control.utils.i18n import i18ncomp @@ -86,6 +86,12 @@ class EventOrderFilterForm(OrderFilterForm): ], required=False, ) + subevent = forms.ModelChoiceField( + label=pgettext_lazy('subevent', 'Date'), + queryset=SubEvent.objects.none(), + required=False, + empty_label=pgettext_lazy('subevent', 'All dates') + ) def get_payment_providers(self): providers = [] @@ -105,12 +111,20 @@ class EventOrderFilterForm(OrderFilterForm): self.fields['provider'].choices += [(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()] + if self.event.has_subevents: + self.fields['subevent'].queryset = self.event.subevents.all() + elif 'subevent': + del self.fields['subevent'] + def filter_qs(self, qs): fdata = self.cleaned_data qs = super().filter_qs(qs) if fdata.get('item'): - qs = qs.filter(positions__item_id__in=(fdata.get('item'),)) + qs = qs.filter(positions__item=fdata.get('item')) + + if fdata.get('subevent'): + qs = qs.filter(positions__subevent=fdata.get('subevent')) if fdata.get('provider'): qs = qs.filter(payment_provider=fdata.get('provider')) @@ -146,6 +160,57 @@ class OrderSearchFilterForm(OrderFilterForm): return qs +class SubEventFilterForm(FilterForm): + status = forms.ChoiceField( + label=_('Status'), + choices=( + ('', _('All')), + ('active', _('Active')), + ('running', _('Shop live and presale running')), + ('inactive', _('Inactive')), + ('future', _('Presale not started')), + ('past', _('Presale over')), + ), + required=False + ) + query = forms.CharField( + label=_('Event name'), + widget=forms.TextInput(attrs={ + 'placeholder': _('Event name'), + 'autofocus': 'autofocus' + }), + required=False + ) + + def filter_qs(self, qs): + fdata = self.cleaned_data + + if fdata.get('status') == 'active': + qs = qs.filter(active=True) + elif fdata.get('status') == 'running': + qs = qs.filter( + active=True + ).filter( + Q(presale_start__isnull=True) | Q(presale_start__lte=now()) + ).filter( + Q(presale_end__isnull=True) | Q(presale_end__gte=now()) + ) + elif fdata.get('status') == 'inactive': + qs = qs.filter(active=False) + elif fdata.get('status') == 'future': + qs = qs.filter(presale_start__gte=now()) + elif fdata.get('status') == 'past': + qs = qs.filter(presale_end__lte=now()) + + if fdata.get('query'): + query = fdata.get('query') + qs = qs.filter( + Q(name__icontains=i18ncomp(query)) | Q(location__icontains=query) + ) + + return qs + + class EventFilterForm(FilterForm): status = forms.ChoiceField( label=_('Status'), diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 31d629be8..3c5987df1 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -3,7 +3,6 @@ import copy from django import forms from django.core.exceptions import ValidationError from django.db.models import Max -from django.forms import BooleanField, ModelMultipleChoiceField from django.forms.formsets import DELETION_FIELD_NAME from django.utils.translation import ugettext as __, ugettext_lazy as _ from i18nfield.forms import I18nFormField, I18nTextarea @@ -52,7 +51,6 @@ class QuestionForm(I18nModelForm): class QuestionOptionForm(I18nModelForm): - class Meta: model = QuestionOption localized_fields = '__all__' @@ -62,36 +60,38 @@ class QuestionOptionForm(I18nModelForm): class QuotaForm(I18nModelForm): - def __init__(self, **kwargs): - items = kwargs['items'] - del kwargs['items'] - instance = kwargs.get('instance', None) - self.original_instance = copy.copy(instance) if instance else None + self.instance = kwargs.get('instance', None) + self.event = kwargs.get('event') + items = kwargs.pop('items', None) or self.event.items.prefetch_related('variations') + self.original_instance = copy.copy(self.instance) if self.instance else None + initial = kwargs.get('initial', {}) + if self.instance and self.instance.pk: + initial['itemvars'] = [str(i.pk) for i in self.instance.items.all()] + [ + '{}-{}'.format(v.item_id, v.pk) for v in self.instance.variations.all() + ] + kwargs['initial'] = initial super().__init__(**kwargs) - if hasattr(self, 'instance') and self.instance.pk: - active_items = set(self.instance.items.all()) - active_variations = set(self.instance.variations.all()) - else: - active_items = set() - active_variations = set() - + choices = [] for item in items: if len(item.variations.all()) > 0: - self.fields['item_%s' % item.id] = ModelMultipleChoiceField( - label=_("Activate for"), - required=False, - initial=active_variations, - queryset=item.variations.all(), - widget=forms.CheckboxSelectMultiple - ) + for v in item.variations.all(): + choices.append(('{}-{}'.format(item.pk, v.pk), '{} – {}'.format(item.name, v.value))) else: - self.fields['item_%s' % item.id] = BooleanField( - label=_("Activate"), - required=False, - initial=(item in active_items) - ) + choices.append(('{}'.format(item.pk), item.name)) + + self.fields['itemvars'] = forms.MultipleChoiceField( + label=_('Products'), + required=False, + choices=choices, + widget=forms.CheckboxSelectMultiple + ) + + if self.event.has_subevents: + self.fields['subevent'].queryset = self.event.subevents.all() + else: + del self.fields['subevent'] class Meta: model = Quota @@ -99,8 +99,29 @@ class QuotaForm(I18nModelForm): fields = [ 'name', 'size', + 'subevent' ] + def save(self, *args, **kwargs): + creating = not self.instance.pk + inst = super().save(*args, **kwargs) + + selected_items = set(list(self.event.items.filter(id__in=[ + i.split('-')[0] for i in self.cleaned_data['itemvars'] + ]))) + selected_variations = list(ItemVariation.objects.filter(item__event=self.event, id__in=[ + i.split('-')[1] for i in self.cleaned_data['itemvars'] if '-' in i + ])) + + current_items = [] if creating else self.instance.items.all() + current_variations = [] if creating else self.instance.variations.all() + + self.instance.items.remove(*[i for i in current_items if i not in selected_items]) + self.instance.items.add(*[i for i in selected_items if i not in current_items]) + self.instance.variations.remove(*[i for i in current_variations if i not in selected_variations]) + self.instance.variations.add(*[i for i in selected_variations if i not in current_variations]) + return inst + class ItemCreateForm(I18nModelForm): has_variations = forms.BooleanField(label=_('The product should exist in multiple variations'), @@ -197,7 +218,6 @@ class ItemUpdateForm(I18nModelForm): class ItemVariationsFormSet(I18nFormSet): - def clean(self): super().clean() for f in self.forms: diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index b465ca4eb..bb570b838 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -4,10 +4,12 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils.formats import localize 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.forms import I18nModelForm from pretix.base.models import Item, ItemAddOn, Order, OrderPosition +from pretix.base.models.event import SubEvent +from pretix.base.services.pricing import get_price class ExtendForm(I18nModelForm): @@ -55,6 +57,15 @@ class CommentForm(I18nModelForm): } +class SubEventChoiceField(forms.ModelChoiceField): + def label_from_instance(self, obj): + p = get_price(self.instance.item, self.instance.variation, + voucher=self.instance.voucher, + subevent=obj) + return '{} – {} ({} {})'.format(obj.name, obj.get_date_range_display(), + p, self.instance.order.event.currency) + + class OrderPositionAddForm(forms.Form): do = forms.BooleanField( label=_('Add a new product to the order'), @@ -74,6 +85,12 @@ class OrderPositionAddForm(forms.Form): label=_('Gross price'), help_text=_("Keep empty for the product's default price") ) + subevent = forms.ModelChoiceField( + SubEvent.objects.none(), + label=pgettext_lazy('subevent', 'Date'), + required=True, + empty_label=None + ) def __init__(self, *args, **kwargs): order = kwargs.pop('order') @@ -100,9 +117,20 @@ class OrderPositionAddForm(forms.Form): else: del self.fields['addon_to'] + if order.event.has_subevents: + self.fields['subevent'].queryset = order.event.subevents.all() + else: + del self.fields['subevent'] + class OrderPositionChangeForm(forms.Form): itemvar = forms.ChoiceField() + subevent = SubEventChoiceField( + SubEvent.objects.none(), + label=pgettext_lazy('subevent', 'New date'), + required=True, + empty_label=None + ) price = forms.DecimalField( required=False, max_digits=10, decimal_places=2, @@ -114,6 +142,7 @@ class OrderPositionChangeForm(forms.Form): choices=( ('product', 'Change product'), ('price', 'Change price'), + ('subevent', 'Change event date'), ('cancel', 'Remove product') ) ) @@ -131,9 +160,15 @@ class OrderPositionChangeForm(forms.Form): pass initial['price'] = instance.price + initial['subevent'] = instance.subevent kwargs['initial'] = initial super().__init__(*args, **kwargs) + if instance.order.event.has_subevents: + self.fields['subevent'].instance = instance + self.fields['subevent'].queryset = instance.order.event.subevents.all() + else: + del self.fields['subevent'] choices = [] for i in instance.order.event.items.prefetch_related('variations').all(): pname = str(i.name) @@ -142,11 +177,13 @@ class OrderPositionChangeForm(forms.Form): variations = list(i.variations.all()) if variations: for v in variations: + p = get_price(i, v, voucher=instance.voucher, subevent=instance.subevent) choices.append(('%d-%d' % (i.pk, v.pk), - '%s – %s (%s %s)' % (pname, v.value, localize(v.price), + '%s – %s (%s %s)' % (pname, v.value, localize(p), instance.order.event.currency))) else: - choices.append((str(i.pk), '%s (%s %s)' % (pname, localize(i.default_price), + p = get_price(i, None, voucher=instance.voucher, subevent=instance.subevent) + choices.append((str(i.pk), '%s (%s %s)' % (pname, localize(p), instance.order.event.currency))) self.fields['itemvar'].choices = choices diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py new file mode 100644 index 000000000..fd2541402 --- /dev/null +++ b/src/pretix/control/forms/subevents.py @@ -0,0 +1,98 @@ +from django import forms +from django.utils.functional import cached_property +from i18nfield.forms import I18nInlineFormSet + +from pretix.base.forms import I18nModelForm +from pretix.base.models.event import SubEvent +from pretix.base.models.items import SubEventItem + + +class SubEventForm(I18nModelForm): + def __init__(self, *args, **kwargs): + self.event = kwargs['event'] + super().__init__(*args, **kwargs) + self.fields['location'].widget.attrs['rows'] = '3' + + class Meta: + model = SubEvent + localized_fields = '__all__' + fields = [ + 'name', + 'active', + 'date_from', + 'date_to', + 'date_admission', + 'presale_start', + 'presale_end', + 'location', + ] + widgets = { + 'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), + 'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker', 'data-date-after': '#id_date_from'}), + 'date_admission': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), + 'presale_start': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), + 'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker', + 'data-date-after': '#id_presale_start'}), + } + + +class SubEventItemOrVariationFormMixin: + def __init__(self, *args, **kwargs): + self.item = kwargs.pop('item') + self.variation = kwargs.pop('variation', None) + super().__init__(*args, **kwargs) + + +class SubEventItemForm(SubEventItemOrVariationFormMixin, forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['price'].widget.attrs['placeholder'] = '{} {}'.format( + self.item.default_price, self.item.event.currency + ) + + class Meta: + model = SubEventItem + fields = ['price'] + + +class SubEventItemVariationForm(SubEventItemOrVariationFormMixin, forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['price'].widget.attrs['placeholder'] = '{} {}'.format( + self.variation.price, self.item.event.currency + ) + + class Meta: + model = SubEventItem + fields = ['price'] + + +class QuotaFormSet(I18nInlineFormSet): + + def __init__(self, *args, **kwargs): + self.event = kwargs.pop('event', None) + self.locales = self.event.settings.get('locales') + super().__init__(*args, **kwargs) + + @cached_property + def items(self): + return self.event.items.prefetch_related('variations').all() + + def _construct_form(self, i, **kwargs): + kwargs['locales'] = self.locales + kwargs['event'] = self.event + kwargs['items'] = self.items + return super()._construct_form(i, **kwargs) + + @property + def empty_form(self): + form = self.form( + auto_id=self.auto_id, + prefix=self.add_prefix('__prefix__'), + empty_permitted=True, + locales=self.locales, + event=self.event, + items=self.items + ) + self.add_fields(form, None) + return form diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py index 627e03da7..b38882774 100644 --- a/src/pretix/control/forms/vouchers.py +++ b/src/pretix/control/forms/vouchers.py @@ -4,7 +4,7 @@ from django import forms from django.core.exceptions import ValidationError from django.db.models import Q 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.forms import I18nModelForm from pretix.base.models import Item, ItemVariation, Quota, Voucher @@ -24,7 +24,7 @@ class VoucherForm(I18nModelForm): localized_fields = '__all__' fields = [ 'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', - 'comment', 'max_usages', 'price_mode' + 'comment', 'max_usages', 'price_mode', 'subevent' ] widgets = { 'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), @@ -47,6 +47,12 @@ class VoucherForm(I18nModelForm): else: self.initial_instance_data = None super().__init__(*args, **kwargs) + + if instance.event.has_subevents: + self.fields['subevent'].queryset = instance.event.subevents.all() + elif 'subevent': + del self.fields['subevent'] + choices = [] for i in self.instance.event.items.prefetch_related('variations').all(): variations = list(i.variations.all()) @@ -103,6 +109,12 @@ class VoucherForm(I18nModelForm): else: cnt = data['max_usages'] + if self.instance.event.has_subevents and data['block_quota'] and not data.get('subevent'): + raise ValidationError(pgettext_lazy( + 'subevent', + 'If you want this voucher to block quota, you need to select a specific date.' + )) + if self._clean_quota_needs_checking(data): self._clean_quota_check(data, cnt) @@ -136,6 +148,10 @@ class VoucherForm(I18nModelForm): # The voucher has been reassigned to a different item, variation or quota return True + if data.get('subevent') != self.initial.get('subevent'): + # The voucher has been reassigned to a different subevent + return True + return False def _clean_was_valid(self): @@ -147,9 +163,11 @@ class VoucherForm(I18nModelForm): if self.initial_instance_data.quota: quotas.add(self.initial_instance_data.quota) elif self.initial_instance_data.variation: - quotas |= set(self.initial_instance_data.variation.quotas.all()) + quotas |= set(self.initial_instance_data.variation.quotas.filter( + subevent=self.initial_instance_data.subevent)) elif self.initial_instance_data.item: - quotas |= set(self.initial_instance_data.item.quotas.all()) + quotas |= set(self.initial_instance_data.item.quotas.filter( + subevent=self.initial_instance_data.subevent)) return quotas def _clean_quota_check(self, data, cnt): @@ -164,9 +182,9 @@ class VoucherForm(I18nModelForm): raise ValidationError(_('You can only block quota if you specify a specific product variation. ' 'Otherwise it might be unclear which quotas to block.')) elif self.instance.item and self.instance.variation: - avail = self.instance.variation.check_quotas(ignored_quotas=old_quotas) + avail = self.instance.variation.check_quotas(ignored_quotas=old_quotas, subevent=data.get('subevent')) elif self.instance.item and not self.instance.item.has_variations: - avail = self.instance.item.check_quotas(ignored_quotas=old_quotas) + avail = self.instance.item.check_quotas(ignored_quotas=old_quotas, subevent=data.get('subevent')) else: raise ValidationError(_('You need to specify either a quota or a product.')) @@ -195,7 +213,7 @@ class VoucherBulkForm(VoucherForm): localized_fields = '__all__' fields = [ 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment', - 'max_usages', 'price_mode' + 'max_usages', 'price_mode', 'subevent' ] widgets = { 'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index dec62c1d4..51ceb0553 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -3,7 +3,7 @@ from decimal import Decimal from django.dispatch import receiver from django.utils import formats -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from i18nfield.strings import LazyI18nString from pretix.base.models import Event, ItemVariation, LogEntry, OrderPosition @@ -33,6 +33,17 @@ def _display_order_changed(event: Event, logentry: LogEntry): new_price=formats.localize(Decimal(data['new_price'])), currency=event.currency ) + elif logentry.action_type == 'pretix.event.order.changed.subevent': + old_se = str(event.subevents.get(pk=data['old_subevent'])) + new_se = str(event.subevents.get(pk=data['new_subevent'])) + return text + ' ' + _('Position #{posid}: Event date "{old_event}" ({old_price} {currency}) changed ' + 'to "{new_event}" ({new_price} {currency}).').format( + posid=data.get('positionid', '?'), + old_event=old_se, new_event=new_se, + old_price=formats.localize(Decimal(data['old_price'])), + new_price=formats.localize(Decimal(data['new_price'])), + currency=event.currency + ) elif logentry.action_type == 'pretix.event.order.changed.price': return text + ' ' + _('Price of position #{posid} changed from {old_price} {currency} ' 'to {new_price} {currency}.').format( @@ -146,6 +157,12 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.team.created': _('The team has been created.'), 'pretix.team.changed': _('The team settings have been modified.'), 'pretix.team.deleted': _('The team has been deleted.'), + 'pretix.subevent.deleted': pgettext_lazy('subevent', 'The event date has been deleted.'), + 'pretix.subevent.changed': pgettext_lazy('subevent', 'The event date has been modified.'), + 'pretix.subevent.added': pgettext_lazy('subevent', 'The event date has been created.'), + 'pretix.subevent.quota.added': pgettext_lazy('subevent', 'A quota has been added to the event date.'), + 'pretix.subevent.quota.changed': pgettext_lazy('subevent', 'A quota has been modified on the event date.'), + 'pretix.subevent.quota.deleted': pgettext_lazy('subevent', 'A quota has been removed from the event date.'), } data = json.loads(logentry.data) diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index c2f7bc228..884a441ff 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -45,7 +45,7 @@ - +
+ {% if form.subevent %} + {% bootstrap_field form.subevent layout="horizontal" %} + {% endif %} {% bootstrap_field form.tag layout="horizontal" %} {% bootstrap_field form.comment layout="horizontal" %} diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/index.html b/src/pretix/control/templates/pretixcontrol/vouchers/index.html index 1972fdc51..f458dab74 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/index.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/index.html @@ -20,6 +20,17 @@ + {% if request.event.has_subevents %} + + {% endif %}

@@ -27,7 +38,7 @@ {% if vouchers|length == 0 %}

- {% if request.GET.search or request.GET.tag or request.GET.status %} + {% if request.GET.search or request.GET.tag or request.GET.status or request.GET.subevent %} {% trans "Your search did not match any vouchers." %} {% else %} {% blocktrans trimmed %} @@ -58,6 +69,9 @@ {% trans "Expiry" %} {% trans "Tag" %} {% trans "Product" %} + {% if request.event.has_subevents %} + {% trans "Date" context "subevent" %} + {% endif %} @@ -85,6 +99,9 @@ {% endblocktrans %} {% endif %} + {% if request.event.has_subevents %} + {{ v.subevent.name }} – {{ v.subevent.get_date_range_display }} + {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/waitinglist/index.html b/src/pretix/control/templates/pretixcontrol/waitinglist/index.html index 25701f8a6..02b48a139 100644 --- a/src/pretix/control/templates/pretixcontrol/waitinglist/index.html +++ b/src/pretix/control/templates/pretixcontrol/waitinglist/index.html @@ -19,7 +19,7 @@

{% trans "Send vouchers" %}
-
+
{% csrf_token %} {% if request.event.settings.waiting_list_auto %}

@@ -41,6 +41,17 @@ {% endblocktrans %}

{% endif %} + {% if request.event.has_subevents %} + + {% endif %} @@ -82,6 +93,17 @@ {% endfor %} + {% if request.event.has_subevents %} + + {% endif %}

@@ -93,6 +115,9 @@ {% trans "User" %} {% trans "Product" %} + {% if request.event.has_subevents %} + {% trans "Date" context "subevent" %} + {% endif %} {% trans "On the list since" %} {% trans "Status" %} {% trans "Voucher" %} @@ -109,6 +134,9 @@ – {{ e.variation }} {% endif %} + {% if request.event.has_subevents %} + {{ e.subevent.name }} – {{ e.subevent.get_date_range_display }} + {% endif %} {{ e.created|date:"SHORT_DATETIME_FORMAT" }} {% if e.voucher %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index a78beee37..9a1f48ac1 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -2,7 +2,7 @@ from django.conf.urls import include, url from pretix.control.views import ( auth, checkin, dashboards, event, global_settings, item, main, orders, - organizer, search, typeahead, user, vouchers, waitinglist, + organizer, search, subevents, typeahead, user, vouchers, waitinglist, ) urlpatterns = [ @@ -69,6 +69,11 @@ urlpatterns = [ url(r'^settings/invoice$', event.InvoiceSettings.as_view(), name='event.settings.invoice'), url(r'^settings/invoice/preview$', event.InvoicePreview.as_view(), name='event.settings.invoice.preview'), url(r'^settings/display', event.DisplaySettings.as_view(), name='event.settings.display'), + url(r'^subevents/$', subevents.SubEventList.as_view(), name='event.subevents'), + url(r'^subevents/(?P\d+)/$', subevents.SubEventUpdate.as_view(), name='event.subevent'), + url(r'^subevents/(?P\d+)/delete$', subevents.SubEventDelete.as_view(), + name='event.subevent.delete'), + url(r'^subevents/add$', subevents.SubEventCreate.as_view(), name='event.subevents.add'), url(r'^items/$', item.ItemList.as_view(), name='event.items'), url(r'^items/add$', item.ItemCreate.as_view(), name='event.items.add'), url(r'^items/(?P\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'), diff --git a/src/pretix/control/views/checkin.py b/src/pretix/control/views/checkin.py index 871f733b6..64dfda570 100644 --- a/src/pretix/control/views/checkin.py +++ b/src/pretix/control/views/checkin.py @@ -37,7 +37,11 @@ class CheckInView(EventPermissionRequiredMixin, ListView): if self.request.GET.get("item", "") != "": u = self.request.GET.get("item", "") - qs = qs.filter(item_id__in=(u,)) + qs = qs.filter(item_id=u) + + if self.request.GET.get("subevent", "") != "": + s = self.request.GET.get("subevent", "") + qs = qs.filter(subevent_id=s) qs = qs.prefetch_related( Prefetch('checkins', queryset=Checkin.objects.filter(position__order__event=self.request.event)) @@ -48,8 +52,11 @@ class CheckInView(EventPermissionRequiredMixin, ListView): keys_allowed = self.get_ordering_keys_mappings() if p in keys_allowed: mapped_field = keys_allowed[p] - if type(mapped_field) is tuple: - qs = qs.annotate(**mapped_field[1]).order_by(mapped_field[0]) + if isinstance(mapped_field, dict): + order = mapped_field.pop('_order') + qs = qs.annotate(**mapped_field).order_by(order) + elif isinstance(mapped_field, (list, tuple)): + qs = qs.order_by(*mapped_field) else: qs = qs.order_by(mapped_field) @@ -58,7 +65,8 @@ class CheckInView(EventPermissionRequiredMixin, ListView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['items'] = Item.objects.filter(event=self.request.event) - ctx['filtered'] = ("status" in self.request.GET or "user" in self.request.GET or "item" in self.request.GET) + ctx['filtered'] = ("status" in self.request.GET or "user" in self.request.GET or "item" in self.request.GET + or "subevent" in self.request.GET) return ctx @staticmethod @@ -73,10 +81,12 @@ class CheckInView(EventPermissionRequiredMixin, ListView): '-status': F('checkins__id').desc(nulls_last=True), 'timestamp': F('checkins__datetime').asc(nulls_first=True), '-timestamp': F('checkins__datetime').desc(nulls_last=True), - 'item': 'item__name', - '-item': '-item__name', - 'name': (F('display_name').asc(nulls_first=True), - {'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')}), - '-name': (F('display_name').desc(nulls_last=True), - {'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')}), + 'item': ('item__name', 'variation__value'), + '-item': ('-item__name', 'variation__value'), + 'subevent': ('subevent__date_from', 'subevent__name'), + '-subevent': ('-subevent__date_from', '-subevent__name'), + 'name': {'_order': F('display_name').asc(nulls_first=True), + 'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')}, + '-name': {'_order': F('display_name').desc(nulls_last=True), + 'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')}, } diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py index 38dbf0ea2..ede7eb764 100644 --- a/src/pretix/control/views/dashboards.py +++ b/src/pretix/control/views/dashboards.py @@ -98,9 +98,9 @@ def waitinglist_widgets(sender, **kwargs): for wle in wles: if (wle.item, wle.variation) not in itemvar_cache: itemvar_cache[(wle.item, wle.variation)] = ( - wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache) + wle.variation.check_quotas(subevent=wle.subevent, count_waitinglist=False, _cache=quota_cache) if wle.variation - else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache) + else wle.item.check_quotas(subevent=wle.subevent, count_waitinglist=False, _cache=quota_cache) ) row = itemvar_cache.get((wle.item, wle.variation)) if row[1] > 0: diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index adfab590b..ca1dfae6d 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -5,7 +5,7 @@ from django.core.files import File from django.core.urlresolvers import resolve, reverse from django.db import transaction from django.db.models import Count, F, Q -from django.forms.models import ModelMultipleChoiceField, inlineformset_factory +from django.forms.models import inlineformset_factory from django.http import Http404, HttpResponseRedirect from django.shortcuts import redirect from django.utils.functional import cached_property @@ -21,6 +21,7 @@ from pretix.base.models import ( CachedTicket, Item, ItemCategory, ItemVariation, Order, Question, QuestionAnswer, QuestionOption, Quota, Voucher, ) +from pretix.base.models.event import SubEvent from pretix.base.models.items import ItemAddOn from pretix.control.forms.item import ( CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemCreateForm, @@ -549,54 +550,16 @@ class QuotaList(ListView): template_name = 'pretixcontrol/items/quotas.html' def get_queryset(self): - return Quota.objects.filter( + qs = Quota.objects.filter( event=self.request.event ).prefetch_related("items") + if self.request.GET.get("subevent", "") != "": + s = self.request.GET.get("subevent", "") + qs = qs.filter(subevent_id=s) + return qs -class QuotaEditorMixin: - @cached_property - def items(self) -> "List[Item]": - return list(self.request.event.items.all().prefetch_related("variations")) - - def get_form(self, form_class=QuotaForm): - if not hasattr(self, '_form'): - kwargs = self.get_form_kwargs() - kwargs['items'] = self.items - self._form = form_class(**kwargs) - return self._form - - def get_context_data(self, *args, **kwargs) -> dict: - context = super().get_context_data(*args, **kwargs) - context['items'] = self.items - for item in context['items']: - item.field = self.get_form(QuotaForm)['item_%s' % item.id] - return context - - @transaction.atomic - def form_valid(self, form): - res = super().form_valid(form) - items = self.object.items.all() - variations = self.object.variations.all() - selected_variations = [] - self.object = form.instance - for item in self.items: - field = form.fields['item_%s' % item.id] - data = form.cleaned_data['item_%s' % item.id] - if isinstance(field, ModelMultipleChoiceField): - for v in data: - selected_variations.append(v) - if data and item not in items: - self.object.items.add(item) - elif not data and item in items: - self.object.items.remove(item) - - self.object.variations.add(*[v for v in selected_variations if v not in variations]) - self.object.variations.remove(*[v for v in variations if v not in selected_variations]) - return res - - -class QuotaCreate(EventPermissionRequiredMixin, QuotaEditorMixin, CreateView): +class QuotaCreate(EventPermissionRequiredMixin, CreateView): model = Quota form_class = QuotaForm template_name = 'pretixcontrol/items/quota_edit.html' @@ -691,7 +654,7 @@ class QuotaView(ChartContainingView, DetailView): raise Http404(_("The requested quota does not exist.")) -class QuotaUpdate(EventPermissionRequiredMixin, QuotaEditorMixin, UpdateView): +class QuotaUpdate(EventPermissionRequiredMixin, UpdateView): model = Quota form_class = QuotaForm template_name = 'pretixcontrol/items/quota_edit.html' @@ -719,6 +682,22 @@ class QuotaUpdate(EventPermissionRequiredMixin, QuotaEditorMixin, UpdateView): k: form.cleaned_data.get(k) for k in form.changed_data } ) + if ((form.initial.get('subevent') and not form.instance.subevent) + or (form.instance.subevent and form.initial.get('subevent') != form.instance.subevent.pk)): + + if form.initial.get('subevent'): + se = SubEvent.objects.get(event=self.request.event, pk=form.initial.get('subevent')) + se.log_action( + 'pretix.subevent.quota.deleted', user=self.request.user, data={ + 'id': form.instance.pk + } + ) + if form.instance.subevent: + form.instance.subevent.log_action( + 'pretix.subevent.quota.added', user=self.request.user, data={ + 'id': form.instance.pk + } + ) return super().form_valid(form) def get_success_url(self) -> str: diff --git a/src/pretix/control/views/main.py b/src/pretix/control/views/main.py index d5ad490ea..833806ba3 100644 --- a/src/pretix/control/views/main.py +++ b/src/pretix/control/views/main.py @@ -90,6 +90,7 @@ class EventWizard(SessionWizardView): event = form_dict['basics'].instance event.organizer = foundation_data['organizer'] event.plugins = settings.PRETIX_PLUGINS_DEFAULT + event.has_subevents = foundation_data['has_subevents'] form_dict['basics'].save() has_control_rights = self.request.user.teams.filter( @@ -106,6 +107,17 @@ class EventWizard(SessionWizardView): t.members.add(self.request.user) t.limit_events.add(event) + if event.has_subevents: + event.subevents.create( + name=event.name, + date_from=event.date_from, + date_to=event.date_to, + presale_start=event.presale_start, + presale_end=event.presale_end, + location=event.location, + active=True + ) + logdata = {} for f in form_list: logdata.update({ diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 6405d7bc5..322db3451 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -16,6 +16,7 @@ from pretix.base.models import ( CachedFile, CachedTicket, Invoice, InvoiceAddress, Item, ItemVariation, Order, Quota, generate_position_secret, generate_secret, ) +from pretix.base.models.event import SubEvent from pretix.base.services.export import export from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, @@ -53,13 +54,6 @@ class OrderList(EventPermissionRequiredMixin, ListView): if self.filter_form.is_valid(): qs = self.filter_form.filter_qs(qs) - if self.request.GET.get("ordering", "") != "": - p = self.request.GET.get("ordering", "") - p_admissable = ('-code', 'code', '-email', 'email', '-total', 'total', '-datetime', 'datetime', - '-status', 'status', 'pcnt', '-pcnt') - if p in p_admissable: - qs = qs.order_by(p) - return qs.distinct() def get_context_data(self, **kwargs): @@ -480,7 +474,8 @@ class OrderChange(OrderView): try: ocm.add_position(item, variation, self.add_form.cleaned_data['price'], - self.add_form.cleaned_data.get('addon_to')) + self.add_form.cleaned_data.get('addon_to'), + self.add_form.cleaned_data.get('subevent')) except OrderError as e: self.add_form.custom_error = str(e) return False @@ -506,6 +501,8 @@ class OrderChange(OrderView): ocm.change_item(p, item, variation) elif p.form.cleaned_data['operation'] == 'price': ocm.change_price(p, p.form.cleaned_data['price']) + elif p.form.cleaned_data['operation'] == 'subevent': + ocm.change_subevent(p, p.form.cleaned_data['subevent']) elif p.form.cleaned_data['operation'] == 'cancel': ocm.cancel(p) @@ -613,7 +610,19 @@ class OverView(EventPermissionRequiredMixin, TemplateView): def get_context_data(self, **kwargs): ctx = super().get_context_data() - ctx['items_by_category'], ctx['total'] = order_overview(self.request.event) + + subevent = None + if self.request.GET.get("subevent", "") != "" and self.request.event.has_subevents: + i = self.request.GET.get("subevent", "") + try: + subevent = self.request.event.subevents.get(pk=i) + except SubEvent.DoesNotExist: + pass + + ctx['items_by_category'], ctx['total'] = order_overview(self.request.event, subevent=subevent) + ctx['subevent_warning'] = self.request.event.has_subevents and subevent and ( + self.request.event.orders.filter(payment_fee__gt=0).exists() + ) return ctx diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py new file mode 100644 index 000000000..d1898ac7d --- /dev/null +++ b/src/pretix/control/views/subevents.py @@ -0,0 +1,312 @@ +import copy + +from django.contrib import messages +from django.core.urlresolvers import reverse +from django.db import transaction +from django.forms import inlineformset_factory +from django.http import Http404, HttpResponseRedirect +from django.utils.functional import cached_property +from django.utils.translation import pgettext_lazy, ugettext_lazy as _ +from django.views.generic import CreateView, DeleteView, ListView, UpdateView + +from pretix.base.models.event import SubEvent +from pretix.base.models.items import Quota, SubEventItem, SubEventItemVariation +from pretix.control.forms.filter import SubEventFilterForm +from pretix.control.forms.item import QuotaForm +from pretix.control.forms.subevents import ( + QuotaFormSet, SubEventForm, SubEventItemForm, SubEventItemVariationForm, +) +from pretix.control.permissions import EventPermissionRequiredMixin + + +class SubEventList(EventPermissionRequiredMixin, ListView): + model = SubEvent + context_object_name = 'subevents' + paginate_by = 30 + template_name = 'pretixcontrol/subevents/index.html' + permission = 'can_change_settings' + + def get_queryset(self): + qs = self.request.event.subevents.all() + if self.filter_form.is_valid(): + qs = self.filter_form.filter_qs(qs) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['filter_form'] = self.filter_form + return ctx + + @cached_property + def filter_form(self): + return SubEventFilterForm(data=self.request.GET) + + +class SubEventDelete(EventPermissionRequiredMixin, DeleteView): + model = SubEvent + template_name = 'pretixcontrol/subevents/delete.html' + permission = 'can_change_settings' + context_object_name = 'subevents' + + def get_object(self, queryset=None) -> SubEvent: + try: + return self.request.event.subevents.get( + id=self.kwargs['subevent'] + ) + except SubEvent.DoesNotExist: + raise Http404(pgettext_lazy("subevent", "The requested date does not exist.")) + + def get(self, request, *args, **kwargs): + if self.get_object().orderposition_set.count() > 0: + messages.error(request, pgettext_lazy('subevent', 'A date can not be deleted if orders already have been ' + 'placed.')) + return HttpResponseRedirect(self.get_success_url()) + return super().get(request, *args, **kwargs) + + @transaction.atomic + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + success_url = self.get_success_url() + + if self.get_object().orderposition_set.count() > 0: + messages.error(request, pgettext_lazy('subevent', 'A date can not be deleted if orders already have been ' + 'placed.')) + return HttpResponseRedirect(self.get_success_url()) + else: + self.object.log_action('pretix.subevent.deleted', user=self.request.user) + self.object.delete() + messages.success(request, pgettext_lazy('subevent', 'The selected date has been deleted.')) + return HttpResponseRedirect(success_url) + + def get_success_url(self) -> str: + return reverse('control:event.subevents', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) + + +class SubEventEditorMixin: + @cached_property + def formset(self): + extra = 0 + kwargs = {} + + if self.copy_from: + kwargs['initial'] = [ + { + 'size': q.size, + 'name': q.name, + 'itemvars': [str(i.pk) for i in q.items.all()] + [ + '{}-{}'.format(v.item_id, v.pk) for v in q.variations.all() + ] + } for q in self.copy_from.quotas.prefetch_related('items', 'variations') + ] + extra = len(kwargs['initial']) + + formsetclass = inlineformset_factory( + SubEvent, Quota, + form=QuotaForm, formset=QuotaFormSet, + can_order=False, can_delete=True, extra=extra, + ) + if self.object: + kwargs['queryset'] = self.object.quotas.prefetch_related('items', 'variations') + + return formsetclass(self.request.POST if self.request.method == "POST" else None, + instance=self.object, + event=self.request.event, **kwargs) + + def save_formset(self, obj): + for form in self.formset.initial_forms: + if form in self.formset.deleted_forms: + if not form.instance.pk: + continue + form.instance.log_action(action='pretix.event.quota.deleted', user=self.request.user) + obj.log_action('pretix.subevent.quota.deleted', user=self.request.user, data={ + 'id': form.instance.pk + }) + form.instance.delete() + form.instance.pk = None + elif form.has_changed(): + form.instance.question = obj + form.save() + change_data = {k: form.cleaned_data.get(k) for k in form.changed_data} + change_data['id'] = form.instance.pk + obj.log_action( + 'pretix.subevent.quota.changed', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) + form.instance.log_action( + 'pretix.event.quota.changed', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) + + for form in self.formset.extra_forms: + if not form.has_changed(): + continue + if self.formset._should_delete_form(form): + continue + form.instance.subevent = obj + form.instance.event = obj.event + form.save() + change_data = {k: form.cleaned_data.get(k) for k in form.changed_data} + change_data['id'] = form.instance.pk + form.instance.log_action(action='pretix.event.quota.added', user=self.request.user, data=change_data) + obj.log_action('pretix.subevent.quota.added', user=self.request.user, data=change_data) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['formset'] = self.formset + ctx['itemvar_forms'] = self.itemvar_forms + return ctx + + @cached_property + def copy_from(self): + if self.request.GET.get("copy_from") and not getattr(self, 'object'): + try: + return self.request.event.subevents.get(pk=self.request.GET.get("copy_from")) + except SubEvent.DoesNotExist: + pass + + @cached_property + def itemvar_forms(self): + se_item_instances = { + sei.item_id: sei for sei in SubEventItem.objects.filter(subevent=self.object) + } + se_var_instances = { + sei.variation_id: sei for sei in SubEventItemVariation.objects.filter(subevent=self.object) + } + + if self.copy_from: + se_item_instances = { + sei.item_id: SubEventItem(item=sei.item, price=sei.price) + for sei in SubEventItem.objects.filter(subevent=self.copy_from).select_related('item') + } + se_var_instances = { + sei.variation_id: SubEventItemVariation(variation=sei.variation, price=sei.price) + for sei in SubEventItemVariation.objects.filter(subevent=self.copy_from).select_related('variation') + } + + formlist = [] + for i in self.request.event.items.filter(active=True).prefetch_related('variations'): + if i.has_variations: + for v in i.variations.all(): + inst = se_var_instances.get(v.pk) or SubEventItemVariation(subevent=self.object, variation=v) + formlist.append(SubEventItemVariationForm( + prefix='itemvar-{}'.format(v.pk), + item=i, variation=v, + instance=inst, + data=(self.request.POST if self.request.method == "POST" else None) + )) + else: + inst = se_item_instances.get(i.pk) or SubEventItem(subevent=self.object, item=i) + formlist.append(SubEventItemForm( + prefix='item-{}'.format(i.pk), + item=i, + instance=inst, + data=(self.request.POST if self.request.method == "POST" else None) + )) + return formlist + + def is_valid(self, form): + return form.is_valid() and all([f.is_valid() for f in self.itemvar_forms]) and self.formset.is_valid() + + +class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateView): + model = SubEvent + template_name = 'pretixcontrol/subevents/detail.html' + permission = 'can_change_settings' + context_object_name = 'subevent' + form_class = SubEventForm + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + form = self.get_form() + if self.is_valid(form): + return self.form_valid(form) + else: + return self.form_invalid(form) + + def get_object(self, queryset=None) -> SubEvent: + try: + return self.request.event.subevents.get( + id=self.kwargs['subevent'] + ) + except SubEvent.DoesNotExist: + raise Http404(pgettext_lazy("subevent", "The requested date does not exist.")) + + @transaction.atomic + def form_valid(self, form): + self.save_formset(self.object) + + for f in self.itemvar_forms: + f.save() + # TODO: LogEntry? + + messages.success(self.request, _('Your changes have been saved.')) + if form.has_changed(): + self.object.log_action( + 'pretix.subevent.changed', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) + return super().form_valid(form) + + def get_success_url(self) -> str: + return reverse('control:event.subevents', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['event'] = self.request.event + return kwargs + + +class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateView): + model = SubEvent + template_name = 'pretixcontrol/subevents/detail.html' + permission = 'can_change_settings' + context_object_name = 'subevent' + form_class = SubEventForm + + def post(self, request, *args, **kwargs): + self.object = SubEvent(event=self.request.event) + form = self.get_form() + if self.is_valid(form): + return self.form_valid(form) + else: + return self.form_invalid(form) + + def get_success_url(self) -> str: + return reverse('control:event.subevents', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['event'] = self.request.event + if self.copy_from: + i = copy.copy(self.copy_from) + i.pk = None + kwargs['instance'] = i + else: + kwargs['instance'] = SubEvent(event=self.request.event) + return kwargs + + @transaction.atomic + def form_valid(self, form): + form.instance.event = self.request.event + messages.success(self.request, pgettext_lazy('subevent', 'The new date has been created.')) + ret = super().form_valid(form) + form.instance.log_action('pretix.subevent.added', data=dict(form.cleaned_data), user=self.request.user) + + self.save_formset(form.instance) + for f in self.itemvar_forms: + f.instance.subevent = form.instance + f.save() + + return ret diff --git a/src/pretix/control/views/vouchers.py b/src/pretix/control/views/vouchers.py index 48060381b..511e5301a 100644 --- a/src/pretix/control/views/vouchers.py +++ b/src/pretix/control/views/vouchers.py @@ -46,6 +46,9 @@ class VoucherList(EventPermissionRequiredMixin, ListView): qs = qs.filter(redeemed__gt=0) elif s == 'e': qs = qs.filter(Q(valid_until__isnull=False) & Q(valid_until__lt=now())).filter(redeemed=0) + if self.request.GET.get("subevent", "") != "": + s = self.request.GET.get("subevent", "") + qs = qs.filter(subevent_id=s) return qs def get(self, request, *args, **kwargs): diff --git a/src/pretix/control/views/waitinglist.py b/src/pretix/control/views/waitinglist.py index 1d73d8240..5a756a937 100644 --- a/src/pretix/control/views/waitinglist.py +++ b/src/pretix/control/views/waitinglist.py @@ -35,7 +35,8 @@ class AutoAssign(EventPermissionRequiredMixin, AsyncAction, View): }) def post(self, request, *args, **kwargs): - return self.do(self.request.event.id, self.request.user.id) + return self.do(self.request.event.id, self.request.user.id, + self.request.POST.get('subevent')) class WaitingListView(EventPermissionRequiredMixin, ListView): @@ -78,7 +79,9 @@ class WaitingListView(EventPermissionRequiredMixin, ListView): def get_queryset(self): qs = WaitingListEntry.objects.filter( event=self.request.event - ).select_related('item', 'variation', 'voucher').prefetch_related('item__quotas', 'variation__quotas') + ).select_related('item', 'variation', 'voucher').prefetch_related( + 'item__quotas', 'variation__quotas' + ) s = self.request.GET.get("status", "") if s == 's': @@ -90,7 +93,11 @@ class WaitingListView(EventPermissionRequiredMixin, ListView): if self.request.GET.get("item", "") != "": i = self.request.GET.get("item", "") - qs = qs.filter(item_id__in=(i,)) + qs = qs.filter(item_id=i) + + if self.request.GET.get("subevent", "") != "": + s = self.request.GET.get("subevent", "") + qs = qs.filter(subevent_id=s) return qs @@ -107,9 +114,9 @@ class WaitingListView(EventPermissionRequiredMixin, ListView): wle.availability = itemvar_cache.get((wle.item, wle.variation)) else: wle.availability = ( - wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache) + wle.variation.check_quotas(count_waitinglist=False, subevent=wle.subevent, _cache=quota_cache) if wle.variation - else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache) + else wle.item.check_quotas(count_waitinglist=False, subevent=wle.subevent, _cache=quota_cache) ) itemvar_cache[(wle.item, wle.variation)] = wle.availability if wle.availability[0] == 100: diff --git a/src/pretix/helpers/cache.py b/src/pretix/helpers/cache.py new file mode 100644 index 000000000..bd6b54d0f --- /dev/null +++ b/src/pretix/helpers/cache.py @@ -0,0 +1,9 @@ +from django.core.cache.backends.base import DEFAULT_TIMEOUT +from django.core.cache.backends.dummy import DummyCache + + +class CustomDummyCache(DummyCache): + def get_or_set(self, key, default, timeout=DEFAULT_TIMEOUT, version=None): + if callable(default): + default = default() + return default diff --git a/src/pretix/helpers/json.py b/src/pretix/helpers/json.py new file mode 100644 index 000000000..c0344f192 --- /dev/null +++ b/src/pretix/helpers/json.py @@ -0,0 +1,11 @@ +from i18nfield.utils import I18nJSONEncoder + +from pretix.base.reldate import RelativeDateWrapper + + +class CustomJSONEncoder(I18nJSONEncoder): + def default(self, obj): + if isinstance(obj, RelativeDateWrapper): + return obj.to_string() + else: + return super().default(obj) diff --git a/src/pretix/plugins/banktransfer/views.py b/src/pretix/plugins/banktransfer/views.py index e0a863a3b..b15484d32 100644 --- a/src/pretix/plugins/banktransfer/views.py +++ b/src/pretix/plugins/banktransfer/views.py @@ -415,7 +415,7 @@ class ImportView(ListView): if 'event' in self.kwargs: ctx['basetpl'] = 'pretixplugins/banktransfer/import_base.html' - if self.request.event.settings.get('payment_term_last'): + if not self.request.event.has_subevents and self.request.event.settings.get('payment_term_last'): if now() > self.request.event.payment_term_last: ctx['no_more_payments'] = True else: diff --git a/src/pretix/plugins/checkinlists/exporters.py b/src/pretix/plugins/checkinlists/exporters.py index 77966dcbd..da99a0b9a 100644 --- a/src/pretix/plugins/checkinlists/exporters.py +++ b/src/pretix/plugins/checkinlists/exporters.py @@ -4,7 +4,9 @@ from collections import OrderedDict from django import forms from django.db.models.functions import Coalesce -from django.utils.translation import ugettext as _, ugettext_lazy +from django.utils.translation import ( + pgettext, pgettext_lazy, ugettext as _, ugettext_lazy, +) from pretix.base.exporter import BaseExporter from pretix.base.models import Order, OrderPosition, Question @@ -21,7 +23,7 @@ class CSVCheckinList(BaseCheckinList): @property def export_form_fields(self): - return OrderedDict( + d = OrderedDict( [ ('items', forms.ModelMultipleChoiceField( @@ -61,6 +63,14 @@ class CSVCheckinList(BaseCheckinList): )), ] ) + if self.event.has_subevents: + d['subevent'] = forms.ModelChoiceField( + self.event.subevents.all(), + label=pgettext_lazy('subevent', 'Date'), + required=False, + empty_label=pgettext_lazy('subevent', 'All dates') + ) + return d def render(self, form_data: dict): output = io.StringIO() @@ -81,6 +91,8 @@ class CSVCheckinList(BaseCheckinList): headers = [ _('Order code'), _('Attendee name'), _('Product'), _('Price') ] + if form_data.get('subevent'): + qs = qs.filter(subevent=form_data.get('subevent')) if form_data['paid_only']: qs = qs.filter(order__status=Order.STATUS_PAID) else: @@ -93,6 +105,9 @@ class CSVCheckinList(BaseCheckinList): if self.event.settings.attendee_emails_asked: headers.append(_('E-mail')) + if self.event.has_subevents: + headers.append(pgettext('subevent', 'Date')) + for q in questions: headers.append(str(q.question)) @@ -111,6 +126,8 @@ class CSVCheckinList(BaseCheckinList): row.append(op.secret) if self.event.settings.attendee_emails_asked: row.append(op.attendee_email or (op.addon_to.attendee_email if op.addon_to else '')) + if self.event.has_subevents: + row.append(str(op.subevent)) acache = {} for a in op.answers.all(): acache[a.question_id] = str(a) diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py index f0c7d0010..08382a671 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -280,7 +280,7 @@ class Paypal(BasePaymentProvider): order.save() def order_can_retry(self, order): - return self._is_still_available() + return self._is_still_available(order=order) def order_prepare(self, request, order): self.init_api() diff --git a/src/pretix/plugins/pretixdroid/templates/pretixplugins/pretixdroid/configuration.html b/src/pretix/plugins/pretixdroid/templates/pretixplugins/pretixdroid/configuration.html index 241f56a9b..55879dfdc 100644 --- a/src/pretix/plugins/pretixdroid/templates/pretixplugins/pretixdroid/configuration.html +++ b/src/pretix/plugins/pretixdroid/templates/pretixplugins/pretixdroid/configuration.html @@ -29,12 +29,32 @@ The code tells the app all it needs about your event. {% endblocktrans %}

-
- {% trans "Reset authentication token" %} - + + {% endif %} {% endblock %} diff --git a/src/pretix/plugins/pretixdroid/urls.py b/src/pretix/plugins/pretixdroid/urls.py index 1470f88d6..564c22f78 100644 --- a/src/pretix/plugins/pretixdroid/urls.py +++ b/src/pretix/plugins/pretixdroid/urls.py @@ -1,16 +1,22 @@ -from django.conf.urls import url +from django.conf.urls import include, url from . import views +pretixdroid_api_patterns = [ + url(r'^redeem/', views.ApiRedeemView.as_view(), + name='api.redeem'), + url(r'^search/', views.ApiSearchView.as_view(), + name='api.search'), + url(r'^download/', views.ApiDownloadView.as_view(), + name='api.download'), + url(r'^status/', views.ApiStatusView.as_view(), + name='api.status'), +] + urlpatterns = [ url(r'^control/event/(?P[^/]+)/(?P[^/]+)/pretixdroid/', views.ConfigView.as_view(), name='config'), - url(r'^pretixdroid/api/(?P[^/]+)/(?P[^/]+)/redeem/', views.ApiRedeemView.as_view(), - name='api.redeem'), - url(r'^pretixdroid/api/(?P[^/]+)/(?P[^/]+)/search/', views.ApiSearchView.as_view(), - name='api.search'), - url(r'^pretixdroid/api/(?P[^/]+)/(?P[^/]+)/download/', views.ApiDownloadView.as_view(), - name='api.download'), - url(r'^pretixdroid/api/(?P[^/]+)/(?P[^/]+)/status/', views.ApiStatusView.as_view(), - name='api.status'), + url(r'^pretixdroid/api/(?P[^/]+)/(?P[^/]+)/(?P\d+)/', + include(pretixdroid_api_patterns)), + url(r'^pretixdroid/api/(?P[^/]+)/(?P[^/]+)/', include(pretixdroid_api_patterns)), ] diff --git a/src/pretix/plugins/pretixdroid/views.py b/src/pretix/plugins/pretixdroid/views.py index 46edfb21d..acfb96ea0 100644 --- a/src/pretix/plugins/pretixdroid/views.py +++ b/src/pretix/plugins/pretixdroid/views.py @@ -8,6 +8,7 @@ from django.db.models import Count, Q from django.http import ( HttpResponseForbidden, HttpResponseNotFound, JsonResponse, ) +from django.shortcuts import get_object_or_404 from django.utils.crypto import get_random_string from django.utils.decorators import method_decorator from django.utils.timezone import now @@ -15,6 +16,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.generic import TemplateView, View from pretix.base.models import Checkin, Event, Order, OrderPosition +from pretix.base.models.event import SubEvent from pretix.control.permissions import EventPermissionRequiredMixin from pretix.helpers.urls import build_absolute_uri from pretix.multidomain.urlreverse import ( @@ -37,13 +39,26 @@ class ConfigView(EventPermissionRequiredMixin, TemplateView): allowed_chars=string.ascii_uppercase + string.ascii_lowercase + string.digits) self.request.event.settings.set('pretixdroid_key', key) + subevent = None + url = build_absolute_uri('plugins:pretixdroid:api.redeem', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug + }) + if self.request.event.has_subevents: + if self.request.GET.get('subevent'): + subevent = get_object_or_404(SubEvent, event=self.request.event, pk=self.request.GET['subevent']) + url = build_absolute_uri('plugins:pretixdroid:api.redeem', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + 'subevent': subevent.pk + }) + + ctx['subevent'] = subevent + ctx['qrdata'] = json.dumps({ 'version': API_VERSION, - 'url': build_absolute_uri('plugins:pretixdroid:api.redeem', kwargs={ - 'organizer': self.request.event.organizer.slug, - 'event': self.request.event.slug - })[:-7], # the slice removes the redeem/ part at the end - 'key': key + 'url': url[:-7], # the slice removes the redeem/ part at the end + 'key': key, }) return ctx @@ -61,9 +76,19 @@ class ApiView(View): return HttpResponseNotFound('Unknown event') if (not self.event.settings.get('pretixdroid_key') - or self.event.settings.get('pretixdroid_key') != request.GET.get('key', '')): + or self.event.settings.get('pretixdroid_key') != request.GET.get('key', '-unset-')): return HttpResponseForbidden('Invalid key') + self.subevent = None + if self.event.has_subevents: + if 'subevent' in kwargs: + self.subevent = get_object_or_404(SubEvent, event=self.event, pk=kwargs['subevent']) + else: + return HttpResponseForbidden('No subevent selected.') + else: + if 'subevent' in kwargs: + return HttpResponseForbidden('Subevents not enabled.') + return super().dispatch(request, **kwargs) @@ -85,7 +110,7 @@ class ApiRedeemView(ApiView): with transaction.atomic(): created = False op = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').get( - order__event=self.event, secret=secret + order__event=self.event, secret=secret, subevent=self.subevent ) if op.order.status == Order.STATUS_PAID or force: ci, created = Checkin.objects.get_or_create(position=op, defaults={ @@ -161,6 +186,7 @@ class ApiSearchView(ApiView): & Q( Q(secret__istartswith=query) | Q(attendee_name__icontains=query) | Q(order__code__istartswith=query) ) + & Q(subevent=self.subevent) ).annotate(checkin_cnt=Count('checkins'))[:25] response['results'] = [serialize_op(op) for op in ops] @@ -177,7 +203,7 @@ class ApiDownloadView(ApiView): } ops = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').filter( - Q(order__event=self.event) + Q(order__event=self.event) & Q(subevent=self.subevent) ).annotate(checkin_cnt=Count('checkins')) response['results'] = [serialize_op(op) for op in ops] @@ -186,25 +212,27 @@ class ApiDownloadView(ApiView): class ApiStatusView(ApiView): def get(self, request, **kwargs): + ev = self.subevent or self.event response = { 'version': API_VERSION, 'event': { - 'name': str(self.event), + 'name': str(ev.name), 'slug': self.event.slug, 'organizer': { 'name': str(self.event.organizer), 'slug': self.event.organizer.slug }, - 'date_from': self.event.date_from, - 'date_to': self.event.date_to, + 'subevent': self.subevent.pk if self.subevent else str(self.event), + 'date_from': ev.date_from, + 'date_to': ev.date_to, 'timezone': self.event.settings.timezone, 'url': event_absolute_uri(self.event, 'presale:event.index') }, 'checkins': Checkin.objects.filter( - position__order__event=self.event + position__order__event=self.event, position__subevent=self.subevent ).count(), 'total': OrderPosition.objects.filter( - order__event=self.event, order__status=Order.STATUS_PAID + order__event=self.event, order__status=Order.STATUS_PAID, subevent=self.subevent ).count() } @@ -212,28 +240,32 @@ class ApiStatusView(ApiView): p['item']: p['cnt'] for p in OrderPosition.objects.filter( order__event=self.event, - order__status=Order.STATUS_PAID + order__status=Order.STATUS_PAID, + subevent=self.subevent ).order_by().values('item').annotate(cnt=Count('id')) } op_by_variation = { p['variation']: p['cnt'] for p in OrderPosition.objects.filter( order__event=self.event, - order__status=Order.STATUS_PAID + order__status=Order.STATUS_PAID, + subevent=self.subevent ).order_by().values('variation').annotate(cnt=Count('id')) } c_by_item = { p['position__item']: p['cnt'] for p in Checkin.objects.filter( position__order__event=self.event, - position__order__status=Order.STATUS_PAID + position__order__status=Order.STATUS_PAID, + position__subevent=self.subevent ).order_by().values('position__item').annotate(cnt=Count('id')) } c_by_variation = { p['position__variation']: p['cnt'] for p in Checkin.objects.filter( position__order__event=self.event, - position__order__status=Order.STATUS_PAID + position__order__status=Order.STATUS_PAID, + position__subevent=self.subevent ).order_by().values('position__variation').annotate(cnt=Count('id')) } diff --git a/src/pretix/plugins/reports/exporters.py b/src/pretix/plugins/reports/exporters.py index 1b657ff01..e146b3593 100644 --- a/src/pretix/plugins/reports/exporters.py +++ b/src/pretix/plugins/reports/exporters.py @@ -9,10 +9,11 @@ from django.contrib.staticfiles import finders from django.db.models import Sum from django.utils.formats import date_format, localize from django.utils.timezone import now -from django.utils.translation import ugettext as _ +from django.utils.translation import pgettext, pgettext_lazy, ugettext as _ from pretix.base.exporter import BaseExporter from pretix.base.models import Order, OrderPosition +from pretix.base.models.event import SubEvent from pretix.base.services.stats import order_overview @@ -161,6 +162,13 @@ class OverviewReport(Report): Paragraph(_('Orders by product'), headlinestyle), Spacer(1, 5 * mm) ] + if self.form_data.get('subevent'): + try: + subevent = self.event.subevents.get(pk=self.form_data.get('subevent')) + except SubEvent.DoesNotExist: + subevent = self.form_data.get('subevent') + story.append(Paragraph(pgettext('subevent', 'Date: {}').format(subevent), self.get_style())) + story.append(Spacer(1, 5 * mm)) tdata = [ [ _('Product'), _('Canceled'), '', _('Refunded'), '', _('Expired'), '', _('Purchased'), @@ -180,7 +188,7 @@ class OverviewReport(Report): ], ] - items_by_category, total = order_overview(self.event) + items_by_category, total = order_overview(self.event, subevent=self.form_data.get('subevent')) for tup in items_by_category: if tup[0]: @@ -231,6 +239,18 @@ class OverviewReport(Report): story.append(table) return story + @property + def export_form_fields(self) -> dict: + d = OrderedDict() + if self.event.has_subevents: + d['subevent'] = forms.ModelChoiceField( + self.event.subevents.all(), + label=pgettext_lazy('subevent', 'Date'), + required=False, + empty_label=pgettext_lazy('subevent', 'All dates') + ) + return d + class OrderTaxListReport(Report): name = "ordertaxlist" diff --git a/src/pretix/plugins/sendmail/forms.py b/src/pretix/plugins/sendmail/forms.py index 92fe19f1d..da526f071 100644 --- a/src/pretix/plugins/sendmail/forms.py +++ b/src/pretix/plugins/sendmail/forms.py @@ -1,15 +1,22 @@ from django import forms -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput from pretix.base.forms import PlaceholderValidator from pretix.base.models import Order +from pretix.base.models.event import SubEvent class MailForm(forms.Form): sendto = forms.MultipleChoiceField() # overridden later subject = forms.CharField(label=_("Subject")) message = forms.CharField(label=_("Message")) + subevent = forms.ModelChoiceField( + SubEvent.objects.none(), + label=_('Only send to customers of'), + required=False, + empty_label=pgettext_lazy('subevent', 'All dates') + ) def __init__(self, *args, **kwargs): event = kwargs.pop('event') @@ -35,3 +42,7 @@ class MailForm(forms.Form): label=_("Send to"), widget=forms.CheckboxSelectMultiple, choices=choices ) + if event.has_subevents: + self.fields['subevent'].queryset = event.subevents.all() + else: + del self.fields['subevent'] diff --git a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/history.html b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/history.html index 53b7593c2..53accc0c7 100644 --- a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/history.html +++ b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/history.html @@ -9,17 +9,20 @@ {% for log in logs %}
  • - {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }} + {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }} {% if log.user %} -
    {{ log.user.get_full_name }} +
    {{ log.user.get_full_name }} {% endif %} {% if log.display %} -
    {{ log.display }} +
    {{ log.display }} {% endif %} -
    {% trans "Sent to orders:" %} +
    {% trans "Sent to orders:" %} {% for status in log.parsed_data.sendto %} {{ status }}{% if forloop.revcounter > 1 %},{% endif %} {% endfor %} + {% if log.pdata.subevent_obj %} +
    {{ log.pdata.subevent_obj }} + {% endif %}

    {% for locale, value in log.pdata.locales.items %} diff --git a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html index 59690115b..70baa68ee 100644 --- a/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html +++ b/src/pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html @@ -8,6 +8,9 @@

    {% csrf_token %} {% bootstrap_field form.sendto layout='horizontal' %} + {% if form.subevent %} + {% bootstrap_field form.subevent layout='horizontal' %} + {% endif %} {% bootstrap_field form.subject layout='horizontal' %} {% bootstrap_field form.message layout='horizontal' %} {% if request.method == "POST" %} diff --git a/src/pretix/plugins/sendmail/views.py b/src/pretix/plugins/sendmail/views.py index 198cce98c..8e3a469cc 100644 --- a/src/pretix/plugins/sendmail/views.py +++ b/src/pretix/plugins/sendmail/views.py @@ -13,6 +13,7 @@ from django.views.generic import FormView, ListView from pretix.base.i18n import LazyI18nString, language from pretix.base.models import InvoiceAddress, LogEntry, Order +from pretix.base.models.event import SubEvent from pretix.base.services.mail import SendMailException, mail from pretix.control.permissions import EventPermissionRequiredMixin from pretix.multidomain.urlreverse import build_absolute_uri @@ -43,6 +44,13 @@ class SenderView(EventPermissionRequiredMixin, FormView): 'subject': LazyI18nString(logentry.parsed_data['subject']), 'sendto': logentry.parsed_data['sendto'], } + if logentry.parsed_data.get('subevent'): + try: + kwargs['initial']['subevent'] = self.request.event.subevents.get( + pk=logentry.parsed_data['subevent']['id'] + ) + except SubEvent.DoesNotExist: + pass except LogEntry.DoesNotExist: raise Http404(_('You supplied an invalid log entry ID')) return kwargs @@ -57,6 +65,8 @@ class SenderView(EventPermissionRequiredMixin, FormView): if 'overdue' in form.cleaned_data['sendto']: statusq |= Q(status=Order.STATUS_PENDING, expires__lt=now()) orders = qs.filter(statusq) + if form.cleaned_data.get('subevent'): + orders = orders.filter(positions__subevent__in=(form.cleaned_data.get('subevent'),)).distinct() tz = pytz.timezone(self.request.event.settings.timezone) @@ -119,7 +129,7 @@ class SenderView(EventPermissionRequiredMixin, FormView): data={ 'subject': form.cleaned_data['subject'], 'message': form.cleaned_data['message'], - 'recipient': o.email + 'recipient': o.email, } ) except SendMailException: @@ -175,5 +185,10 @@ class EmailHistoryView(EventPermissionRequiredMixin, ListView): log.pdata['sendto'] = [ status[s] for s in log.pdata['sendto'] ] + if log.pdata.get('subevent'): + try: + log.pdata['subevent_obj'] = self.request.event.subevents.get(pk=log.pdata['subevent']['id']) + except SubEvent.DoesNotExist: + pass return ctx diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index 821e7fda7..056460173 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -91,7 +91,7 @@ class Stripe(BasePaymentProvider): return template.render(ctx) def order_can_retry(self, order): - return self._is_still_available() + return self._is_still_available(order=order) def _charge_source(self, source, order): try: diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index fd354eb93..ddccf4646 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -186,7 +186,8 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): initial=current_addon_products, data=(self.request.POST if self.request.method == 'POST' else None), quota_cache=quota_cache, - item_cache=item_cache + item_cache=item_cache, + subevent=cartpos.subevent ) } diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index e49ebb726..c1ce0e804 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -10,6 +10,7 @@ from django.utils.formats import number_format from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ +from pretix.base.decimal import round_decimal from pretix.base.models import ItemVariation, Question from pretix.base.models.orders import InvoiceAddress, OrderPosition from pretix.base.templatetags.rich_text import rich_text @@ -274,7 +275,7 @@ class AddOnsForm(forms.Form): This form class is responsible for selecting add-ons to a product in the cart. """ - def _label(self, event, item_or_variation, avail): + def _label(self, event, item_or_variation, avail, override_price=None): if isinstance(item_or_variation, ItemVariation): variation = item_or_variation item = item_or_variation.item @@ -287,6 +288,11 @@ class AddOnsForm(forms.Form): price_net = item.default_price_net label = item.name + if override_price: + price = override_price + tax_value = round_decimal(price * (1 - 100 / (100 + item.tax_rate))) + price_net = price - tax_value + if not price: n = '{name}'.format( name=label @@ -319,19 +325,29 @@ class AddOnsForm(forms.Form): :param category: The category to choose from :param event: The event this belongs to + :param subevent: The event the parent cart position belongs to :param initial: The current set of add-ons :param quota_cache: A shared dictionary for quota caching :param item_cache: A shared dictionary for item/category caching """ category = kwargs.pop('category') event = kwargs.pop('event') + subevent = kwargs.pop('subevent') current_addons = kwargs.pop('initial') quota_cache = kwargs.pop('quota_cache') item_cache = kwargs.pop('item_cache') super().__init__(*args, **kwargs) - if category.pk not in item_cache: + if subevent: + item_price_override = subevent.item_price_overrides + var_price_override = subevent.var_price_overrides + else: + item_price_override = {} + var_price_override = {} + + ckey = '{}-{}'.format(subevent.pk if subevent else 0, category.pk) + if ckey not in item_cache: # Get all items to possibly show items = category.items.filter( Q(active=True) @@ -339,26 +355,37 @@ class AddOnsForm(forms.Form): & Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) & Q(hide_without_voucher=False) ).prefetch_related( - 'variations__quotas', # for .availability() - Prefetch('quotas', queryset=event.quotas.all()), + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.filter(subevent=subevent)), Prefetch('variations', to_attr='available_variations', - queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).distinct()), + queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).prefetch_related( + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.filter(subevent=subevent)) + ).distinct()), ).annotate( quotac=Count('quotas'), has_variations=Count('variations') ).filter( quotac__gt=0 ).order_by('category__position', 'category_id', 'position', 'name') - item_cache[category.pk] = items + item_cache[ckey] = items else: - items = item_cache[category.pk] + items = item_cache[ckey] for i in items: if i.has_variations: choices = [('', _('no selection'), '')] for v in i.available_variations: - cached_availability = v.check_quotas(_cache=quota_cache) - choices.append((v.pk, self._label(event, v, cached_availability), v.description)) + cached_availability = v.check_quotas(subevent=subevent, _cache=quota_cache) + if v._subevent_quotas: + choices.append( + (v.pk, + self._label(event, v, cached_availability, + override_price=var_price_override.get(v.pk)), + v.description) + ) field = AddOnVariationField( choices=choices, @@ -368,13 +395,17 @@ class AddOnsForm(forms.Form): help_text=rich_text(str(i.description)), initial=current_addons.get(i.pk), ) + if len(choices) > 1: + self.fields['item_%s' % i.pk] = field else: - cached_availability = i.check_quotas(_cache=quota_cache) + if not i._subevent_quotas: + continue + cached_availability = i.check_quotas(subevent=subevent, _cache=quota_cache) field = forms.BooleanField( - label=self._label(event, i, cached_availability), + label=self._label(event, i, cached_availability, + override_price=item_price_override.get(i.pk)), required=False, initial=i.pk in current_addons, help_text=rich_text(str(i.description)), ) - - self.fields['item_%s' % i.pk] = field + self.fields['item_%s' % i.pk] = field diff --git a/src/pretix/presale/templates/pretixpresale/event/base.html b/src/pretix/presale/templates/pretixpresale/event/base.html index f55f5134b..9e6ea379a 100644 --- a/src/pretix/presale/templates/pretixpresale/event/base.html +++ b/src/pretix/presale/templates/pretixpresale/event/base.html @@ -30,7 +30,9 @@ {% else %}

    {{ event.name }} - {{ event.get_date_range_display }} + {% if not event.has_subevents %} + {{ event.get_date_range_display }} + {% endif %}

    {% endif %}
  • diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_availability.html b/src/pretix/presale/templates/pretixpresale/event/fragment_availability.html index f97bdefe1..fff6719bc 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_availability.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_availability.html @@ -5,7 +5,7 @@ {% trans "SOLD OUT" %} {% if event.settings.waiting_list_enabled %}
    - + {% trans "Waiting list" %} @@ -19,7 +19,7 @@ {% if event.settings.waiting_list_enabled %}
    - + {% trans "Waiting list" %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index b9e0cefba..7c7459ddb 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -13,6 +13,9 @@ {% if line.voucher %}
    {% trans "Voucher code used:" %} {{ line.voucher.code }} {% endif %} + {% if line.subevent %} +
    {{ line.subevent.name }} · {{ line.subevent.get_date_range_display }} + {% endif %} {% if line.has_questions %} @@ -83,6 +86,7 @@ {% if editable %} + {% csrf_token %} {% if line.variation %} +
    + +
    + + + +
    + +
    + +{% include "pretixpresale/fragment_calendar.html" %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_list.html b/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_list.html new file mode 100644 index 000000000..492a7409a --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_list.html @@ -0,0 +1,34 @@ +{% load i18n %} +{% load eventurl %} +{% for subev in event.active_future_subevents %} + +
    +
    + {{ subev.name }} +
    +
    + + {{ subev.get_date_range_display }} + {% if event.settings.show_times %} + + {{ subev.date_from|date:"TIME_FORMAT" }} + {% endif %} +
    +
    + {% if subev.presale_is_running %} + {% trans "Tickets on sale" %} + {% elif subev.presale_has_ended %} + {% trans "Sale over" %} + {% elif event.settings.presale_start_show_date %} + + {% blocktrans trimmed with date=subev.presale_start|date:"SHORT_DATE_FORMAT" %} + Sale starts {{ date }} + {% endblocktrans %} + + {% else %} + {% trans "Not yet on sale" %} + {% endif %} +
    +
    +
    +{% endfor %} diff --git a/src/pretix/presale/templates/pretixpresale/event/index.html b/src/pretix/presale/templates/pretixpresale/event/index.html index dccf075d0..869a4c914 100644 --- a/src/pretix/presale/templates/pretixpresale/event/index.html +++ b/src/pretix/presale/templates/pretixpresale/event/index.html @@ -8,7 +8,7 @@ {% block title %}{% trans "Presale" %}{% endblock %} {% block content %} - {% if cart.positions and event.presale_is_running %} + {% if show_cart %}

    {% trans "Your cart" %}

    @@ -48,87 +48,224 @@
    {% endif %} - {% if not event.presale_is_running %} -
    - {% if event.presale_has_ended %} - {% blocktrans trimmed %} - The presale period for this event is over. - {% endblocktrans %} - {% elif event.settings.presale_start_show_date %} - {% blocktrans trimmed with date=event.presale_start|date:"SHORT_DATE_FORMAT" time=event.presale_start|time:"TIME_FORMAT" %} - The presale for this event will start on {{ date }} at {{ time }}. - {% endblocktrans %} + + {% if event.has_subevents %} + {% if subevent %} + + {% trans "View other date" %} + + {% else %} +

    {% trans "Choose date to buy a ticket" %}

    + {% endif %} +
    + {% if event.settings.event_list_type == "calendar" %} + {% include "pretixpresale/event/fragment_subevent_calendar.html" %} {% else %} - {% blocktrans trimmed %} - The presale for this event has not yet started. - {% endblocktrans %} + {% include "pretixpresale/event/fragment_subevent_list.html" %} {% endif %}
    + {% if subevent %} +

    {{ subevent.name }}

    + {% endif %} {% endif %} -
    - {% if frontpage_text %} -
    - {{ frontpage_text|rich_text }} -
    - {% endif %} + {% if frontpage_text %} +
    + {{ frontpage_text|rich_text }} +
    + {% endif %} - {% if event.location %} -
    - -

    - {{ event.location|linebreaksbr }} -

    -
    - {% endif %} -
    - -

    - {{ event.get_date_range_display }} - {% if event.settings.show_times %} -
    - {% blocktrans trimmed with time=event.date_from|date:"TIME_FORMAT" %} - Begin: {{ time }} + {% if subevent or not event.has_subevents %} + {% if not ev.presale_is_running %} +

    + {% if ev.presale_has_ended %} + {% blocktrans trimmed %} + The presale period for this event is over. + {% endblocktrans %} + {% elif event.settings.presale_start_show_date %} + {% blocktrans trimmed with date=ev.presale_start|date:"SHORT_DATE_FORMAT" time=ev.presale_start|time:"TIME_FORMAT" %} + The presale for this event will start on {{ date }} at {{ time }}. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed %} + The presale for this event has not yet started. {% endblocktrans %} {% endif %} - {% if event.date_admission %} -
    - {% if event.date_admission|date:"SHORT_DATE_FORMAT" == event.date_from|date:"SHORT_DATE_FORMAT" %} - {% blocktrans trimmed with time=event.date_admission|date:"TIME_FORMAT" %} - Admission: {{ time }} - {% endblocktrans %} - {% else %} - {% blocktrans trimmed with datetime=event.date_admission|date:"SHORT_DATETIME_FORMAT" %} - Admission: {{ datetime }} +
    + {% endif %} +
    + {% if ev.location %} +
    + +

    + {{ ev.location|linebreaksbr }} +

    +
    + {% endif %} +
    + +

    + {{ ev.get_date_range_display }} + {% if event.settings.show_times %} +
    + {% blocktrans trimmed with time=ev.date_from|date:"TIME_FORMAT" %} + Begin: {{ time }} {% endblocktrans %} {% endif %} - {% endif %} -
    - - {% trans "Add to Calendar" %} - -

    -
    - -
    - - {% eventsignal event "pretix.presale.signals.front_page_top" %} - {% if event.presale_is_running or event.settings.show_items_outside_presale_period %} -
    - {% csrf_token %} - {% for tup in items_by_category %} -
    - {% if tup.0 %} -

    {{ tup.0.name }}

    - {% if tup.0.description %} -

    {{ tup.0.description|localize|rich_text }}

    + {% if ev.date_admission %} +
    + {% if ev.date_admission|date:"SHORT_DATE_FORMAT" == ev.date_from|date:"SHORT_DATE_FORMAT" %} + {% blocktrans trimmed with time=ev.date_admission|date:"TIME_FORMAT" %} + Admission: {{ time }} + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with datetime=ev.date_admission|date:"SHORT_DATETIME_FORMAT" %} + Admission: {{ datetime }} + {% endblocktrans %} {% endif %} {% endif %} - {% for item in tup.1 %} - {% if item.has_variations %} - + + {% eventsignal event "pretix.presale.signals.front_page_top" %} + {% if ev.presale_is_running or event.settings.show_items_outside_presale_period %} + + {% csrf_token %} + + {% for tup in items_by_category %} +
    + {% if tup.0 %} +

    {{ tup.0.name }}

    + {% if tup.0.description %} +

    {{ tup.0.description|localize|rich_text }}

    + {% endif %} + {% endif %} + {% for item in tup.1 %} + {% if item.has_variations %} +
    +
    +
    + {% if item.picture %} + + {{ item.name }} + + {% endif %} + + {{ item.name }} + + {% if item.description %} +
    + {{ item.description|localize|rich_text }} +
    + {% endif %} + {% if item.min_per_order %} +

    + + {% blocktrans trimmed with num=item.min_per_order %} + minimum amount to order: {{ num }} + {% endblocktrans %} + +

    + {% endif %} +
    +
    + {% if item.min_price != item.max_price or item.free_price %} + {% blocktrans trimmed with minprice=item.min_price|floatformat:2 currency=event.currency %} + from {{ currency }} {{ minprice }} + {% endblocktrans %} + {% else %} + {{ event.currency }} {{ item.min_price|floatformat:2 }} + {% endif %} +
    +
    + {% if not event.settings.show_variations_expanded %} + + {% trans "Show variants" %} + + {% endif %} +
    +
    +
    +
    + {% for var in item.available_variations %} +
    +
    + {{ var }} + {% if var.description %} +
    + {{ var.description|localize|rich_text }} +
    + {% endif %} + {% if event.settings.show_quota_left %} + {% include "pretixpresale/event/fragment_quota_left.html" with avail=var.cached_availability %} + {% endif %} +
    +
    + {% if item.free_price %} +
    + {{ event.currency }} + +
    + {% else %} + {{ event.currency }} {{ var.display_price|floatformat:2 }} + {% endif %} + {% if item.tax_rate and event.settings.display_net_prices %} + {% blocktrans trimmed with rate=item.tax_rate %} + plus {{ rate }}% taxes + {% endblocktrans %} + {% elif item.tax_rate %} + {% blocktrans trimmed with rate=item.tax_rate %} + incl. {{ rate }}% taxes + {% endblocktrans %} + {% endif %} +
    + {% if item.require_voucher %} +
    + + {% trans "Enter a voucher code below to buy this ticket." %} + +
    + {% elif var.cached_availability.0 == 100 %} +
    + {% if item.max_per_order == 1 %} + + {% else %} + + {% endif %} +
    + {% else %} + {% include "pretixpresale/event/fragment_availability.html" with avail=var.cached_availability.0 event=event item=item var=var %} + {% endif %} +
    +
    + {% endfor %} +
    +
    + {% else %} +
    {% if item.picture %} {% endif %} - - {{ item.name }} - + {{ item.name }} {% if item.description %}
    {{ item.description|localize|rich_text }}
    {% endif %} + {% if event.settings.show_quota_left %} + {% include "pretixpresale/event/fragment_quota_left.html" with avail=item.cached_availability %} + {% endif %} {% if item.min_per_order %}

    @@ -157,179 +295,68 @@ {% endif %}

    - {% if item.min_price != item.max_price or item.free_price %} - {% blocktrans trimmed with minprice=item.min_price|floatformat:2 currency=event.currency %} - from {{ currency }} {{ minprice }} - {% endblocktrans %} + {% if item.free_price %} +
    + {{ event.currency }} + +
    {% else %} - {{ event.currency }} {{ item.min_price|floatformat:2 }} + {{ event.currency }} {{ item.display_price|floatformat:2 }} + {% endif %} + {% if item.tax_rate and event.settings.display_net_prices %} + {% blocktrans trimmed with rate=item.tax_rate %} + plus {{ rate }}% taxes + {% endblocktrans %} + {% elif item.tax_rate %} + {% blocktrans trimmed with rate=item.tax_rate %} + incl. {{ rate }}% taxes + {% endblocktrans %} {% endif %}
    -
    - {% if not event.settings.show_variations_expanded %} - - {% trans "Show variants" %} - - {% endif %} -
    -
    -
    -
    - {% for var in item.available_variations %} -
    -
    - {{ var }} - {% if var.description %} -
    - {{ var.description|localize|rich_text }} -
    - {% endif %} - {% if event.settings.show_quota_left %} - {% include "pretixpresale/event/fragment_quota_left.html" with avail=var.cached_availability %} - {% endif %} -
    -
    - {% if item.free_price %} -
    - {{ event.currency }} - -
    - {% else %} - {{ event.currency }} {{ var.display_price|floatformat:2 }} - {% endif %} - {% if item.tax_rate and event.settings.display_net_prices %} - {% blocktrans trimmed with rate=item.tax_rate %} - plus {{ rate }}% taxes - {% endblocktrans %} - {% elif item.tax_rate %} - {% blocktrans trimmed with rate=item.tax_rate %} - incl. {{ rate }}% taxes - {% endblocktrans %} - {% endif %} -
    - {% if item.require_voucher %} -
    - - {% trans "Enter a voucher code below to buy this ticket." %} - -
    - {% elif var.cached_availability.0 == 100 %} -
    - {% if item.max_per_order == 1 %} - - {% else %} - - {% endif %} -
    - {% else %} - {% include "pretixpresale/event/fragment_availability.html" with avail=var.cached_availability.0 event=event item=item var=var %} - {% endif %} -
    -
    - {% endfor %} -
    -
    - {% else %} -
    -
    - {% if item.picture %} - - {{ item.name }} - - {% endif %} - {{ item.name }} - {% if item.description %} -
    - {{ item.description|localize|rich_text }} -
    - {% endif %} - {% if event.settings.show_quota_left %} - {% include "pretixpresale/event/fragment_quota_left.html" with avail=item.cached_availability %} - {% endif %} - {% if item.min_per_order %} -

    + {% if item.require_voucher %} +

    - {% blocktrans trimmed with num=item.min_per_order %} - minimum amount to order: {{ num }} - {% endblocktrans %} + {% trans "Enter a voucher code below to buy this ticket." %} -

    - {% endif %} -
    -
    - {% if item.free_price %} -
    - {{ event.currency }} - +
    + {% elif item.cached_availability.0 == 100 %} +
    + {% if item.max_per_order == 1 %} + + {% else %} + + {% endif %}
    {% else %} - {{ event.currency }} {{ item.display_price|floatformat:2 }} - {% endif %} - {% if item.tax_rate and event.settings.display_net_prices %} - {% blocktrans trimmed with rate=item.tax_rate %} - plus {{ rate }}% taxes - {% endblocktrans %} - {% elif item.tax_rate %} - {% blocktrans trimmed with rate=item.tax_rate %} - incl. {{ rate }}% taxes - {% endblocktrans %} + {% include "pretixpresale/event/fragment_availability.html" with avail=item.cached_availability.0 event=event item=item var=0 %} {% endif %} +
    - {% if item.require_voucher %} -
    - - {% trans "Enter a voucher code below to buy this ticket." %} - -
    - {% elif item.cached_availability.0 == 100 %} -
    - {% if item.max_per_order == 1 %} - - {% else %} - - {% endif %} -
    - {% else %} - {% include "pretixpresale/event/fragment_availability.html" with avail=item.cached_availability.0 event=event item=item var=0 %} - {% endif %} -
    + {% endif %} + {% endfor %} + + {% endfor %} + {% if ev.presale_is_running and display_add_to_cart %} +
    +
    +
    +
    - {% endif %} - {% endfor %} -
    - {% endfor %} - {% if event.presale_is_running and display_add_to_cart %} -
    -
    -
    - +
    -
    -
    -
    - {% endif %} - + + {% endif %} + + {% endif %} {% endif %} {% if vouchers_exist %}
    @@ -343,6 +370,7 @@ placeholder="{% trans "Voucher code" %}">
    +
    + {% if subevent %} +
    + +
    + +
    +
    + {% endif %} {% bootstrap_form form layout='horizontal' %}
    diff --git a/src/pretix/presale/templates/pretixpresale/fragment_calendar.html b/src/pretix/presale/templates/pretixpresale/fragment_calendar.html new file mode 100644 index 000000000..1a4da7106 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/fragment_calendar.html @@ -0,0 +1,70 @@ +{% load i18n %} +
    + + + + + + + + + + + + + + {% for week in weeks %} + + {% for day in week %} + {% if day %} + + {% else %} + + {% endif %} + {% endfor %} + + {% endfor %} + + + + +
    {{ weeks.1.0.date|date:"D" }}{{ weeks.1.1.date|date:"D" }}{{ weeks.1.2.date|date:"D" }}{{ weeks.1.3.date|date:"D" }}{{ weeks.1.4.date|date:"D" }}{{ weeks.1.5.date|date:"D" }}{{ weeks.1.6.date|date:"D" }}
    +

    {{ day.day }}

    + +
    +
    diff --git a/src/pretix/presale/templates/pretixpresale/organizers/calendar.html b/src/pretix/presale/templates/pretixpresale/organizers/calendar.html index 8b8fd78ce..c2634209a 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/calendar.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/calendar.html @@ -10,83 +10,41 @@ {% endif %}

    {{ date|date:"F Y" }}

    -
    -
    - - - {{ before|date:"F Y" }} - + +
    + +
    + + + +
    +
    - -
    -
    - - - - - - - - - - - - - - {% for week in weeks %} - - {% for day in week %} - {% if day %} - - {% else %} - - {% endif %} - {% endfor %} - - {% endfor %} - -
    {{ weeks.1.0.date|date:"D" }}{{ weeks.1.1.date|date:"D" }}{{ weeks.1.2.date|date:"D" }}{{ weeks.1.3.date|date:"D" }}{{ weeks.1.4.date|date:"D" }}{{ weeks.1.5.date|date:"D" }}{{ weeks.1.6.date|date:"D" }}
    -

    {{ day.day }}

    - {% for event in day.events %} - - - {{ event.event.name }} - - {% if not event.continued %} - {% if event.time %} - - - {{ event.time|date:"TIME_FORMAT" }} - {% if multiple_timezones %} - {{ event.timezone }} - {% endif %} - - {% endif %} - - {% if event.event.presale_is_running %} - {% trans "Tickets on sale" %} - {% elif event.event.presale_has_ended %} - {% trans "Sale over" %} - {% elif event.event.settings.presale_start_show_date and event.event.presale_start %} - - {% blocktrans with start_date=event.event.presale_start|date:"SHORT_DATE_FORMAT" %} - from {{ start_date }} - {% endblocktrans %} - {% else %} - {% trans "Soon" %} - {% endif %} - - {% endif %} - - {% endfor %} -
    + + + {% include "pretixpresale/fragment_calendar.html" %} {% if multiple_timezones %}
    {% blocktrans trimmed %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index e76a742a1..cff11ab40 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -66,7 +66,11 @@ event_patterns = [ url(r'^ical/?$', pretix.presale.views.event.EventIcalDownload.as_view(), name='event.ical.download'), + url(r'^ical/(?P[0-9]+)/$', + pretix.presale.views.event.EventIcalDownload.as_view(), + name='event.ical.download'), url(r'^auth/$', pretix.presale.views.event.EventAuth.as_view(), name='event.auth'), + url(r'^(?P[0-9]+)/$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'), url(r'^$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'), ] diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 60b046d4d..ff67d0bf2 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -30,7 +30,7 @@ class CartMixin: cartpos = queryset.order_by( 'item', 'variation' ).select_related( - 'item', 'variation', 'addon_to' + 'item', 'variation', 'addon_to', 'subevent', 'subevent__event', 'subevent__event__organizer' ).prefetch_related( *prefetch ) @@ -73,11 +73,14 @@ class CartMixin: ) addon_penalty = 1 if pos.addon_to else 0 if downloads or pos.pk in has_addons or pos.addon_to: - return i, addon_penalty, pos.pk, 0, 0, 0, 0, + return i, addon_penalty, pos.pk, 0, 0, 0, 0, (pos.subevent_id or 0) if answers and (has_attendee_data or pos.item.questions.all()): - return i, addon_penalty, pos.pk, 0, 0, 0, 0, + return i, addon_penalty, pos.pk, 0, 0, 0, 0, (pos.subevent_id or 0) - return 0, addon_penalty, 0, pos.item_id, pos.variation_id, pos.price, (pos.voucher_id or 0) + return ( + 0, addon_penalty, 0, pos.item_id, pos.variation_id, pos.price, (pos.voucher_id or 0), + (pos.subevent_id or 0) + ) positions = [] for k, g in groupby(sorted(lcp, key=keyfunc), key=keyfunc): @@ -144,7 +147,7 @@ def get_cart(request): ).order_by( 'item', 'variation' ).select_related( - 'item', 'variation' + 'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer' ).prefetch_related( 'item__questions', 'answers' ) diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 8bce1a33c..21fbfa3d7 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -2,7 +2,7 @@ import mimetypes import os from django.contrib import messages -from django.db.models import Count, Q +from django.db.models import Count, Prefetch, Q from django.http import FileResponse, Http404, JsonResponse from django.shortcuts import get_object_or_404, redirect from django.utils import translation @@ -11,7 +11,9 @@ from django.utils.translation import ugettext as _ from django.views.generic import TemplateView, View from pretix.base.decimal import round_decimal -from pretix.base.models import CartPosition, QuestionAnswer, Quota, Voucher +from pretix.base.models import ( + CartPosition, ItemVariation, QuestionAnswer, Quota, SubEvent, Voucher, +) from pretix.base.services.cart import ( CartError, add_items_to_cart, clear_cart, remove_cart_position, ) @@ -60,7 +62,8 @@ class CartActionMixin: 'variation': None, 'count': amount, 'price': price, - 'voucher': voucher + 'voucher': voucher, + 'subevent': self.request.POST.get("subevent") } except ValueError: raise CartError(_('Please enter numbers only.')) @@ -71,7 +74,8 @@ class CartActionMixin: 'variation': int(parts[2]), 'count': amount, 'price': price, - 'voucher': voucher + 'voucher': voucher, + 'subevent': self.request.POST.get("subevent") } except ValueError: raise CartError(_('Please enter numbers only.')) @@ -188,10 +192,29 @@ class RedeemView(EventViewMixin, TemplateView): items = items.filter(vouchq).select_related( 'category', # for re-grouping ).prefetch_related( - 'quotas', 'variations__quotas', 'quotas__event' # for .availability() - ).annotate(quotac=Count('quotas')).filter( + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=self.request.event.quotas.filter(subevent=self.subevent)), + Prefetch('variations', to_attr='avail_variations', + queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).prefetch_related( + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=self.request.event.quotas.filter(subevent=self.subevent)) + ).distinct()), + ).annotate( + quotac=Count('quotas'), + has_variations=Count('variations') + ).filter( quotac__gt=0 ).distinct().order_by('category__position', 'category_id', 'position', 'name') + quota_cache = {} + + if self.subevent: + item_price_override = self.subevent.item_price_overrides + var_price_override = self.subevent.var_price_overrides + else: + item_price_override = {} + var_price_override = {} for item in items: item.available_variations = list(item.variations.filter(active=True, quotas__isnull=False).distinct()) @@ -202,34 +225,49 @@ class RedeemView(EventViewMixin, TemplateView): item.has_variations = item.variations.exists() if not item.has_variations: + item._remove = not bool(item._subevent_quotas) if self.voucher.allow_ignore_quota or self.voucher.block_quota: item.cached_availability = (Quota.AVAILABILITY_OK, 1) else: - item.cached_availability = item.check_quotas() - item.price = self.voucher.calculate_price(item.default_price) + item.cached_availability = item.check_quotas(subevent=self.subevent, _cache=quota_cache) + + item.price = item_price_override.get(item.pk, item.default_price) + item.price = self.voucher.calculate_price(item.price) if self.request.event.settings.display_net_prices: item.price -= round_decimal(item.price * (1 - 100 / (100 + item.tax_rate))) else: - for var in item.available_variations: + item._remove = False + for var in item.avail_variations: if self.voucher.allow_ignore_quota or self.voucher.block_quota: var.cached_availability = (Quota.AVAILABILITY_OK, 1) else: - var.cached_availability = list(var.check_quotas()) - var.display_price = self.voucher.calculate_price(var.price) + var.cached_availability = list(var.check_quotas(subevent=self.subevent, _cache=quota_cache)) + + var.display_price = var_price_override.get(var.pk, var.price) + var.display_price = self.voucher.calculate_price(var.display_price) if self.request.event.settings.display_net_prices: var.display_price -= round_decimal(var.display_price * (1 - 100 / (100 + item.tax_rate))) + item.available_variations = [ + v for v in item.avail_variations if v._subevent_quotas + ] + if self.voucher.variation_id: + item.available_variations = [v for v in item.available_variations + if v.pk == self.voucher.variation_id] if len(item.available_variations) > 0: - item.min_price = min([v.display_price for v in item.available_variations]) - item.max_price = max([v.display_price for v in item.available_variations]) + item.min_price = min([v.display_price for v in item.avail_variations]) + item.max_price = max([v.display_price for v in item.avail_variations]) - items = [item for item in items if len(item.available_variations) > 0 or not item.has_variations] + items = [item for item in items + if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove] context['options'] = sum([(len(item.available_variations) if item.has_variations else 1) for item in items]) # Regroup those by category context['items_by_category'] = item_group_by_category(items) + context['subevent'] = self.subevent + return context def dispatch(self, request, *args, **kwargs): @@ -264,6 +302,17 @@ class RedeemView(EventViewMixin, TemplateView): if request.event.presale_end and now() > request.event.presale_end: err = error_messages['ended'] + self.subevent = None + if request.event.has_subevents: + if 'subevent' in request.GET: + self.subevent = get_object_or_404(SubEvent, event=request.event, pk=request.GET.get('subevent'), + active=True) + + if self.voucher.subevent: + self.subevent = self.voucher.subevent + else: + pass + if err: messages.error(request, _(err)) return redirect(eventreverse(request.event, 'presale:event.index')) diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index bdf73b44f..7f1e49b67 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -1,5 +1,7 @@ +import calendar import sys -from datetime import datetime +from collections import defaultdict +from datetime import date, datetime, timedelta from importlib import import_module import pytz @@ -8,19 +10,24 @@ from django.conf import settings from django.core.exceptions import PermissionDenied from django.db.models import Count, Prefetch, Q from django.http import Http404, HttpResponse -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.utils.decorators import method_decorator from django.utils.formats import date_format 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 django.views import View from django.views.decorators.csrf import csrf_exempt from django.views.generic import TemplateView from pytz import timezone +from pretix.base.decimal import round_decimal from pretix.base.models import ItemVariation +from pretix.base.models.event import SubEvent from pretix.multidomain.urlreverse import eventreverse +from pretix.presale.views.organizer import ( + add_subevents_for_days, weeks_for_template, +) from . import CartMixin, EventViewMixin, get_cart @@ -41,7 +48,7 @@ def item_group_by_category(items): ) -def get_grouped_items(event): +def get_grouped_items(event, subevent=None): items = event.items.all().filter( Q(active=True) & Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) @@ -51,11 +58,15 @@ def get_grouped_items(event): ).select_related( 'category', # for re-grouping ).prefetch_related( - 'variations__quotas', # for .availability() Prefetch('quotas', - queryset=event.quotas.all()), + to_attr='_subevent_quotas', + queryset=event.quotas.filter(subevent=subevent)), Prefetch('variations', to_attr='available_variations', - queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).distinct()), + queryset=ItemVariation.objects.filter(active=True, quotas__isnull=False).prefetch_related( + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.filter(subevent=subevent)) + ).distinct()), ).annotate( quotac=Count('quotas'), has_variations=Count('variations') @@ -64,54 +75,156 @@ def get_grouped_items(event): ).order_by('category__position', 'category_id', 'position', 'name') display_add_to_cart = False quota_cache = {} + + if subevent: + item_price_override = subevent.item_price_overrides + var_price_override = subevent.var_price_overrides + else: + item_price_override = {} + var_price_override = {} + for item in items: max_per_order = item.max_per_order or int(event.settings.max_items_per_order) if not item.has_variations: - item.cached_availability = list(item.check_quotas(_cache=quota_cache)) + item._remove = not bool(item._subevent_quotas) + item.cached_availability = list(item.check_quotas(subevent=subevent, _cache=quota_cache)) item.order_max = min(item.cached_availability[1] if item.cached_availability[1] is not None else sys.maxsize, max_per_order) item.price = item.default_price - item.display_price = item.default_price_net if event.settings.display_net_prices else item.price + + if event.settings.display_net_prices: + if item_price_override.get(item.pk): + _p = item_price_override.get(item.pk) + tax_value = round_decimal(_p * (1 - 100 / (100 + item.tax_rate))) + item.display_price = _p - tax_value + else: + item.display_price = item.default_price_net + else: + item.display_price = item_price_override.get(item.pk, item.price) display_add_to_cart = display_add_to_cart or item.order_max > 0 else: for var in item.available_variations: - var.cached_availability = list(var.check_quotas(_cache=quota_cache)) + var.cached_availability = list(var.check_quotas(subevent=subevent, _cache=quota_cache)) var.order_max = min(var.cached_availability[1] if var.cached_availability[1] is not None else sys.maxsize, max_per_order) - var.display_price = var.net_price if event.settings.display_net_prices else var.price + + if event.settings.display_net_prices: + if var_price_override.get(var.pk): + _p = var_price_override.get(var.pk) + tax_value = round_decimal(_p * (1 - 100 / (100 + item.tax_rate))) + var.display_price = _p - tax_value + else: + var.display_price = var.net_price + else: + var.display_price = var_price_override.get(var.pk, var.price) + display_add_to_cart = display_add_to_cart or var.order_max > 0 + + item.available_variations = [ + v for v in item.available_variations if v._subevent_quotas + ] if len(item.available_variations) > 0: item.min_price = min([v.display_price for v in item.available_variations]) item.max_price = max([v.display_price for v in item.available_variations]) + item._remove = not bool(item.available_variations) - items = [item for item in items if len(item.available_variations) > 0 or not item.has_variations] + items = [item for item in items + if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove] return items, display_add_to_cart class EventIndex(EventViewMixin, CartMixin, TemplateView): template_name = "pretixpresale/event/index.html" + def get(self, request, *args, **kwargs): + self.subevent = None + if request.event.has_subevents: + if 'subevent' in kwargs: + self.subevent = request.event.subevents.filter(pk=kwargs['subevent'], active=True).first() + if not self.subevent: + raise Http404() + return super().get(request, *args, **kwargs) + else: + return super().get(request, *args, **kwargs) + else: + if 'subevent' in kwargs: + return redirect(eventreverse(request.event, 'presale:event.index')) + else: + return super().get(request, *args, **kwargs) + + def _set_month_year(self): + tz = pytz.timezone(self.request.event.settings.timezone) + if self.subevent: + self.year = self.subevent.date_from.astimezone(tz).year + self.month = self.subevent.date_from.astimezone(tz).month + elif 'year' in self.request.GET and 'month' in self.request.GET: + try: + self.year = int(self.request.GET.get('year')) + self.month = int(self.request.GET.get('month')) + except ValueError: + self.year = now().year + self.month = now().month + else: + next_sev = self.request.event.subevents.filter( + active=True, + date_from__gte=now() + ).select_related('event').order_by('date_from').first() + + if next_sev: + datetime_from = next_sev.date_from + self.year = datetime_from.astimezone(tz).year + self.month = datetime_from.astimezone(tz).month + else: + self.year = now().year + self.month = now().month + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - # Fetch all items - items, display_add_to_cart = get_grouped_items(self.request.event) + if not self.request.event.has_subevents or self.subevent: + # Fetch all items + items, display_add_to_cart = get_grouped_items(self.request.event, self.subevent) - # Regroup those by category - context['items_by_category'] = item_group_by_category(items) - context['display_add_to_cart'] = display_add_to_cart + # Regroup those by category + context['items_by_category'] = item_group_by_category(items) + context['display_add_to_cart'] = display_add_to_cart + context['subevent'] = self.subevent + context['cart'] = self.get_cart() + context['has_addon_choices'] = get_cart(self.request).filter(item__addons__isnull=False).exists() vouchers_exist = self.request.event.get_cache().get('vouchers_exist') if vouchers_exist is None: vouchers_exist = self.request.event.vouchers.exists() self.request.event.get_cache().set('vouchers_exist', vouchers_exist) context['vouchers_exist'] = vouchers_exist - - context['cart'] = self.get_cart() - context['has_addon_choices'] = get_cart(self.request).filter(item__addons__isnull=False).exists() - + context['ev'] = self.subevent or self.request.event context['frontpage_text'] = str(self.request.event.settings.frontpage_text) + + if self.request.event.settings.event_list_type == "calendar": + self._set_month_year() + tz = pytz.timezone(self.request.event.settings.timezone) + _, ndays = calendar.monthrange(self.year, self.month) + before = datetime(self.year, self.month, 1, 0, 0, 0, tzinfo=tz) - timedelta(days=1) + after = datetime(self.year, self.month, ndays, 0, 0, 0, tzinfo=tz) + timedelta(days=1) + + context['date'] = date(self.year, self.month, 1) + context['before'] = before + context['after'] = after + + ebd = defaultdict(list) + add_subevents_for_days(self.request.event.subevents.all(), before, after, ebd, set(), self.request.event) + + context['weeks'] = weeks_for_template(ebd, self.year, self.month) + context['months'] = [date(self.year, i + 1, 1) for i in range(12)] + context['years'] = range(now().year - 2, now().year + 3) + + context['show_cart'] = ( + context['cart']['positions'] and ( + self.request.event.has_subevents or self.request.event.presale_is_running + ) + ) + return context @@ -125,39 +238,52 @@ class EventIcalDownload(EventViewMixin, View): if not self.request.event: raise Http404(_('Unknown event code or not authorized to access this event.')) + subevent = None + if request.event.has_subevents: + if 'subevent' in kwargs: + subevent = get_object_or_404(SubEvent, event=request.event, pk=kwargs['subevent'], active=True) + else: + raise Http404(pgettext_lazy('subevent', 'No date selected.')) + else: + if 'subevent' in kwargs: + raise Http404(pgettext_lazy('subevent', 'Unknown date selected.')) + event = self.request.event + ev = subevent or event creation_time = datetime.now(pytz.utc) cal = vobject.iCalendar() cal.add('prodid').value = '-//pretix//{}//'.format(settings.PRETIX_INSTANCE_NAME) vevent = cal.add('vevent') - vevent.add('summary').value = str(event.name) + vevent.add('summary').value = str(ev.name) vevent.add('dtstamp').value = creation_time - vevent.add('location').value = str(event.location) + vevent.add('location').value = str(ev.location) vevent.add('organizer').value = event.organizer.name - vevent.add('uid').value = '{}-{}-{}'.format( - event.organizer.slug, event.slug, creation_time.strftime('%Y%m%d%H%M%S%f') + vevent.add('uid').value = '{}-{}-{}-{}'.format( + event.organizer.slug, event.slug, + subevent.pk if subevent else '0', + creation_time.strftime('%Y%m%d%H%M%S%f') ) if event.settings.show_times: - vevent.add('dtstart').value = event.date_from.astimezone(self.event_timezone) + vevent.add('dtstart').value = ev.date_from.astimezone(self.event_timezone) else: - vevent.add('dtstart').value = event.date_from.astimezone(self.event_timezone).date() + vevent.add('dtstart').value = ev.date_from.astimezone(self.event_timezone).date() - if event.settings.show_date_to: + if event.settings.show_date_to and ev.date_to: if event.settings.show_times: - vevent.add('dtend').value = event.date_to.astimezone(self.event_timezone) + vevent.add('dtend').value = ev.date_to.astimezone(self.event_timezone) else: - vevent.add('dtend').value = event.date_to.astimezone(self.event_timezone).date() + vevent.add('dtend').value = ev.date_to.astimezone(self.event_timezone).date() if event.date_admission: vevent.add('description').value = str(_('Admission: {datetime}')).format( - datetime=date_format(event.date_admission.astimezone(self.event_timezone), 'SHORT_DATETIME_FORMAT') + datetime=date_format(ev.date_admission.astimezone(self.event_timezone), 'SHORT_DATETIME_FORMAT') ) resp = HttpResponse(cal.serialize(), content_type='text/calendar') - resp['Content-Disposition'] = 'attachment; filename="{}-{}.ics"'.format( - event.organizer.slug, event.slug + resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}.ics"'.format( + event.organizer.slug, event.slug, subevent.pk if subevent else '0', ) return resp diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index e940a5f2d..2002fd584 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -82,11 +82,14 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TemplateView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['order'] = self.order + + if self.request.event.settings.ticket_download_date: + ctx['ticket_download_date'] = self.order.ticket_download_date ctx['can_download'] = ( self.request.event.settings.ticket_download and ( self.request.event.settings.ticket_download_date is None - or now() > self.request.event.settings.ticket_download_date + or now() > self.order.ticket_download_date ) and self.order.status == Order.STATUS_PAID ) ctx['download_buttons'] = self.download_buttons @@ -138,10 +141,10 @@ class OrderPaymentStart(EventViewMixin, OrderDetailMixin, TemplateView): messages.error(request, _('The payment for this order cannot be continued.')) return redirect(self.get_order_url()) - if self.request.event.settings.get('payment_term_last'): - if now() > self.request.event.payment_term_last: - messages.error(request, _('The payment is too late to be accepted.')) - return redirect(self.get_order_url()) + term_last = self.order.payment_term_last + if term_last and now() > term_last: + messages.error(request, _('The payment is too late to be accepted.')) + return redirect(self.get_order_url()) return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): @@ -233,10 +236,10 @@ class OrderPaymentComplete(EventViewMixin, OrderDetailMixin, View): messages.error(request, _('The payment information you entered was incomplete.')) return redirect(self.get_payment_url()) - if self.request.event.settings.get('payment_term_last'): - if now() > self.request.event.payment_term_last: - messages.error(request, _('The payment is too late to be accepted.')) - return redirect(self.get_order_url()) + term_last = self.order.payment_term_last + if term_last and now() > term_last: + messages.error(request, _('The payment is too late to be accepted.')) + return redirect(self.get_order_url()) return super().dispatch(request, *args, **kwargs) @@ -270,10 +273,10 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView): messages.error(request, _('The payment method for this order cannot be changed.')) return redirect(self.get_order_url()) - if self.request.event.settings.get('payment_term_last'): - if now() > self.request.event.payment_term_last: - messages.error(request, _('The payment is too late to be accepted.')) - return redirect(self.get_order_url()) + term_last = self.order.payment_term_last + if term_last and now() > term_last: + messages.error(request, _('The payment is too late to be accepted.')) + return redirect(self.get_order_url()) return super().dispatch(request, *args, **kwargs) @@ -551,7 +554,7 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View): return self.error(_('Order is not paid.')) if (not self.request.event.settings.ticket_download or (self.request.event.settings.ticket_download_date is not None - and now() < self.request.event.settings.ticket_download_date)): + and now() < self.order.ticket_download_date)): return self.error(_('Ticket download is not (yet) enabled.')) if 'position' in kwargs and (self.order_position.addon_to and not self.request.event.settings.ticket_download_addons): return self.error(_('Ticket download is not enabled for add-on products.')) diff --git a/src/pretix/presale/views/organizer.py b/src/pretix/presale/views/organizer.py index db80e5049..ef53647c4 100644 --- a/src/pretix/presale/views/organizer.py +++ b/src/pretix/presale/views/organizer.py @@ -8,7 +8,7 @@ from django.utils.timezone import now from django.views.generic import ListView, TemplateView from pytz import UTC -from pretix.base.models import Event +from pretix.base.models import Event, SubEvent from pretix.multidomain.urlreverse import eventreverse from pretix.presale.views import OrganizerViewMixin @@ -40,6 +40,104 @@ class OrganizerIndex(OrganizerViewMixin, ListView): ).order_by(order) +def add_events_for_days(organizer, before, after, ebd, timezones): + qs = organizer.events.filter(is_public=True, live=True, has_subevents=False).filter( + Q(Q(date_to__gte=before) & Q(date_from__lte=after)) | + Q(Q(date_from__lte=after) & Q(date_to__gte=before)) | + Q(Q(date_to__isnull=True) & Q(date_from__gte=before) & Q(date_from__lte=after)) + ).order_by( + 'date_from' + ).prefetch_related( + '_settings_objects', 'organizer___settings_objects' + ) + for event in qs: + timezones.add(event.settings.timezones) + tz = pytz.timezone(event.settings.timezone) + datetime_from = event.date_from.astimezone(tz) + date_from = datetime_from.date() + if event.settings.show_date_to and event.date_to: + date_to = event.date_to.astimezone(tz).date() + d = max(date_from, before.date()) + while d <= date_to and d <= after.date(): + first = d == date_from + ebd[d].append({ + 'event': event, + 'continued': not first, + 'time': datetime_from.time().replace(tzinfo=None) if first and event.settings.show_times else None, + 'url': eventreverse(event, 'presale:event.index'), + 'timezone': event.settings.timezone, + }) + d += timedelta(days=1) + + else: + ebd[date_from].append({ + 'event': event, + 'continued': False, + 'time': datetime_from.time().replace(tzinfo=None) if event.settings.show_times else None, + 'url': eventreverse(event, 'presale:event.index'), + 'timezone': event.settings.timezone, + }) + + +def add_subevents_for_days(qs, before, after, ebd, timezones, event=None): + qs = qs.filter(active=True).filter( + Q(Q(date_to__gte=before) & Q(date_from__lte=after)) | + Q(Q(date_from__lte=after) & Q(date_to__gte=before)) | + Q(Q(date_to__isnull=True) & Q(date_from__gte=before) & Q(date_from__lte=after)) + ).order_by( + 'date_from' + ) + for se in qs: + settings = event.settings if event else se.event.settings + timezones.add(settings.timezones) + tz = pytz.timezone(settings.timezone) + datetime_from = se.date_from.astimezone(tz) + date_from = datetime_from.date() + if se.event.settings.show_date_to and se.date_to: + date_to = se.date_to.astimezone(tz).date() + d = max(date_from, before.date()) + while d <= date_to and d <= after.date(): + first = d == date_from + ebd[d].append({ + 'continued': not first, + 'timezone': settings.timezone, + 'time': datetime_from.time().replace(tzinfo=None) if first and settings.show_times else None, + 'event': se, + 'url': eventreverse(se.event, 'presale:event.index', kwargs={ + 'subevent': se.pk + }), + }) + d += timedelta(days=1) + + else: + ebd[date_from].append({ + 'event': se, + 'continued': False, + 'time': datetime_from.time().replace(tzinfo=None) if se.event.settings.show_times else None, + 'url': eventreverse(se.event, 'presale:event.index', kwargs={ + 'subevent': se.pk + }), + 'timezone': se.event.settings.timezone, + }) + + +def weeks_for_template(ebd, year, month): + calendar.setfirstweekday(0) # TODO: Configurable + return [ + [ + { + 'day': day, + 'date': date(year, month, day), + 'events': ebd.get(date(year, month, day)) + } + if day > 0 + else None + for day in week + ] + for week in calendar.monthcalendar(year, month) + ] + + class CalendarView(OrganizerViewMixin, TemplateView): template_name = 'pretixpresale/organizers/calendar.html' @@ -47,12 +145,42 @@ class CalendarView(OrganizerViewMixin, TemplateView): if 'year' in kwargs and 'month' in kwargs: self.year = int(kwargs.get('year')) self.month = int(kwargs.get('month')) + elif 'year' in request.GET and 'month' in request.GET: + try: + self.year = int(request.GET.get('year')) + self.month = int(request.GET.get('month')) + except ValueError: + self.year = now().year + self.month = now().month else: - next_ev = Event.objects.filter(live=True, is_public=True, date_from__gte=now()).order_by('date_from').first() - tz = pytz.timezone(next_ev.settings.timezone) - datetime_from = next_ev.date_from.astimezone(tz) - self.year = datetime_from.year - self.month = datetime_from.month + next_ev = Event.objects.filter( + live=True, + is_public=True, + date_from__gte=now(), + has_subevents=False + ).order_by('date_from').first() + next_sev = SubEvent.objects.filter( + event__organizer=self.request.organizer, + event__is_public=True, + event__live=True, + active=True, + date_from__gte=now() + ).select_related('event').order_by('date_from').first() + + datetime_from = None + if (next_ev and next_sev and next_sev.date_from < next_ev.date_from) or (next_sev and not next_ev): + datetime_from = next_sev.date_from + next_ev = next_sev.event + elif next_ev: + datetime_from = next_ev.date_from + + if datetime_from: + tz = pytz.timezone(next_ev.settings.timezone) + self.year = datetime_from.astimezone(tz).year + self.month = datetime_from.astimezone(tz).month + else: + self.year = now().year + self.month = now().month return super().get(request, *args, **kwargs) def get_context_data(self, **kwargs): @@ -65,68 +193,25 @@ class CalendarView(OrganizerViewMixin, TemplateView): ctx['date'] = date(self.year, self.month, 1) ctx['before'] = before ctx['after'] = after - ebd = self._events_by_day() + ebd = self._events_by_day(before, after) - calendar.setfirstweekday(0) # TODO: Configurable ctx['multiple_timezones'] = self._multiple_timezones - ctx['weeks'] = [ - [ - { - 'day': day, - 'date': date(self.year, self.month, day), - 'events': ebd[date(self.year, self.month, day)] - } - if day > 0 - else None - for day in week - ] - for week in calendar.monthcalendar(self.year, self.month) - ] + ctx['weeks'] = weeks_for_template(ebd, self.year, self.month) + ctx['months'] = [date(self.year, i + 1, 1) for i in range(12)] + ctx['years'] = range(now().year - 2, now().year + 3) return ctx - def _events_by_day(self): - _, ndays = calendar.monthrange(self.year, self.month) - before = datetime(self.year, self.month, 1, 0, 0, 0, tzinfo=UTC) - timedelta(days=1) - after = datetime(self.year, self.month, ndays, 0, 0, 0, tzinfo=UTC) + timedelta(days=1) + def _events_by_day(self, before, after): ebd = defaultdict(list) - qs = self.request.organizer.events.filter(is_public=True, live=True).filter( - Q(Q(date_to__gte=before) & Q(date_from__lte=after)) | - Q(Q(date_from__lte=after) & Q(date_to__gte=before)) | - Q(Q(date_to__isnull=True) & Q(date_from__gte=before) & Q(date_from__lte=after)) - ).order_by( - 'date_from' - ).prefetch_related( - '_settings_objects', 'organizer___settings_objects' - ) timezones = set() - for event in qs: - timezones.add(event.settings.timezones) - tz = pytz.timezone(event.settings.timezone) - datetime_from = event.date_from.astimezone(tz) - date_from = datetime_from.date() - if event.settings.show_date_to and event.date_to: - date_to = event.date_to.astimezone(tz).date() - d = date_from - while d <= date_to: - first = d == date_from - ebd[d].append({ - 'event': event, - 'continued': not first, - 'time': datetime_from.time().replace(tzinfo=None) if first and event.settings.show_times else None, - 'url': eventreverse(event, 'presale:event.index'), - 'timezone': event.settings.timezone, - }) - d += timedelta(days=1) - - else: - ebd[date_from].append({ - 'event': event, - 'continued': False, - 'time': datetime_from.time().replace(tzinfo=None) if event.settings.show_times else None, - 'url': eventreverse(event, 'presale:event.index'), - 'timezone': event.settings.timezone, - }) - + add_events_for_days(self.request.organizer, before, after, ebd, timezones) + add_subevents_for_days(SubEvent.objects.filter( + event__organizer=self.request.organizer, + event__is_public=True, + event__live=True, + ).prefetch_related( + 'event___settings_objects', 'event__organizer___settings_objects' + ), before, after, ebd, timezones) self._multiple_timezones = len(timezones) > 1 return ebd diff --git a/src/pretix/presale/views/waiting.py b/src/pretix/presale/views/waiting.py index 5be03db0d..1ed9cc6e4 100644 --- a/src/pretix/presale/views/waiting.py +++ b/src/pretix/presale/views/waiting.py @@ -1,10 +1,12 @@ from django.contrib import messages -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.utils import translation 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 django.views.generic import FormView +from pretix.base.models.event import SubEvent + from ...base.models import Item, ItemVariation, WaitingListEntry from ...multidomain.urlreverse import eventreverse from ..forms.waitinglist import WaitingListForm @@ -19,13 +21,15 @@ class WaitingView(FormView): kwargs['event'] = self.request.event kwargs['instance'] = WaitingListEntry( item=self.item_and_variation[0], variation=self.item_and_variation[1], - event=self.request.event, locale=translation.get_language() + event=self.request.event, locale=translation.get_language(), + subevent=self.subevent ) return kwargs def get_context_data(self, **kwargs): ctx = super().get_context_data() ctx['event'] = self.request.event + ctx['subevent'] = self.subevent ctx['item'], ctx['variation'] = self.item_and_variation return ctx @@ -54,13 +58,22 @@ class WaitingView(FormView): messages.error(request, _("We could not identify the product you selected.")) return redirect(eventreverse(self.request.event, 'presale:event.index')) + self.subevent = None + if request.event.has_subevents: + if 'subevent' in request.GET: + self.subevent = get_object_or_404(SubEvent, event=request.event, pk=request.GET['subevent'], + active=True) + else: + messages.error(request, pgettext_lazy('subevent', "You need to select a date.")) + return redirect(eventreverse(self.request.event, 'presale:event.index')) + return super().dispatch(request, *args, **kwargs) def form_valid(self, form): availability = ( - self.item_and_variation[1].check_quotas(count_waitinglist=False) + self.item_and_variation[1].check_quotas(count_waitinglist=False, subevent=self.subevent) if self.item_and_variation[1] - else self.item_and_variation[0].check_quotas(count_waitinglist=False) + else self.item_and_variation[0].check_quotas(count_waitinglist=False, subevent=self.subevent) ) if availability[0] == 100: messages.error(self.request, _("You cannot add yourself to the waiting list as this product is currently " diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 198457b6c..de24f22bc 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -114,7 +114,7 @@ METRICS_PASSPHRASE = config.get('metrics', 'passphrase', fallback="") CACHES = { 'default': { - 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + 'BACKEND': 'pretix.helpers.cache.CustomDummyCache', } } REAL_CACHE_USED = False diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js index e3ca74085..b4ef8eea3 100644 --- a/src/pretix/static/pretixcontrol/js/ui/main.js +++ b/src/pretix/static/pretixcontrol/js/ui/main.js @@ -180,6 +180,29 @@ $(function () { $(this).datetimepicker(opts); }); + $(".timepickerfield").each(function() { + var opts = { + format: $("body").attr("data-timeformat"), + locale: $("body").attr("data-datetimelocale"), + useCurrent: false, + showClear: !$(this).prop("required"), + icons: { + time: 'fa fa-clock-o', + date: 'fa fa-calendar', + up: 'fa fa-chevron-up', + down: 'fa fa-chevron-down', + previous: 'fa fa-chevron-left', + next: 'fa fa-chevron-right', + today: 'fa fa-screenshot', + clear: 'fa fa-trash', + close: 'fa fa-remove' + } + }; + if ($(this).is('[data-is-payment-date]')) + opts["daysOfWeekDisabled"] = JSON.parse($("body").attr("data-payment-weekdays-disabled")); + $(this).datetimepicker(opts); + }); + $(".datetimepicker[data-date-after], .datepickerfield[data-date-after]").each(function() { var later_field = $(this), earlier_field = $($(this).attr("data-date-after")), diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index d090927ae..37a814b6b 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -164,3 +164,14 @@ pre.mail-preview { .input-group-btn .btn { padding-bottom: 7px; } +.reldatetime { + input[type=text], select { + display: inline-block; + max-width: 200px; + } + input[type=number] { + display: inline-block; + width: 80px; + } + +} diff --git a/src/pretix/static/pretixpresale/js/ui/main.js b/src/pretix/static/pretixpresale/js/ui/main.js index 13c3db02f..2704d250f 100644 --- a/src/pretix/static/pretixpresale/js/ui/main.js +++ b/src/pretix/static/pretixpresale/js/ui/main.js @@ -52,6 +52,25 @@ $(function () { copy_answers(idx); }); + // Subevent choice + if ($(".subevent-toggle").length) { + $(".subevent-list").hide(); + $(".subevent-toggle").css("display", "block").click(function() { + $(".subevent-list").slideToggle(300); + }); + } + + $("#monthselform select").change(function () { + $(this).closest("form").get(0).submit(); + }); + + $(".table-calendar td.has-events").click(function () { + var $tr = $(this).closest(".table-calendar").find(".selected-day"); + $tr.find("td").html($(this).find(".events").html()); + $tr.find("td").prepend($("

    ").text($(this).attr("data-date"))); + $tr.show(); + }); + // Lightbox lightbox.init(); }); diff --git a/src/pretix/static/pretixpresale/scss/_calendar.scss b/src/pretix/static/pretixpresale/scss/_calendar.scss index aa35ebb2a..ac618e0bf 100644 --- a/src/pretix/static/pretixpresale/scss/_calendar.scss +++ b/src/pretix/static/pretixpresale/scss/_calendar.scss @@ -33,4 +33,20 @@ background: darken($brand-primary, 15%); } } -} \ No newline at end of file + .selected-day { + display: none; + } +} +@media (max-width: $screen-xs-max) { + .table-calendar .day .events { + display: none; + } + .table-calendar td.day.has-events { + background: $brand-primary; + cursor: pointer; + color: white; + } + .table-calendar td.day.has-events:hover { + background: darken($brand-primary, 15%); + } +} diff --git a/src/pretix/static/pretixpresale/scss/_event.scss b/src/pretix/static/pretixpresale/scss/_event.scss index af2bb23f0..99568dcae 100644 --- a/src/pretix/static/pretixpresale/scss/_event.scss +++ b/src/pretix/static/pretixpresale/scss/_event.scss @@ -128,3 +128,49 @@ section.front-page { margin-bottom: 15px; } } + +.subevent-list { + background-color: $body-bg; + + form { + padding: 10px 0; + } + + .row { + margin: 0; + } + a.subevent-row { + display: block; + color: $text-color; + padding: 3px 0; + } + a.subevent-row:nth-child(2n) { + background-color: $gray-lighter; + } + a.subevent-row:hover { + text-decoration: none; + background-color: darken($gray-lighter, 10%); + } + .subevent-row .row > div { + padding: 5px; + min-height: 35px; /* label height */ + vertical-align: middle; + } +} +.subevent-toggle { + padding: 5px; + text-align: center; + background-color: darken($gray-lighter, 30%); + display: none; + color: white; + cursor: pointer; + + &:hover { + color: white; + text-decoration: none; + background-color: darken($gray-lighter, 40%); + } +} +h2.subevent-head { + margin-top: 10px; +} diff --git a/src/requirements/production.txt b/src/requirements/production.txt index d5d7026b0..1fcc3021c 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -6,7 +6,7 @@ pytz django-bootstrap3==8.2.* django-formset-js-improved==0.5.0.1 django-compressor==2.1.1 -django-hierarkey==1.0.*,>=1.0.2 +django-hierarkey==1.0.*,>=1.0.3 django-filter==1.0.* reportlab==3.2.* PyPDF2==1.26.* diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 02f90aa98..2bb40146e 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -45,3 +45,11 @@ def token_client(client, team): t = team.tokens.create(name='Foo') client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) return client + + +@pytest.fixture +def subevent(event): + event.has_subevents = True + event.save() + return event.subevents.create(name="Foobar", + date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC)) diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index 345d04f72..a2d2060e9 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -12,6 +12,7 @@ TEST_EVENT_RES = { "presale_end": None, "location": None, "slug": "dummy", + "has_subevents": False, } diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index dbe320c5b..7a5d88869 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -192,12 +192,13 @@ TEST_QUOTA_RES = { "name": "Budget Quota", "size": 200, "items": [], - "variations": [] + "variations": [], + "subevent": None } @pytest.mark.django_db -def test_quota_list(token_client, organizer, event, quota, item): +def test_quota_list(token_client, organizer, event, quota, item, subevent): res = dict(TEST_QUOTA_RES) res["id"] = quota.pk res["items"] = [item.pk] @@ -206,6 +207,16 @@ def test_quota_list(token_client, organizer, event, quota, item): assert resp.status_code == 200 assert [res] == resp.data['results'] + quota.subevent = subevent + quota.save() + res["subevent"] = subevent.pk + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/quotas/?subevent={}'.format(organizer.slug, event.slug, subevent.pk)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/quotas/?subevent={}'.format(organizer.slug, event.slug, subevent.pk + 1)) + assert [] == resp.data['results'] + @pytest.mark.django_db def test_quota_detail(token_client, organizer, event, quota, item): diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index ec15b3505..45bfca026 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -57,7 +57,8 @@ TEST_ORDERPOSITION_RES = { "addon_to": None, "checkins": [], "downloads": [], - "answers": [] + "answers": [], + "subevent": None } TEST_ORDER_RES = { "code": "FOO", @@ -142,7 +143,7 @@ def test_order_detail(token_client, organizer, event, order, item): @pytest.mark.django_db -def test_orderposition_list(token_client, organizer, event, order, item): +def test_orderposition_list(token_client, organizer, event, order, item, subevent): var = item.variations.create(value="Children") res = dict(TEST_ORDERPOSITION_RES) op = order.positions.first() @@ -206,12 +207,24 @@ def test_orderposition_list(token_client, organizer, event, order, item): '/api/v1/organizers/{}/events/{}/orderpositions/?has_checkin=true'.format(organizer.slug, event.slug)) assert [] == resp.data['results'] - order.positions.first().checkins.create(datetime=datetime.datetime(2017, 12, 26, 10, 0, 0, tzinfo=UTC)) + op.checkins.create(datetime=datetime.datetime(2017, 12, 26, 10, 0, 0, tzinfo=UTC)) res['checkins'] = [{'datetime': '2017-12-26T10:00:00Z'}] resp = token_client.get( '/api/v1/organizers/{}/events/{}/orderpositions/?has_checkin=true'.format(organizer.slug, event.slug)) assert [res] == resp.data['results'] + op.subevent = subevent + op.save() + res['subevent'] = subevent.pk + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?subevent={}'.format(organizer.slug, event.slug, subevent.pk)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?subevent={}'.format(organizer.slug, event.slug, + subevent.pk + 1)) + assert [] == resp.data['results'] + @pytest.mark.django_db def test_orderposition_detail(token_client, organizer, event, order, item): diff --git a/src/tests/api/test_subevents.py b/src/tests/api/test_subevents.py new file mode 100644 index 000000000..b8089ccaa --- /dev/null +++ b/src/tests/api/test_subevents.py @@ -0,0 +1,42 @@ +import pytest + +TEST_SUBEVENT_RES = { + 'active': False, + 'presale_start': None, + 'date_to': None, + 'date_admission': None, + 'name': {'en': 'Foobar'}, + 'date_from': '2017-12-27T10:00:00Z', + 'presale_end': None, + 'id': 1, + 'variation_price_overrides': [], + 'location': None, + 'item_price_overrides': [] +} + + +@pytest.mark.django_db +def test_subevent_list(token_client, organizer, event, subevent): + res = dict(TEST_SUBEVENT_RES) + res["id"] = subevent.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/subevents/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + print(dict(resp.data['results'][0])) + assert [res] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/subevents/?active=false'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/subevents/?active=true'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + +@pytest.mark.django_db +def test_subevent_detail(token_client, organizer, event, subevent): + res = dict(TEST_SUBEVENT_RES) + res["id"] = subevent.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/subevents/{}/'.format(organizer.slug, event.slug, + subevent.pk)) + assert resp.status_code == 200 + assert res == resp.data diff --git a/src/tests/api/test_vouchers.py b/src/tests/api/test_vouchers.py index e2f89d92a..1628f3301 100644 --- a/src/tests/api/test_vouchers.py +++ b/src/tests/api/test_vouchers.py @@ -35,12 +35,13 @@ TEST_VOUCHER_RES = { 'variation': None, 'quota': None, 'tag': 'Foo', - 'comment': '' + 'comment': '', + 'subevent': None } @pytest.mark.django_db -def test_voucher_list(token_client, organizer, event, voucher, item, quota): +def test_voucher_list(token_client, organizer, event, voucher, item, quota, subevent): res = dict(TEST_VOUCHER_RES) res['item'] = item.pk res['id'] = voucher.pk @@ -187,6 +188,18 @@ def test_voucher_list(token_client, organizer, event, voucher, item, quota): ) assert [res] == resp.data['results'] + voucher.subevent = subevent + voucher.save() + res['subevent'] = subevent.pk + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?subevent={}'.format(organizer.slug, event.slug, subevent.pk)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?subevent={}'.format(organizer.slug, event.slug, + subevent.pk + 1)) + assert [] == resp.data['results'] + @pytest.mark.django_db def test_voucher_detail(token_client, organizer, event, voucher, item): diff --git a/src/tests/api/test_waitinglist.py b/src/tests/api/test_waitinglist.py index effbefc73..8c7dc058c 100644 --- a/src/tests/api/test_waitinglist.py +++ b/src/tests/api/test_waitinglist.py @@ -28,12 +28,13 @@ TEST_WLE_RES = { "voucher": None, "item": 2, "variation": None, - "locale": "en" + "locale": "en", + "subevent": None, } @pytest.mark.django_db -def test_wle_list(token_client, organizer, event, wle, item): +def test_wle_list(token_client, organizer, event, wle, item, subevent): var = item.variations.create(value="Children") res = dict(TEST_WLE_RES) wle.variation = var @@ -91,6 +92,18 @@ def test_wle_list(token_client, organizer, event, wle, item): '/api/v1/organizers/{}/events/{}/waitinglistentries/?has_voucher=true'.format(organizer.slug, event.slug)) assert [res] == resp.data['results'] + wle.subevent = subevent + wle.save() + res['subevent'] = subevent.pk + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?subevent={}'.format(organizer.slug, event.slug, subevent.pk)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?subevent={}'.format(organizer.slug, event.slug, + subevent.pk + 1)) + assert [] == resp.data['results'] + @pytest.mark.django_db def test_wle_detail(token_client, organizer, event, wle, item): diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index d0cd1b238..e5fb7d06a 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -1,9 +1,10 @@ import datetime import sys -from datetime import timedelta +from datetime import date, timedelta from decimal import Decimal import pytest +import pytz from django.conf import settings from django.core.exceptions import ValidationError from django.core.files.storage import default_storage @@ -15,6 +16,9 @@ from pretix.base.models import ( CachedFile, CartPosition, Event, Item, ItemCategory, ItemVariation, Order, OrderPosition, Organizer, Question, Quota, User, Voucher, WaitingListEntry, ) +from pretix.base.models.event import SubEvent +from pretix.base.models.items import SubEventItem, SubEventItemVariation +from pretix.base.reldate import RelativeDate, RelativeDateWrapper from pretix.base.services.orders import ( OrderError, cancel_order, mark_order_paid, perform_order, ) @@ -378,6 +382,64 @@ class QuotaTestCase(BaseQuotaTestCase): with self.assertNumQueries(1): self.assertEqual(self.var1.check_quotas(_cache=cache, count_waitinglist=False), (Quota.AVAILABILITY_OK, 1)) + def test_subevent_isolation(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(date_from=now(), name="SE 1") + se2 = self.event.subevents.create(date_from=now(), name="SE 2") + q1 = self.event.quotas.create(name="Q1", subevent=se1, size=50) + q2 = self.event.quotas.create(name="Q2", subevent=se2, size=50) + q1.items.add(self.item1) + q2.items.add(self.item1) + + # Create orders + order = Order.objects.create(event=self.event, status=Order.STATUS_PAID, + expires=now() + timedelta(days=3), + total=6) + OrderPosition.objects.create(order=order, item=self.item1, subevent=se1, price=2) + OrderPosition.objects.create(order=order, item=self.item1, subevent=se1, price=2) + OrderPosition.objects.create(order=order, item=self.item1, subevent=se2, price=2) + order = Order.objects.create(event=self.event, status=Order.STATUS_PENDING, + expires=now() + timedelta(days=3), + total=8) + OrderPosition.objects.create(order=order, item=self.item1, subevent=se1, price=2) + OrderPosition.objects.create(order=order, item=self.item1, subevent=se1, price=2) + OrderPosition.objects.create(order=order, item=self.item1, subevent=se1, price=2) + OrderPosition.objects.create(order=order, item=self.item1, subevent=se2, price=2) + + Voucher.objects.create(item=self.item1, event=self.event, valid_until=now() + timedelta(days=5), + block_quota=True, max_usages=6, subevent=se1) + Voucher.objects.create(item=self.item1, event=self.event, valid_until=now() + timedelta(days=5), + block_quota=True, max_usages=4, subevent=se2) + + for i in range(8): + CartPosition.objects.create(event=self.event, item=self.item1, price=2, subevent=se1, + expires=now() + timedelta(days=3)) + + for i in range(5): + CartPosition.objects.create(event=self.event, item=self.item1, price=2, subevent=se2, + expires=now() + timedelta(days=3)) + + for i in range(16): + WaitingListEntry.objects.create( + event=self.event, item=self.item1, email='foo@bar.com', subevent=se1 + ) + + for i in range(13): + WaitingListEntry.objects.create( + event=self.event, item=self.item1, email='foo@bar.com', subevent=se2 + ) + + with self.assertRaises(TypeError): + self.item1.check_quotas() + + self.assertEqual(self.item1.check_quotas(subevent=se1), (Quota.AVAILABILITY_OK, 50 - 5 - 6 - 8 - 16)) + self.assertEqual(self.item1.check_quotas(subevent=se2), (Quota.AVAILABILITY_OK, 50 - 2 - 4 - 5 - 13)) + self.assertEqual(q1.availability(), (Quota.AVAILABILITY_OK, 50 - 5 - 6 - 8 - 16)) + self.assertEqual(q2.availability(), (Quota.AVAILABILITY_OK, 50 - 2 - 4 - 5 - 13)) + self.event.has_subevents = False + self.event.save() + class WaitingListTestCase(BaseQuotaTestCase): @@ -496,27 +558,27 @@ class VoucherTestCase(BaseQuotaTestCase): def test_calculate_price_none(self): v = Voucher.objects.create(event=self.event, price_mode='none', value=Decimal('10.00')) - v.calculate_price(Decimal('23.42')) == Decimal('23.42') + assert v.calculate_price(Decimal('23.42')) == Decimal('23.42') def test_calculate_price_set_empty(self): v = Voucher.objects.create(event=self.event, price_mode='set') - v.calculate_price(Decimal('23.42')) == Decimal('23.42') + assert v.calculate_price(Decimal('23.42')) == Decimal('23.42') def test_calculate_price_set(self): v = Voucher.objects.create(event=self.event, price_mode='set', value=Decimal('10.00')) - v.calculate_price(Decimal('23.42')) == Decimal('10.00') + assert v.calculate_price(Decimal('23.42')) == Decimal('10.00') def test_calculate_price_set_zero(self): v = Voucher.objects.create(event=self.event, price_mode='set', value=Decimal('0.00')) - v.calculate_price(Decimal('23.42')) == Decimal('0.00') + assert v.calculate_price(Decimal('23.42')) == Decimal('0.00') def test_calculate_price_subtract(self): v = Voucher.objects.create(event=self.event, price_mode='subtract', value=Decimal('10.00')) - v.calculate_price(Decimal('23.42')) == Decimal('13.42') + assert v.calculate_price(Decimal('23.42')) == Decimal('13.42') def test_calculate_price_percent(self): v = Voucher.objects.create(event=self.event, price_mode='percent', value=Decimal('23.00')) - v.calculate_price(Decimal('100.00')) == Decimal('77.00') + assert v.calculate_price(Decimal('100.00')) == Decimal('77.00') class OrderTestCase(BaseQuotaTestCase): @@ -529,10 +591,10 @@ class OrderTestCase(BaseQuotaTestCase): expires=now() + timedelta(days=5), total=46 ) self.quota.items.add(self.item1) - OrderPosition.objects.create(order=self.order, item=self.item1, - variation=None, price=23) - OrderPosition.objects.create(order=self.order, item=self.item1, - variation=None, price=23) + self.op1 = OrderPosition.objects.create(order=self.order, item=self.item1, + variation=None, price=23) + self.op2 = OrderPosition.objects.create(order=self.order, item=self.item1, + variation=None, price=23) def test_paid_in_time(self): self.quota.size = 0 @@ -560,6 +622,29 @@ class OrderTestCase(BaseQuotaTestCase): self.order = Order.objects.get(id=self.order.id) self.assertEqual(self.order.status, Order.STATUS_EXPIRED) + def test_paid_expired_after_last_date_subevent_relative(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="SE1", date_from=now() + timedelta(days=10)) + se2 = self.event.subevents.create(name="SE2", date_from=now() + timedelta(days=1)) + self.op1.subevent = se1 + self.op1.save() + self.op2.subevent = se2 + self.op2.save() + self.event.settings.set('payment_term_last', RelativeDateWrapper( + RelativeDate(days_before=2, time=None, base_date_name='date_from') + )) + + self.order.status = Order.STATUS_EXPIRED + self.order.expires = now() - timedelta(days=2) + self.order.save() + with self.assertRaises(Quota.QuotaExceededException): + mark_order_paid(self.order) + self.order = Order.objects.get(id=self.order.id) + self.assertEqual(self.order.status, Order.STATUS_EXPIRED) + self.event.has_subevents = False + self.event.save() + def test_paid_expired_late_not_allowed(self): self.event.settings.payment_term_accept_late = False self.order.status = Order.STATUS_EXPIRED @@ -615,6 +700,86 @@ class OrderTestCase(BaseQuotaTestCase): self.event.settings.set('last_order_modification_date', now() - timedelta(days=1)) assert not self.order.can_modify_answers + def test_can_modify_answers_subevent(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="SE1", date_from=now() + timedelta(days=10)) + se2 = self.event.subevents.create(name="SE2", date_from=now() + timedelta(days=8)) + se3 = self.event.subevents.create(name="SE2", date_from=now() + timedelta(days=1)) + self.op1.subevent = se1 + self.op1.save() + self.op2.subevent = se2 + self.op2.save() + self.event.settings.set('last_order_modification_date', RelativeDateWrapper( + RelativeDate(days_before=2, time=None, base_date_name='date_from') + )) + assert self.order.can_modify_answers + self.op2.subevent = se3 + self.op2.save() + assert not self.order.can_modify_answers + self.event.has_subevents = False + self.event.save() + + def test_payment_term_last_relative(self): + self.event.settings.set('payment_term_last', date(2017, 5, 3)) + assert self.order.payment_term_last == datetime.datetime(2017, 5, 3, 23, 59, 59, tzinfo=pytz.UTC) + self.event.date_from = datetime.datetime(2017, 5, 3, 12, 0, 0, tzinfo=pytz.UTC) + self.event.save() + self.event.settings.set('payment_term_last', RelativeDateWrapper( + RelativeDate(days_before=2, time=None, base_date_name='date_from') + )) + assert self.order.payment_term_last == datetime.datetime(2017, 5, 1, 23, 59, 59, tzinfo=pytz.UTC) + + def test_payment_term_last_subevent(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="SE1", date_from=now() + timedelta(days=10)) + se2 = self.event.subevents.create(name="SE2", date_from=now() + timedelta(days=8)) + se3 = self.event.subevents.create(name="SE2", date_from=now() + timedelta(days=1)) + self.op1.subevent = se1 + self.op1.save() + self.op2.subevent = se2 + self.op2.save() + self.event.settings.set('payment_term_last', RelativeDateWrapper( + RelativeDate(days_before=2, time=None, base_date_name='date_from') + )) + assert self.order.payment_term_last > now() + self.op2.subevent = se3 + self.op2.save() + assert self.order.payment_term_last < now() + self.event.has_subevents = False + self.event.save() + + def test_ticket_download_date_relative(self): + self.event.settings.set('ticket_download_date', datetime.datetime(2017, 5, 3, 12, 59, 59, tzinfo=pytz.UTC)) + assert self.order.ticket_download_date == datetime.datetime(2017, 5, 3, 12, 59, 59, tzinfo=pytz.UTC) + self.event.date_from = datetime.datetime(2017, 5, 3, 12, 0, 0, tzinfo=pytz.UTC) + self.event.save() + self.event.settings.set('ticket_download_date', RelativeDateWrapper( + RelativeDate(days_before=2, time=None, base_date_name='date_from') + )) + assert self.order.ticket_download_date == datetime.datetime(2017, 5, 1, 12, 0, 0, tzinfo=pytz.UTC) + + def test_ticket_download_date_subevent(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="SE1", date_from=now() + timedelta(days=10)) + se2 = self.event.subevents.create(name="SE2", date_from=now() + timedelta(days=8)) + se3 = self.event.subevents.create(name="SE2", date_from=now() + timedelta(days=1)) + self.op1.subevent = se1 + self.op1.save() + self.op2.subevent = se2 + self.op2.save() + self.event.settings.set('ticket_download_date', RelativeDateWrapper( + RelativeDate(days_before=2, time=None, base_date_name='date_from') + )) + assert self.order.ticket_download_date > now() + self.op2.subevent = se3 + self.op2.save() + assert self.order.ticket_download_date < now() + self.event.has_subevents = False + self.event.save() + def test_can_cancel_order(self): item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, admission=True, allow_cancel=True) @@ -771,6 +936,41 @@ class EventTest(TestCase): self.assertIn('slug', str(context.exception)) +class SubEventTest(TestCase): + @classmethod + def setUpTestData(cls): + cls.organizer = Organizer.objects.create(name='Dummy', slug='dummy') + cls.event = Event.objects.create( + organizer=cls.organizer, name='Dummy', slug='dummy', + date_from=now(), date_to=now() - timedelta(hours=1), + has_subevents=True + ) + cls.se = SubEvent.objects.create( + name='Testsub', date_from=now(), event=cls.event + ) + + def test_override_prices(self): + i = Item.objects.create( + event=self.event, name="Ticket", default_price=23, + active=True, available_until=now() + timedelta(days=1), + ) + SubEventItem.objects.create(item=i, subevent=self.se, price=Decimal('30.00')) + assert self.se.item_price_overrides == { + i.pk: Decimal('30.00') + } + + def test_override_var_prices(self): + i = Item.objects.create( + event=self.event, name="Ticket", default_price=23, + active=True, available_until=now() + timedelta(days=1), + ) + v = i.variations.create(value='Type 1') + SubEventItemVariation.objects.create(variation=v, subevent=self.se, price=Decimal('30.00')) + assert self.se.var_price_overrides == { + v.pk: Decimal('30.00') + } + + class CachedFileTestCase(TestCase): def test_file_handling(self): cf = CachedFile() diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 02d4ce4b5..5e7cbde62 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -7,8 +7,12 @@ from django.test import TestCase from django.utils.timezone import make_aware, now from pretix.base.decimal import round_decimal -from pretix.base.models import Event, Item, Order, OrderPosition, Organizer +from pretix.base.models import ( + CartPosition, Event, Item, Order, OrderPosition, Organizer, +) +from pretix.base.models.items import SubEventItem from pretix.base.payment import FreeOrderProvider +from pretix.base.reldate import RelativeDate, RelativeDateWrapper from pretix.base.services.orders import ( OrderChangeManager, OrderError, _create_order, expire_orders, ) @@ -71,6 +75,52 @@ def test_expiry_last(event): assert (order.expires - today).days == 5 +@pytest.mark.django_db +def test_expiry_last_relative(event): + today = now() + event.settings.set('payment_term_days', 5) + event.settings.set('payment_term_weekdays', False) + event.date_from = now() + timedelta(days=5) + event.save() + event.settings.set('payment_term_last', RelativeDateWrapper( + RelativeDate(days_before=2, time=None, base_date_name='date_from') + )) + order = _create_order(event, email='dummy@example.org', positions=[], + now_dt=today, payment_provider=FreeOrderProvider(event), + locale='de') + assert (order.expires - today).days == 3 + + +@pytest.mark.django_db +def test_expiry_last_relative_subevents(event): + today = now() + event.settings.set('payment_term_days', 100) + event.settings.set('payment_term_weekdays', False) + event.date_from = now() + timedelta(days=5) + event.has_subevents = True + event.save() + ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rate=Decimal('7.00'), + default_price=Decimal('23.00'), admission=True) + + se1 = event.subevents.create(name="SE1", date_from=now() + timedelta(days=10)) + se2 = event.subevents.create(name="SE2", date_from=now() + timedelta(days=8)) + + cp1 = CartPosition.objects.create( + item=ticket, price=23, expires=now() + timedelta(days=1), subevent=se1, event=event, cart_id="123" + ) + cp2 = CartPosition.objects.create( + item=ticket, price=23, expires=now() + timedelta(days=1), subevent=se2, event=event, cart_id="123" + ) + + event.settings.set('payment_term_last', RelativeDateWrapper( + RelativeDate(days_before=2, time=None, base_date_name='date_from') + )) + order = _create_order(event, email='dummy@example.org', positions=[cp1, cp2], + now_dt=today, payment_provider=FreeOrderProvider(event), + locale='de') + assert (order.expires - today).days == 6 + + @pytest.mark.django_db def test_expiry_dst(event): event.settings.set('timezone', 'Europe/Berlin') @@ -153,6 +203,63 @@ class OrderChangeManagerTests(TestCase): price=Decimal("23.00"), attendee_name="Dieter", positionid=2 ) self.ocm = OrderChangeManager(self.order, None) + self.quota = self.event.quotas.create(name='Test', size=None) + self.quota.items.add(self.ticket) + self.quota.items.add(self.ticket2) + self.quota.items.add(self.shirt) + + def test_change_subevent_quota_required(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now()) + se2 = self.event.subevents.create(name="Bar", date_from=now()) + self.op1.subevent = se1 + self.op1.save() + self.quota.subevent = se1 + self.quota.save() + with self.assertRaises(OrderError): + self.ocm.change_subevent(self.op1, se2) + + def test_change_subevent_success(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now()) + se2 = self.event.subevents.create(name="Bar", date_from=now()) + SubEventItem.objects.create(subevent=se2, item=self.ticket, price=12) + self.op1.subevent = se1 + self.op1.save() + self.quota.subevent = se2 + self.quota.save() + + self.ocm.change_subevent(self.op1, se2) + self.ocm.commit() + self.op1.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.subevent == se2 + assert self.op1.price == 12 + assert self.order.total == self.op1.price + self.op2.price + + def test_change_subevent_sold_out(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now()) + se2 = self.event.subevents.create(name="Bar", date_from=now()) + self.op1.subevent = se1 + self.op1.save() + self.quota.subevent = se2 + self.quota.size = 0 + self.quota.save() + + self.ocm.change_subevent(self.op1, se2) + with self.assertRaises(OrderError): + self.ocm.commit() + self.op1.refresh_from_db() + assert self.op1.subevent == se1 + + def test_change_item_quota_required(self): + self.quota.delete() + with self.assertRaises(OrderError): + self.ocm.change_item(self.op1, self.shirt, None) def test_change_item_success(self): self.ocm.change_item(self.op1, self.shirt, None) @@ -325,6 +432,11 @@ class OrderChangeManagerTests(TestCase): assert self.order.total == 46 assert self.order.status == Order.STATUS_PAID + def test_add_item_quota_required(self): + self.quota.delete() + with self.assertRaises(OrderError): + self.ocm.add_position(self.shirt, None, None, None) + def test_add_item_success(self): self.ocm.add_position(self.shirt, None, None, None) self.ocm.commit() @@ -375,3 +487,26 @@ class OrderChangeManagerTests(TestCase): self.shirt.category = self.event.categories.create(name='Add-ons', is_addon=True) with self.assertRaises(OrderError): self.ocm.add_position(self.shirt, None, Decimal('13.00'), None) + + def test_add_item_subevent_required(self): + self.event.has_subevents = True + self.event.save() + with self.assertRaises(OrderError): + self.ocm.add_position(self.ticket, None, None, None) + + def test_add_item_subevent_price(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now()) + SubEventItem.objects.create(subevent=se1, item=self.ticket, price=12) + self.quota.subevent = se1 + self.quota.save() + + self.ocm.add_position(self.ticket, None, None, subevent=se1) + self.ocm.commit() + self.order.refresh_from_db() + assert self.order.positions.count() == 3 + nop = self.order.positions.last() + assert nop.item == self.ticket + assert nop.price == Decimal('12.00') + assert nop.subevent == se1 diff --git a/src/tests/base/test_payment.py b/src/tests/base/test_payment.py index 51e129679..bf047ffba 100644 --- a/src/tests/base/test_payment.py +++ b/src/tests/base/test_payment.py @@ -6,7 +6,10 @@ import pytz from django.utils.timezone import now from tests.testdummy.payment import DummyPaymentProvider -from pretix.base.models import Event, Organizer +from pretix.base.models import ( + CartPosition, Event, Item, Order, OrderPosition, Organizer, +) +from pretix.base.reldate import RelativeDate, RelativeDateWrapper @pytest.fixture @@ -70,6 +73,23 @@ def test_availability_date_not_available(event): assert not result +@pytest.mark.django_db +def test_availability_date_relative(event): + event.settings.set('timezone', 'US/Pacific') + tz = pytz.timezone('US/Pacific') + event.date_from = tz.localize(datetime.datetime(2016, 12, 3, 12, 0, 0)) + event.save() + prov = DummyPaymentProvider(event) + prov.settings.set('_availability_date', RelativeDateWrapper( + RelativeDate(days_before=2, time=None, base_date_name='date_from') + )) + + utc = pytz.timezone('UTC') + assert prov._is_still_available(tz.localize(datetime.datetime(2016, 11, 30, 23, 0, 0)).astimezone(utc)) + assert prov._is_still_available(tz.localize(datetime.datetime(2016, 12, 1, 23, 59, 0)).astimezone(utc)) + assert not prov._is_still_available(tz.localize(datetime.datetime(2016, 12, 2, 0, 0, 1)).astimezone(utc)) + + @pytest.mark.django_db def test_availability_date_timezones(event): event.settings.set('timezone', 'US/Pacific') @@ -81,3 +101,71 @@ def test_availability_date_timezones(event): assert prov._is_still_available(tz.localize(datetime.datetime(2016, 11, 30, 23, 0, 0)).astimezone(utc)) assert prov._is_still_available(tz.localize(datetime.datetime(2016, 12, 1, 23, 59, 0)).astimezone(utc)) assert not prov._is_still_available(tz.localize(datetime.datetime(2016, 12, 2, 0, 0, 1)).astimezone(utc)) + + +@pytest.mark.django_db +def test_availability_date_cart_relative_subevents(event): + event.date_from = now() + datetime.timedelta(days=5) + event.has_subevents = True + event.save() + ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rate=Decimal('7.00'), + default_price=Decimal('23.00'), admission=True) + + se1 = event.subevents.create(name="SE1", date_from=now() + datetime.timedelta(days=10)) + se2 = event.subevents.create(name="SE2", date_from=now() + datetime.timedelta(days=3)) + + CartPosition.objects.create( + item=ticket, price=23, expires=now() + datetime.timedelta(days=1), subevent=se1, event=event, cart_id="123" + ) + CartPosition.objects.create( + item=ticket, price=23, expires=now() + datetime.timedelta(days=1), subevent=se2, event=event, cart_id="123" + ) + + prov = DummyPaymentProvider(event) + prov.settings.set('_availability_date', RelativeDateWrapper( + RelativeDate(days_before=3, time=None, base_date_name='date_from') + )) + assert prov._is_still_available(cart_id="123") + + prov.settings.set('_availability_date', RelativeDateWrapper( + RelativeDate(days_before=4, time=None, base_date_name='date_from') + )) + assert not prov._is_still_available(cart_id="123") + + +@pytest.mark.django_db +def test_availability_date_order_relative_subevents(event): + event.date_from = now() + datetime.timedelta(days=5) + event.has_subevents = True + event.save() + ticket = Item.objects.create(event=event, name='Early-bird ticket', tax_rate=Decimal('7.00'), + default_price=Decimal('23.00'), admission=True) + + se1 = event.subevents.create(name="SE1", date_from=now() + datetime.timedelta(days=10)) + se2 = event.subevents.create(name="SE2", date_from=now() + datetime.timedelta(days=3)) + + order = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + datetime.timedelta(days=10), + total=Decimal('46.00'), payment_provider='dummtest' + ) + OrderPosition.objects.create( + order=order, item=ticket, variation=None, subevent=se1, + price=Decimal("23.00"), attendee_name="Peter", positionid=1 + ) + OrderPosition.objects.create( + order=order, item=ticket, variation=None, subevent=se2, + price=Decimal("23.00"), attendee_name="Dieter", positionid=2 + ) + + prov = DummyPaymentProvider(event) + prov.settings.set('_availability_date', RelativeDateWrapper( + RelativeDate(days_before=3, time=None, base_date_name='date_from') + )) + assert prov._is_still_available(order=order) + + prov.settings.set('_availability_date', RelativeDateWrapper( + RelativeDate(days_before=4, time=None, base_date_name='date_from') + )) + assert not prov._is_still_available(order=order) diff --git a/src/tests/base/test_pricing.py b/src/tests/base/test_pricing.py new file mode 100644 index 000000000..d6a23c9cb --- /dev/null +++ b/src/tests/base/test_pricing.py @@ -0,0 +1,195 @@ +from decimal import Decimal + +import pytest +from django.utils.timezone import now + +from pretix.base.models import Event, Organizer +from pretix.base.models.items import SubEventItem, SubEventItemVariation +from pretix.base.services.pricing import get_price + + +@pytest.fixture +def event(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=now() + ) + return event + + +@pytest.fixture +def item(event): + return event.items.create(name='Ticket', default_price=Decimal('23.00')) + + +@pytest.fixture +def variation(item): + return item.variations.create(value='Premium', default_price=None) + + +@pytest.fixture +def voucher(event): + return event.vouchers.create() + + +@pytest.fixture +def subevent(event): + event.has_subevents = True + event.save() + return event.subevents.create(name='Foobar', date_from=now()) + + +@pytest.mark.django_db +def test_base_item_default(item): + assert get_price(item) == Decimal('23.00') + + +@pytest.mark.django_db +def test_base_item_subevent_no_entry(item, subevent): + assert get_price(item, subevent=subevent) == Decimal('23.00') + + +@pytest.mark.django_db +def test_base_item_subevent_no_override(item, subevent): + SubEventItem.objects.create(item=item, subevent=subevent, price=None) + assert get_price(item, subevent=subevent) == Decimal('23.00') + + +@pytest.mark.django_db +def test_base_item_subevent_override(item, subevent): + SubEventItem.objects.create(item=item, subevent=subevent, price=Decimal('24.00')) + assert get_price(item, subevent=subevent) == Decimal('24.00') + + +@pytest.mark.django_db +def test_variation_with_default_item_price(item, variation): + assert get_price(item, variation=variation) == Decimal('23.00') + + +@pytest.mark.django_db +def test_variation_with_specific_price(item, variation): + variation.default_price = Decimal('24.00') + assert get_price(item, variation=variation) == Decimal('24.00') + + +@pytest.mark.django_db +def test_variation_with_default_subevent_and_default_price(item, subevent, variation): + SubEventItemVariation.objects.create(variation=variation, subevent=subevent, price=None) + assert get_price(item, variation=variation, subevent=subevent) == Decimal('23.00') + + +@pytest.mark.django_db +def test_variation_with_subevent_and_default_price(item, subevent, variation): + SubEventItemVariation.objects.create(variation=variation, subevent=subevent, price=Decimal('24.00')) + assert get_price(item, variation=variation, subevent=subevent) == Decimal('24.00') + + +@pytest.mark.django_db +def test_variation_with_no_subevent_and_specific_price(item, subevent, variation): + variation.default_price = Decimal('24.00') + assert get_price(item, variation=variation, subevent=subevent) == Decimal('24.00') + + +@pytest.mark.django_db +def test_variation_with_default_subevent_and_specific_price(item, subevent, variation): + variation.default_price = Decimal('24.00') + SubEventItemVariation.objects.create(variation=variation, subevent=subevent, price=None) + assert get_price(item, variation=variation, subevent=subevent) == Decimal('24.00') + + +@pytest.mark.django_db +def test_variation_with_subevent_and_specific_price(item, subevent, variation): + variation.default_price = Decimal('24.00') + SubEventItemVariation.objects.create(variation=variation, subevent=subevent, price=Decimal('26.00')) + assert get_price(item, variation=variation, subevent=subevent) == Decimal('26.00') + + +@pytest.mark.django_db +def test_voucher_no_override(item, subevent, voucher): + assert get_price(item, subevent=subevent, voucher=voucher) == Decimal('23.00') + + +@pytest.mark.django_db +def test_voucher_set_price(item, subevent, voucher): + voucher.price_mode = 'set' + voucher.value = Decimal('12.00') + assert get_price(item, subevent=subevent, voucher=voucher) == Decimal('12.00') + + +@pytest.mark.django_db +def test_voucher_subtract(item, subevent, voucher): + voucher.price_mode = 'subtract' + voucher.value = Decimal('12.00') + assert get_price(item, subevent=subevent, voucher=voucher) == Decimal('11.00') + + +@pytest.mark.django_db +def test_voucher_percent(item, subevent, voucher): + voucher.price_mode = 'percent' + voucher.value = Decimal('10.00') + assert get_price(item, subevent=subevent, voucher=voucher) == Decimal('20.70') + + +@pytest.mark.django_db +def test_free_price_ignored_if_disabled(item): + assert get_price(item, custom_price=Decimal('42.00')) == Decimal('23.00') + + +@pytest.mark.django_db +def test_free_price_ignored_if_lower(item): + item.free_price = True + assert get_price(item, custom_price=Decimal('12.00')) == Decimal('23.00') + + +@pytest.mark.django_db +def test_free_price_ignored_if_lower_than_voucher(item, voucher): + voucher.price_mode = 'set' + voucher.value = Decimal('50.00') + assert get_price(item, voucher=voucher, custom_price=Decimal('40.00')) == Decimal('50.00') + + +@pytest.mark.django_db +def test_free_price_ignored_if_lower_than_subevent(item, subevent): + item.free_price = True + SubEventItem.objects.create(item=item, subevent=subevent, price=Decimal('50.00')) + assert get_price(item, subevent=subevent, custom_price=Decimal('40.00')) == Decimal('50.00') + + +@pytest.mark.django_db +def test_free_price_ignored_if_lower_than_variation(item, variation): + variation.default_price = Decimal('50.00') + item.free_price = True + assert get_price(item, variation=variation, custom_price=Decimal('40.00')) == Decimal('50.00') + + +@pytest.mark.django_db +def test_free_price_accepted(item): + item.free_price = True + assert get_price(item, custom_price=Decimal('42.00')) == Decimal('42.00') + + +@pytest.mark.django_db +def test_free_price_string(item): + item.free_price = True + assert get_price(item, custom_price='42,00') == Decimal('42.00') + + +@pytest.mark.django_db +def test_free_price_float(item): + item.free_price = True + assert get_price(item, custom_price=42.00) == Decimal('42.00') + + +@pytest.mark.django_db +def test_free_price_limit(item): + item.free_price = True + with pytest.raises(ValueError): + get_price(item, custom_price=Decimal('200000000')) + + +@pytest.mark.django_db +def test_free_price_net(item): + item.free_price = True + item.tax_rate = 19 + assert get_price(item, custom_price=Decimal('100.00'), custom_price_is_net=True) == Decimal('119.00') diff --git a/src/tests/base/test_reldate.py b/src/tests/base/test_reldate.py new file mode 100644 index 000000000..01e1d45f6 --- /dev/null +++ b/src/tests/base/test_reldate.py @@ -0,0 +1,82 @@ +from datetime import datetime, time + +import pytest +import pytz + +from pretix.base.models import Event, Organizer +from pretix.base.reldate import RelativeDate, RelativeDateWrapper + +TOKYO = pytz.timezone('Asia/Tokyo') + + +@pytest.fixture +def event(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=datetime(2017, 12, 27, 5, 0, 0, tzinfo=TOKYO), + presale_start=datetime(2017, 12, 1, 5, 0, 0, tzinfo=TOKYO), + plugins='pretix.plugins.banktransfer' + + ) + event.settings.timezone = "Asia/Tokyo" + return event + + +@pytest.mark.django_db +def test_absolute_date(event): + d = datetime(2017, 12, 25, 5, 0, 0, tzinfo=TOKYO) + rdw = RelativeDateWrapper(d) + assert rdw.datetime(event) == d + assert rdw.to_string() == d.isoformat() + + +@pytest.mark.django_db +def test_relative_date_without_time(event): + rdw = RelativeDateWrapper(RelativeDate(days_before=1, time=None, base_date_name='date_from')) + assert rdw.datetime(event).astimezone(TOKYO) == datetime(2017, 12, 26, 5, 0, 0, tzinfo=TOKYO) + assert rdw.to_string() == 'RELDATE/1/-/date_from/' + + +@pytest.mark.django_db +def test_relative_date_other_base_point(event): + rdw = RelativeDateWrapper(RelativeDate(days_before=1, time=None, base_date_name='presale_start')) + assert rdw.datetime(event) == datetime(2017, 11, 30, 5, 0, 0, tzinfo=TOKYO) + assert rdw.to_string() == 'RELDATE/1/-/presale_start/' + + # presale_end is unset, defaults to date_from + rdw = RelativeDateWrapper(RelativeDate(days_before=1, time=None, base_date_name='presale_end')) + assert rdw.datetime(event) == datetime(2017, 12, 26, 5, 0, 0, tzinfo=TOKYO) + assert rdw.to_string() == 'RELDATE/1/-/presale_end/' + + # subevent base + se = event.subevents.create(name="SE1", date_from=datetime(2017, 11, 27, 5, 0, 0, tzinfo=TOKYO)) + rdw = RelativeDateWrapper(RelativeDate(days_before=1, time=None, base_date_name='date_from')) + assert rdw.datetime(se) == datetime(2017, 11, 26, 5, 0, 0, tzinfo=TOKYO) + + # presale_start is unset on subevent, default to event + rdw = RelativeDateWrapper(RelativeDate(days_before=1, time=None, base_date_name='presale_start')) + assert rdw.datetime(se) == datetime(2017, 11, 30, 5, 0, 0, tzinfo=TOKYO) + + # presale_end is unset on all, default to date_from of subevent + rdw = RelativeDateWrapper(RelativeDate(days_before=1, time=None, base_date_name='presale_end')) + assert rdw.datetime(se) == datetime(2017, 11, 26, 5, 0, 0, tzinfo=TOKYO) + + +@pytest.mark.django_db +def test_relative_date_with_time(event): + rdw = RelativeDateWrapper(RelativeDate(days_before=1, time=time(8, 5, 13), base_date_name='date_from')) + assert rdw.to_string() == 'RELDATE/1/08:05:13/date_from/' + assert rdw.datetime(event) == datetime(2017, 12, 26, 8, 5, 13, tzinfo=TOKYO) + + +def test_unserialize(): + d = datetime(2017, 12, 25, 10, 0, 0, tzinfo=TOKYO) + rdw = RelativeDateWrapper.from_string(d.isoformat()) + assert rdw.data == d + + rdw = RelativeDateWrapper.from_string('RELDATE/1/-/date_from/') + assert rdw.data == RelativeDate(days_before=1, time=None, base_date_name='date_from') + + rdw = RelativeDateWrapper.from_string('RELDATE/1/18:05:13/date_from/') + assert rdw.data == RelativeDate(days_before=1, time=time(18, 5, 13), base_date_name='date_from') diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py index 455ef8d52..cc920e067 100644 --- a/src/tests/control/test_events.py +++ b/src/tests/control/test_events.py @@ -2,11 +2,15 @@ import datetime from decimal import Decimal import pytz +from django.utils.timezone import now from i18nfield.strings import LazyI18nString from pytz import timezone from tests.base import SoupTest, extract_form_fields -from pretix.base.models import Event, Organizer, Team, User +from pretix.base.models import ( + Event, Order, OrderPosition, Organizer, Team, User, +) +from pretix.base.models.items import SubEventItem from pretix.testutils.mock import mocker_context @@ -164,7 +168,10 @@ class EventsTest(SoupTest): 'payment_banktransfer__fee_abs': '12.23', 'payment_banktransfer_bank_details_0': 'Test', 'settings-payment_term_days': '2', - 'settings-payment_term_last': (self.event1.presale_end - datetime.timedelta(1)).strftime('%Y-%m-%d'), + 'settings-payment_term_last_0': 'absolute', + 'settings-payment_term_last_1': (self.event1.presale_end - datetime.timedelta(1)).strftime('%Y-%m-%d'), + 'settings-payment_term_last_2': '0', + 'settings-payment_term_last_3': 'date_from', 'settings-tax_rate_default': '19.00', }) assert doc.select('.alert-danger') @@ -327,6 +334,41 @@ class EventsTest(SoupTest): assert ev.presale_start == berlin_tz.localize(datetime.datetime(2016, 11, 1, 10, 0, 0)).astimezone(pytz.utc) assert ev.presale_end == berlin_tz.localize(datetime.datetime(2016, 11, 30, 18, 0, 0)).astimezone(pytz.utc) + def test_create_event_with_subevents_success(self): + doc = self.get_doc('/control/events/add') + tabletext = doc.select("form")[0].text + self.assertIn("CCC", tabletext) + self.assertNotIn("MRM", tabletext) + + doc = self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'foundation', + 'foundation-organizer': self.orga1.pk, + 'foundation-locales': ('en', 'de'), + 'foundation-has_subevents': 'on', + }) + doc = self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'basics', + 'basics-name_0': '33C3', + 'basics-name_1': '33C3', + 'basics-slug': '33c3', + 'basics-date_from': '2016-12-27 10:00:00', + 'basics-date_to': '2016-12-30 19:00:00', + 'basics-location_0': 'Hamburg', + 'basics-location_1': 'Hamburg', + 'basics-currency': 'EUR', + 'basics-locale': 'en', + 'basics-timezone': 'Europe/Berlin', + 'basics-presale_start': '2016-11-01 10:00:00', + 'basics-presale_end': '2016-11-30 18:00:00', + }) + self.post_doc('/control/events/add', { + 'event_wizard-current_step': 'copy', + 'copy-copy_from_event': '' + }) + ev = Event.objects.get(slug='33c3') + assert ev.has_subevents + assert ev.subevents.count() == 1 + def test_create_event_only_date_from(self): # date_to, presale_start & presale_end are optional fields self.post_doc('/control/events/add', { @@ -431,3 +473,132 @@ class EventsTest(SoupTest): 'basics-presale_end': '2016-11-30 18:00:00', }) assert doc.select(".alert-danger") + + +class SubEventsTest(SoupTest): + def setUp(self): + super().setUp() + self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + self.orga1 = Organizer.objects.create(name='CCC', slug='ccc') + self.event1 = Event.objects.create( + organizer=self.orga1, name='30C3', slug='30c3', + date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), + plugins='pretix.plugins.banktransfer,tests.testdummy', + has_subevents=True + ) + + t = Team.objects.create(organizer=self.orga1, can_create_events=True, can_change_event_settings=True, + can_change_items=True) + t.members.add(self.user) + t.limit_events.add(self.event1) + self.ticket = self.event1.items.create(name='Early-bird ticket', + category=None, default_price=23, + admission=True) + + self.client.login(email='dummy@dummy.dummy', password='dummy') + + self.subevent1 = self.event1.subevents.create(name='SE1', date_from=now()) + + def test_list(self): + doc = self.get_doc('/control/event/ccc/30c3/subevents/') + tabletext = doc.select("#page-wrapper .table")[0].text + self.assertIn("SE1", tabletext) + + def test_create(self): + doc = self.get_doc('/control/event/ccc/30c3/subevents/add') + assert doc.select("input[name=quotas-TOTAL_FORMS]") + doc = self.post_doc('/control/event/ccc/30c3/subevents/add', { + 'name_0': 'SE2', + 'active': 'on', + 'date_from': '2017-07-01 10:00:00', + 'date_to': '2017-07-01 12:00:00', + 'location_0': 'Hamburg', + 'presale_start': '2017-06-20 10:00:00', + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '50', + 'quotas-0-itemvars': str(self.ticket.pk), + 'item-%d-price' % self.ticket.pk: '12' + }) + assert doc.select(".alert-success") + se = self.event1.subevents.first() + assert str(se.name) == "SE2" + assert se.active + assert se.date_from.isoformat() == "2017-07-01T10:00:00+00:00" + assert se.date_to.isoformat() == "2017-07-01T12:00:00+00:00" + assert str(se.location) == "Hamburg" + assert se.presale_start.isoformat() == "2017-06-20T10:00:00+00:00" + assert not se.presale_end + assert se.quotas.count() == 1 + q = se.quotas.last() + assert q.name == "Q1" + assert q.size == 50 + assert list(q.items.all()) == [self.ticket] + sei = SubEventItem.objects.get(subevent=se, item=self.ticket) + assert sei.price == 12 + + def test_modify(self): + doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/' % self.subevent1.pk) + assert doc.select("input[name=quotas-TOTAL_FORMS]") + doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/' % self.subevent1.pk, { + 'name_0': 'SE2', + 'active': 'on', + 'date_from': '2017-07-01 10:00:00', + 'date_to': '2017-07-01 12:00:00', + 'location_0': 'Hamburg', + 'presale_start': '2017-06-20 10:00:00', + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '50', + 'quotas-0-itemvars': str(self.ticket.pk), + 'item-%d-price' % self.ticket.pk: '12' + }) + assert doc.select(".alert-success") + self.subevent1.refresh_from_db() + se = self.subevent1 + assert str(se.name) == "SE2" + assert se.active + assert se.date_from.isoformat() == "2017-07-01T10:00:00+00:00" + assert se.date_to.isoformat() == "2017-07-01T12:00:00+00:00" + assert str(se.location) == "Hamburg" + assert se.presale_start.isoformat() == "2017-06-20T10:00:00+00:00" + assert not se.presale_end + assert se.quotas.count() == 1 + q = se.quotas.last() + assert q.name == "Q1" + assert q.size == 50 + assert list(q.items.all()) == [self.ticket] + sei = SubEventItem.objects.get(subevent=se, item=self.ticket) + assert sei.price == 12 + + def test_delete(self): + doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent1.pk) + assert doc.select("button") + doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent1.pk, {}) + assert doc.select(".alert-success") + assert not SubEventItem.objects.filter(pk=self.subevent1.pk).exists() + + def test_delete_with_orders(self): + o = Order.objects.create( + code='FOO', event=self.event1, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + datetime.timedelta(days=10), + total=14, payment_provider='banktransfer', locale='en' + ) + OrderPosition.objects.create( + order=o, + item=self.ticket, + subevent=self.subevent1, + price=Decimal("14"), + ) + doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent1.pk, follow=True) + assert doc.select(".alert-danger") + doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/delete' % self.subevent1.pk, {}, follow=True) + assert doc.select(".alert-danger") + assert self.event1.subevents.filter(pk=self.subevent1.pk).exists() diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py index 825bc9995..29a365e90 100644 --- a/src/tests/control/test_items.py +++ b/src/tests/control/test_items.py @@ -231,7 +231,7 @@ class QuotaTest(ItemFormTest): ItemVariation.objects.create(item=item2, value="Silver") ItemVariation.objects.create(item=item2, value="Gold") doc = self.get_doc('/control/event/%s/%s/quotas/%s/change' % (self.orga1.slug, self.event1.slug, c.id)) - doc.select('[name=item_%s]' % item1.id)[0]['checked'] = 'checked' + [i for i in doc.select('[name=itemvars]') if i.get('value') == str(item1.id)][0]['checked'] = 'checked' form_data = extract_form_fields(doc.select('.container-fluid form')[0]) form_data['size'] = '350' doc = self.post_doc('/control/event/%s/%s/quotas/%s/change' % (self.orga1.slug, self.event1.slug, c.id), @@ -242,6 +242,19 @@ class QuotaTest(ItemFormTest): assert Quota.objects.get(id=c.id).size == 350 assert item1 in Quota.objects.get(id=c.id).items.all() + def test_update_subevent(self): + self.event1.has_subevents = True + self.event1.save() + se1 = self.event1.subevents.create(name="Foo", date_from=now()) + se2 = self.event1.subevents.create(name="Bar", date_from=now()) + c = Quota.objects.create(event=self.event1, name="Full house", size=500, subevent=se1) + doc = self.get_doc('/control/event/%s/%s/quotas/%s/change' % (self.orga1.slug, self.event1.slug, c.id)) + form_data = extract_form_fields(doc.select('.container-fluid form')[0]) + form_data['subevent'] = se2.pk + self.post_doc('/control/event/%s/%s/quotas/%s/change' % (self.orga1.slug, self.event1.slug, c.id), + form_data) + assert Quota.objects.get(id=c.id).subevent == se2 + def test_delete(self): c = Quota.objects.create(event=self.event1, name="Full house", size=500) doc = self.get_doc('/control/event/%s/%s/quotas/%s/delete' % (self.orga1.slug, self.event1.slug, c.id)) diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index 5f8c29e65..4a2816cf1 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -476,6 +476,9 @@ class OrderChangeTests(SoupTest): order=self.order, item=self.ticket, variation=None, price=Decimal("23.00"), attendee_name="Dieter" ) + self.quota = self.event.quotas.create(name="All", size=100) + self.quota.items.add(self.ticket) + self.quota.items.add(self.shirt) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') t = Team.objects.create(organizer=o, can_view_orders=True, can_change_orders=True) t.members.add(user) @@ -499,6 +502,38 @@ class OrderChangeTests(SoupTest): assert self.op1.tax_rate == self.shirt.tax_rate assert self.order.total == self.op1.price + self.op2.price + def test_change_subevent_success(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name='Foo', date_from=now()) + se2 = self.event.subevents.create(name='Bar', date_from=now()) + self.op1.subevent = se1 + self.op1.save() + self.op2.subevent = se1 + self.op2.save() + self.quota.subevent = se1 + self.quota.save() + q2 = self.event.quotas.create(name='Q2', size=100, subevent=se2) + q2.items.add(self.ticket) + q2.items.add(self.shirt) + self.client.post('/control/event/{}/{}/orders/{}/change'.format( + self.event.organizer.slug, self.event.slug, self.order.code + ), { + 'op-{}-operation'.format(self.op1.pk): 'subevent', + 'op-{}-subevent'.format(self.op1.pk): str(se2.pk), + 'op-{}-itemvar'.format(self.op1.pk): str(self.ticket.pk), + 'op-{}-operation'.format(self.op2.pk): '', + 'op-{}-itemvar'.format(self.op2.pk): str(self.ticket.pk), + 'op-{}-subevent'.format(self.op2.pk): str(se1.pk), + 'add-itemvar'.format(self.op2.pk): str(self.ticket.pk), + 'add-subevent'.format(self.op2.pk): str(se1.pk), + }) + self.op1.refresh_from_db() + self.op2.refresh_from_db() + self.order.refresh_from_db() + assert self.op1.subevent == se2 + assert self.op2.subevent == se1 + def test_change_price_success(self): self.client.post('/control/event/{}/{}/orders/{}/change'.format( self.event.organizer.slug, self.event.slug, self.order.code diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 147914d55..b38e3b09f 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -64,6 +64,10 @@ event_urls = [ "vouchers/add", "vouchers/bulk_add", "vouchers/rng", + "subevents/", + "subevents/add", + "subevents/2/delete", + "subevents/2/", "quotas/", "quotas/2/delete", "quotas/2/change", @@ -182,6 +186,10 @@ event_permission_urls = [ ("can_change_items", "quotas/2/change", 404), ("can_change_items", "quotas/2/delete", 404), ("can_change_items", "quotas/add", 200), + ("can_change_event_settings", "subevents/", 200), + ("can_change_event_settings", "subevents/2/", 404), + ("can_change_event_settings", "subevents/2/delete", 404), + ("can_change_event_settings", "subevents/add", 200), ("can_view_orders", "orders/overview/", 200), ("can_view_orders", "orders/export/", 200), ("can_view_orders", "orders/", 200), diff --git a/src/tests/control/test_vouchers.py b/src/tests/control/test_vouchers.py index 3bea92166..7740efbc7 100644 --- a/src/tests/control/test_vouchers.py +++ b/src/tests/control/test_vouchers.py @@ -425,3 +425,53 @@ class VoucherFormTest(SoupTest): doc = self.post_doc('/control/event/%s/%s/vouchers/%s/delete' % (self.orga.slug, self.event.slug, v.pk), {}, follow=True) assert doc.select(".alert-danger") + + def test_subevent_optional(self): + self.event.has_subevents = True + self.event.save() + self._create_voucher({ + 'itemvar': '%d' % self.ticket.pk, + }) + + def test_subevent_required_for_blocking(self): + self.event.has_subevents = True + self.event.save() + self._create_voucher({ + 'itemvar': '%d' % self.ticket.pk, + 'block_quota': 'on' + }, expected_failure=True) + + def test_subevent_blocking_quota_free(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now()) + se2 = self.event.subevents.create(name="Bar", date_from=now()) + + self.quota_tickets.subevent = se1 + self.quota_tickets.save() + q2 = Quota.objects.create(event=self.event, name='Tickets', size=0, subevent=se2) + q2.items.add(self.ticket) + + self._create_voucher({ + 'itemvar': '%d' % self.ticket.pk, + 'block_quota': 'on', + 'subevent': se1.pk + }) + + def test_subevent_blocking_quota_full(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now()) + se2 = self.event.subevents.create(name="Bar", date_from=now()) + + self.quota_tickets.subevent = se1 + self.quota_tickets.size = 0 + self.quota_tickets.save() + q2 = Quota.objects.create(event=self.event, name='Tickets', size=5, subevent=se2) + q2.items.add(self.ticket) + + self._create_voucher({ + 'itemvar': '%d' % self.ticket.pk, + 'block_quota': 'on', + 'subevent': se1.pk + }, expected_failure=True) diff --git a/src/tests/plugins/pretixdroid/__init__.py b/src/tests/plugins/pretixdroid/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/tests/plugins/test_pretixdroid.py b/src/tests/plugins/pretixdroid/test_simple.py similarity index 97% rename from src/tests/plugins/test_pretixdroid.py rename to src/tests/plugins/pretixdroid/test_simple.py index e27b81c5f..4ea026c56 100644 --- a/src/tests/plugins/test_pretixdroid.py +++ b/src/tests/plugins/pretixdroid/test_simple.py @@ -20,7 +20,7 @@ def env(): ) user = User.objects.create_user('dummy@dummy.dummy', 'dummy') - t = Team.objects.create(organizer=o, can_change_event_settings=True, can_change_items=True) + t = Team.objects.create(organizer=o, can_change_event_settings=True, can_change_orders=True) t.members.add(user) t.limit_events.add(event) @@ -47,14 +47,15 @@ def env(): @pytest.mark.django_db def test_flush_key(client, env): env[0].settings.set('pretixdroid_key', 'abcdefg') + client.login(email='dummy@dummy.dummy', password='dummy') client.get('/control/event/%s/%s/pretixdroid/' % (env[0].organizer.slug, env[0].slug)) env[0].settings.flush() - env[0].settings.get('pretixdroid_key') == 'abcdefg' + assert env[0].settings.get('pretixdroid_key') == 'abcdefg' client.get('/control/event/%s/%s/pretixdroid/?flush_key=1' % (env[0].organizer.slug, env[0].slug)) env[0].settings.flush() - env[0].settings.get('pretixdroid_key') != 'abcdefg' + assert env[0].settings.get('pretixdroid_key') != 'abcdefg' @pytest.mark.django_db diff --git a/src/tests/plugins/pretixdroid/test_subevents.py b/src/tests/plugins/pretixdroid/test_subevents.py new file mode 100644 index 000000000..7789aa3b1 --- /dev/null +++ b/src/tests/plugins/pretixdroid/test_subevents.py @@ -0,0 +1,172 @@ +import json +from datetime import timedelta + +import pytest +from django.utils.timezone import now + +from pretix.base.models import ( + Checkin, Event, Item, ItemVariation, Order, OrderPosition, Organizer, Team, + User, +) +from pretix.plugins.pretixdroid.views import API_VERSION + + +@pytest.fixture +def env(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.pretixdroid', + has_subevents=True + ) + se1 = event.subevents.create(name='Foo', date_from=now()) + se2 = event.subevents.create(name='Bar', date_from=now()) + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + + t = Team.objects.create(organizer=o, can_change_event_settings=True, can_change_orders=True) + t.members.add(user) + t.limit_events.add(event) + + shirt = Item.objects.create(event=event, name='T-Shirt', default_price=12) + shirt_red = ItemVariation.objects.create(item=shirt, default_price=14, value="Red") + ItemVariation.objects.create(item=shirt, value="Blue") + ticket = Item.objects.create(event=event, name='Ticket', default_price=23) + o1 = Order.objects.create( + code='FOO', event=event, status=Order.STATUS_PAID, + datetime=now(), expires=now() + timedelta(days=10), + total=0, payment_provider='banktransfer' + ) + op1 = OrderPosition.objects.create( + order=o1, item=shirt, variation=shirt_red, + price=12, attendee_name=None, secret='1234', subevent=se1 + ) + op2 = OrderPosition.objects.create( + order=o1, item=ticket, + price=23, attendee_name="Peter", secret='5678910', subevent=se2 + ) + return event, user, o1, op1, op2, se1, se2 + + +@pytest.mark.django_db +def test_config(client, env): + env[0].settings.set('pretixdroid_key', 'abcdefg') + client.login(email='dummy@dummy.dummy', password='dummy') + + r = client.get('/control/event/%s/%s/pretixdroid/' % (env[0].organizer.slug, env[0].slug)) + print(r.content) + assert 'qrcodeCanvas' not in r.rendered_content + + r = client.get('/control/event/%s/%s/pretixdroid/?subevent=%d' % (env[0].organizer.slug, env[0].slug, env[5].pk)) + assert 'qrcodeCanvas' in r.rendered_content + assert '/%d/' % env[5].pk in r.rendered_content + + +@pytest.mark.django_db +def test_custom_datetime(client, env): + env[0].settings.set('pretixdroid_key', 'abcdefg') + dt = now() - timedelta(days=1) + dt = dt.replace(microsecond=0) + resp = client.post('/pretixdroid/api/%s/%s/%d/redeem/?key=%s' % ( + env[0].organizer.slug, env[0].slug, env[5].pk, 'abcdefg' + ), data={'secret': '1234', 'datetime': dt.isoformat()}) + jdata = json.loads(resp.content.decode("utf-8")) + assert jdata['version'] == API_VERSION + assert jdata['status'] == 'ok' + assert Checkin.objects.last().datetime == dt + + +@pytest.mark.django_db +def test_wrong_subevent(client, env): + env[0].settings.set('pretixdroid_key', 'abcdefg') + + resp = client.post('/pretixdroid/api/%s/%s/%d/redeem/?key=%s' % ( + env[0].organizer.slug, env[0].slug, env[5].pk, 'abcdefg' + ), data={'secret': '5678910'}) + jdata = json.loads(resp.content.decode("utf-8")) + assert jdata['status'] == 'error' + assert jdata['reason'] == 'unknown_ticket' + + resp = client.post('/pretixdroid/api/%s/%s/%d/redeem/?key=%s' % ( + env[0].organizer.slug, env[0].slug, env[6].pk, 'abcdefg' + ), data={'secret': '5678910'}) + jdata = json.loads(resp.content.decode("utf-8")) + assert jdata['status'] == 'ok' + + +@pytest.mark.django_db +def test_unknown_subevent(client, env): + env[0].settings.set('pretixdroid_key', 'abcdefg') + resp = client.post('/pretixdroid/api/%s/%s/%d/redeem/?key=%s' % ( + env[0].organizer.slug, env[0].slug, env[6].pk + 1000, 'abcdefg' + ), data={'secret': '5678910'}) + assert resp.status_code == 404 + + +@pytest.mark.django_db +def test_no_subevent(client, env): + resp = client.post('/pretixdroid/api/%s/%s/redeem/?key=%s' % ( + env[0].organizer.slug, env[0].slug, 'abcdefg' + ), data={'secret': '5678910'}) + assert resp.status_code == 403 + + +@pytest.mark.django_db +def test_search(client, env): + env[0].settings.set('pretixdroid_key', 'abcdefg') + resp = client.get('/pretixdroid/api/%s/%s/%d/search/?key=%s&query=%s' % ( + env[0].organizer.slug, env[0].slug, env[5].pk, 'abcdefg', '567891')) + jdata = json.loads(resp.content.decode("utf-8")) + assert len(jdata['results']) == 0 + resp = client.get('/pretixdroid/api/%s/%s/%d/search/?key=%s&query=%s' % ( + env[0].organizer.slug, env[0].slug, env[6].pk, 'abcdefg', '567891')) + jdata = json.loads(resp.content.decode("utf-8")) + assert len(jdata['results']) == 1 + assert jdata['results'][0]['secret'] == '5678910' + + +@pytest.mark.django_db +def test_download_all_data(client, env): + env[0].settings.set('pretixdroid_key', 'abcdefg') + resp = client.get('/pretixdroid/api/%s/%s/%d/download/?key=%s' % ( + env[0].organizer.slug, env[0].slug, env[5].pk, 'abcdefg')) + jdata = json.loads(resp.content.decode("utf-8")) + assert len(jdata['results']) == 1 + assert jdata['results'][0]['secret'] == '1234' + + +@pytest.mark.django_db +def test_status(client, env): + env[0].settings.set('pretixdroid_key', 'abcdefg') + Checkin.objects.create(position=env[3]) + resp = client.get('/pretixdroid/api/%s/%s/%d/status/?key=%s' % ( + env[0].organizer.slug, env[0].slug, env[5].pk, 'abcdefg')) + jdata = json.loads(resp.content.decode("utf-8")) + assert jdata['checkins'] == 1 + assert jdata['total'] == 1 + assert jdata['items'] == [ + {'name': 'T-Shirt', + 'id': env[3].item.pk, + 'checkins': 1, + 'admission': False, + 'total': 1, + 'variations': [ + {'name': 'Red', + 'id': env[3].variation.pk, + 'checkins': 1, + 'total': 1 + }, + {'name': 'Blue', + 'id': env[3].item.variations.get(value='Blue').pk, + 'checkins': 0, + 'total': 0 + } + ] + }, + {'name': 'Ticket', + 'id': env[4].item.pk, + 'checkins': 0, + 'admission': False, + 'total': 0, + 'variations': [] + } + ] diff --git a/src/tests/plugins/test_sendmail.py b/src/tests/plugins/test_sendmail.py index 4524333ab..23eef55b3 100644 --- a/src/tests/plugins/test_sendmail.py +++ b/src/tests/plugins/test_sendmail.py @@ -174,3 +174,42 @@ def test_sendmail_multi_locales(logged_in_client, sendmail_url, event, item): assert response.status_code == 200 assert 'Benutzer' in response.rendered_content assert 'Test nachricht' in response.rendered_content + + +@pytest.mark.django_db +def test_sendmail_subevents(logged_in_client, sendmail_url, event, order): + event.has_subevents = True + event.save() + se1 = event.subevents.create(name='Subevent FOO', date_from=now()) + se2 = event.subevents.create(name='Bar', date_from=now()) + op = order.positions.last() + op.subevent = se1 + op.save() + + djmail.outbox = [] + response = logged_in_client.post(sendmail_url, + {'sendto': 'n', + 'subject_0': 'Test subject', + 'message_0': 'This is a test file for sending mails.', + 'subevent': se1.pk + }, + follow=True) + assert response.status_code == 200 + assert 'alert-success' in response.rendered_content + assert len(djmail.outbox) == 1 + + djmail.outbox = [] + response = logged_in_client.post(sendmail_url, + {'sendto': 'n', + 'subject_0': 'Test subject', + 'message_0': 'This is a test file for sending mails.', + 'subevent': se2.pk + }, + follow=True) + assert len(djmail.outbox) == 0 + + url = sendmail_url + 'history/' + response = logged_in_client.get(url) + + assert response.status_code == 200 + assert 'Subevent FOO' in response.rendered_content diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index cf8bd5485..0cfcd2c08 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -11,7 +11,9 @@ from pretix.base.models import ( CartPosition, Event, Item, ItemCategory, ItemVariation, Organizer, Question, QuestionAnswer, Quota, Voucher, ) -from pretix.base.models.items import ItemAddOn +from pretix.base.models.items import ( + ItemAddOn, SubEventItem, SubEventItemVariation, +) from pretix.base.services.cart import CartError, CartManager @@ -88,6 +90,195 @@ class CartTest(CartTestMixin, TestCase): self.assertIsNone(objs[0].variation) self.assertEqual(objs[0].price, 23) + def test_subevent_missing(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + self.quota_tickets.subevent = se + self.quota_tickets.save() + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + }, follow=False) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + + def test_voucher_subevent(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + self.quota_tickets.subevent = se + self.quota_tickets.save() + v = Voucher.objects.create(item=self.ticket, event=self.event, subevent=se) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + '_voucher_code': v.code, + 'subevent': se.pk + }, follow=True) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.ticket) + self.assertIsNone(objs[0].variation) + self.assertEqual(objs[0].price, 23) + self.assertEqual(objs[0].subevent, se) + + def test_voucher_any_subevent(self): + v = Voucher.objects.create(item=self.ticket, event=self.event) + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + self.quota_tickets.subevent = se + self.quota_tickets.save() + print(self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + '_voucher_code': v.code, + 'subevent': se.pk + }, follow=True).rendered_content) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.ticket) + self.assertIsNone(objs[0].variation) + self.assertEqual(objs[0].price, 23) + self.assertEqual(objs[0].subevent, se) + + def test_voucher_wrong_subevent(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + v = Voucher.objects.create(item=self.ticket, event=self.event, subevent=se2) + self.quota_tickets.subevent = se + self.quota_tickets.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + '_voucher_code': v.code, + 'subevent': se.pk + }, follow=True) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + + def test_inactive_subevent(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=False) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'subevent': se.pk + }, follow=False) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + + def test_subevent_sale_over(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True, + presale_end=now() - timedelta(days=1)) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'subevent': se.pk + }, follow=False) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + + def test_subevent_sale_not_yet(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True, + presale_start=now() + timedelta(days=1)) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'subevent': se.pk + }, follow=False) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + + def test_simple_subevent(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'subevent': se.pk + }, follow=False) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.ticket) + self.assertIsNone(objs[0].variation) + self.assertEqual(objs[0].price, 23) + self.assertEqual(objs[0].subevent, se) + + def test_subevent_sold_out(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + self.event.subevents.create(name='Foo', date_from=now(), active=True) + q = se1.quotas.create(name="foo", size=0, event=self.event) + q.items.add(self.ticket) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'subevent': se1.pk + }, follow=False) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + + def test_other_subevent_sold_out(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + q = se1.quotas.create(name="foo", size=0, event=self.event) + q.items.add(self.ticket) + q = se2.quotas.create(name="foo", size=100, event=self.event) + q.items.add(self.ticket) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'subevent': se2.pk + }, follow=False) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + + def test_subevent_no_quota(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + q = se1.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'subevent': se2.pk + }, follow=False) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + + def test_subevent_price(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + SubEventItem.objects.create(subevent=se, item=self.ticket, price=42) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'subevent': se.pk + }, follow=False) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.ticket) + self.assertIsNone(objs[0].variation) + self.assertEqual(objs[0].price, 42) + self.assertEqual(objs[0].subevent, se) + def test_free_price(self): self.ticket.free_price = True self.ticket.save() @@ -198,6 +389,24 @@ class CartTest(CartTestMixin, TestCase): self.assertEqual(objs[0].variation, self.shirt_red) self.assertEqual(objs[0].price, 16) + def test_subevent_variation_price(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.variations.add(self.shirt_red) + SubEventItemVariation.objects.create(subevent=se, variation=self.shirt_red, price=42) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1', + 'subevent': se.pk + }, follow=False) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.shirt) + self.assertEqual(objs[0].variation, self.shirt_red) + self.assertEqual(objs[0].price, 42) + self.assertEqual(objs[0].subevent, se) + def test_count(self): response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { 'item_%d' % self.ticket.id: '2' @@ -438,6 +647,30 @@ class CartTest(CartTestMixin, TestCase): self.assertIsNone(objs[0].variation) self.assertEqual(objs[0].price, 23) + def test_subevent_quota_partly(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + self.quota_tickets.size = 1 + self.quota_tickets.subevent = se + self.quota_tickets.save() + q2 = self.event.quotas.create(name='Foo', size=15) + q2.items.add(self.ticket) + + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '2', + 'subevent': se.pk + }, follow=True) + self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug), + target_status_code=200) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertIn('no longer available', doc.select('.alert-danger')[0].text) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.ticket) + self.assertIsNone(objs[0].variation) + self.assertEqual(objs[0].price, 23) + def test_renew_in_time(self): cp = CartPosition.objects.create( event=self.event, cart_id=self.session_key, item=self.ticket, @@ -496,6 +729,48 @@ class CartTest(CartTestMixin, TestCase): self.assertIn('no longer available', doc.select('.alert-danger')[0].text) self.assertFalse(CartPosition.objects.filter(id=cp1.id).exists()) + def test_subevent_renew_expired_successfully(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + self.quota_tickets.subevent = se + self.quota_tickets.save() + self.quota_shirts.subevent = se + self.quota_shirts.save() + cp1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10), subevent=se + ) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1', + 'subevent': se.pk, + }, follow=True) + obj = CartPosition.objects.get(id=cp1.id) + self.assertEqual(obj.item, self.ticket) + self.assertIsNone(obj.variation) + self.assertEqual(obj.price, 23) + self.assertEqual(obj.subevent, se) + self.assertGreater(obj.expires, now()) + + def test_subevent_renew_expired_failed(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + self.quota_tickets.subevent = se + self.quota_tickets.size = 0 + self.quota_tickets.save() + cp1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10), subevent=se + ) + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'subevent': se.pk, + }, follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertIn('no longer available', doc.select('.alert-danger')[0].text) + self.assertFalse(CartPosition.objects.filter(id=cp1.id).exists()) + def test_remove_simple(self): cp = CartPosition.objects.create( event=self.event, cart_id=self.session_key, item=self.ticket, @@ -1048,6 +1323,50 @@ class CartAddonTest(CartTestMixin, TestCase): cp2 = cp1.addons.first() assert cp2.item == self.workshop1 + def test_cart_subevent_set_simple_addon(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + self.workshopquota.subevent = se + self.workshopquota.save() + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key, subevent=se + ) + + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + self.cm.commit() + cp2 = cp1.addons.first() + assert cp2.item == self.workshop1 + assert cp2.subevent == se + + def test_cart_subevent_set_addon_for_wrong_subevent(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + self.workshopquota.subevent = se2 + self.workshopquota.save() + cp1 = CartPosition.objects.create( + expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), + event=self.event, cart_id=self.session_key, subevent=se + ) + + with self.assertRaises(CartError): + self.cm.set_addons([ + { + 'addon_to': cp1.pk, + 'item': self.workshop1.pk, + 'variation': None + } + ]) + def test_wrong_category(self): cp1 = CartPosition.objects.create( expires=now() + timedelta(minutes=10), item=self.ticket, price=Decimal('23.00'), diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index c42c9b267..c301516b0 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -13,7 +13,7 @@ from pretix.base.models import ( CartPosition, Event, Item, ItemCategory, Order, OrderPosition, Organizer, Question, Quota, Voucher, ) -from pretix.base.models.items import ItemAddOn, ItemVariation +from pretix.base.models.items import ItemAddOn, ItemVariation, SubEventItem class CheckoutTestCase(TestCase): @@ -300,6 +300,26 @@ class CheckoutTestCase(TestCase): session[key] = value session.save() + def test_subevent(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now()) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10), subevent=se + ) + self._set_session('payment', 'banktransfer') + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + self.assertFalse(CartPosition.objects.filter(id=cr1.id).exists()) + self.assertEqual(Order.objects.count(), 1) + self.assertEqual(OrderPosition.objects.count(), 1) + self.assertEqual(OrderPosition.objects.first().subevent, se) + def test_free_price(self): self.ticket.free_price = True self.ticket.save() @@ -331,6 +351,29 @@ class CheckoutTestCase(TestCase): self.assertEqual(Order.objects.count(), 1) self.assertEqual(OrderPosition.objects.count(), 1) + def test_subevent_confirm_expired_available(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now()) + se2 = self.event.subevents.create(name='Foo', date_from=now()) + self.quota_tickets.size = 0 + self.quota_tickets.subevent = se2 + self.quota_tickets.save() + q2 = se.quotas.create(event=self.event, size=1, name='Bar') + q2.items.add(self.ticket) + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10), subevent=se + ) + self._set_session('payment', 'banktransfer') + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + self.assertFalse(CartPosition.objects.filter(id=cr1.id).exists()) + self.assertEqual(Order.objects.count(), 1) + self.assertEqual(OrderPosition.objects.count(), 1) + def test_confirm_expired_available(self): cr1 = CartPosition.objects.create( event=self.event, cart_id=self.session_key, item=self.ticket, @@ -345,6 +388,25 @@ class CheckoutTestCase(TestCase): self.assertEqual(Order.objects.count(), 1) self.assertEqual(OrderPosition.objects.count(), 1) + def test_subevent_confirm_price_changed(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now()) + q = se.quotas.create(name="foo", size=None, event=self.event) + q.items.add(self.ticket) + SubEventItem.objects.create(subevent=se, item=self.ticket, price=24) + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10), subevent=se + ) + self._set_session('payment', 'banktransfer') + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select(".alert-danger")), 1) + cr1 = CartPosition.objects.get(id=cr1.id) + self.assertEqual(cr1.price, 24) + def test_confirm_price_changed(self): self.ticket.default_price = 24 self.ticket.save() @@ -663,6 +725,35 @@ class CheckoutTestCase(TestCase): self.assertEqual(Order.objects.count(), 1) self.assertEqual(OrderPosition.objects.count(), 1) + def test_subevent_confirm_expired_partial(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now()) + se2 = self.event.subevents.create(name='Foo', date_from=now()) + self.quota_tickets.size = 10 + self.quota_tickets.subevent = se2 + self.quota_tickets.save() + q2 = se.quotas.create(event=self.event, size=1, name='Bar') + q2.items.add(self.ticket) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10), subevent=se + ) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10), subevent=se + ) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10), subevent=se2 + ) + self._set_session('payment', 'banktransfer') + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertEqual(len(doc.select(".alert-danger")), 1) + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key).count(), 2) + def test_confirm_expired_partial(self): self.quota_tickets.size = 1 self.quota_tickets.save() @@ -862,3 +953,83 @@ class CheckoutTestCase(TestCase): response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug)) self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), target_status_code=200) + + def test_set_addons_subevent(self): + self.event.has_subevents = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now()) + self.workshopquota.size = 1 + self.workshopquota.subevent = se + self.workshopquota.save() + SubEventItem.objects.create(subevent=se, item=self.workshop1, price=42) + + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10), subevent=se + ) + + response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True) + self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert 'Workshop 1 (+ EUR 42.00)' in response.rendered_content + + def test_set_addons_subevent_net_prices(self): + self.event.has_subevents = True + self.event.settings.display_net_prices = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now()) + self.workshopquota.size = 1 + self.workshopquota.subevent = se + self.workshopquota.save() + self.workshop1.tax_rate = 19 + self.workshop1.save() + self.workshop2.tax_rate = 19 + self.workshop2.save() + SubEventItem.objects.create(subevent=se, item=self.workshop1, price=42) + + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10), subevent=se + ) + + response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True) + self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert 'Workshop 1 (+ EUR 35.29 plus 19.00% taxes)' in response.rendered_content + assert 'A (+ EUR 10.08 plus 19.00% taxes)' in response.rendered_content + + def test_confirm_subevent_presale_not_yet(self): + self.event.has_subevents = True + self.event.settings.display_net_prices = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), presale_start=now() + datetime.timedelta(days=1)) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10), subevent=se + ) + self._set_session('payment', 'banktransfer') + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertGreaterEqual(len(doc.select(".alert-danger")), 1) + assert 'presale period for one of the events in your cart has not yet started.' in response.rendered_content + assert not CartPosition.objects.filter(cart_id=self.session_key).exists() + + def test_confirm_subevent_presale_over(self): + self.event.has_subevents = True + self.event.settings.display_net_prices = True + self.event.save() + se = self.event.subevents.create(name='Foo', date_from=now(), presale_end=now() - datetime.timedelta(days=1)) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10), subevent=se + ) + self._set_session('payment', 'banktransfer') + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content, "lxml") + self.assertGreaterEqual(len(doc.select(".alert-danger")), 1) + assert 'presale period for one of the events in your cart has ended.' in response.rendered_content + assert not CartPosition.objects.filter(cart_id=self.session_key).exists() diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 7b9964046..96f883557 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -14,6 +14,7 @@ from pretix.base.models import ( Event, Item, ItemCategory, ItemVariation, Order, Organizer, Quota, Team, User, WaitingListEntry, ) +from pretix.base.models.items import SubEventItem, SubEventItemVariation class EventTestMixin: @@ -130,6 +131,94 @@ class ItemDisplayTest(EventTestMixin, SoupTest): resp = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) self.assertNotIn("Early-bird", resp.rendered_content) + def test_subevents_inactive_unknown(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name='Foo', date_from=now(), active=False) + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se1.pk)) + assert resp.status_code == 404 + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se1.pk + 1000)) + assert resp.status_code == 404 + + def test_subevent_list(self): + self.event.has_subevents = True + self.event.save() + self.event.subevents.create(name='Foo SE1', date_from=now() + datetime.timedelta(days=1), active=True) + self.event.subevents.create(name='Foo SE2', date_from=now() + datetime.timedelta(days=1), active=False) + resp = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertIn("Foo SE1", resp.rendered_content) + self.assertNotIn("Foo SE2", resp.rendered_content) + + def test_subevent_calendar(self): + self.event.settings.event_list_type = 'calendar' + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name='Foo SE1', date_from=now() + datetime.timedelta(days=64), active=True) + self.event.subevents.create(name='Foo SE2', date_from=now() + datetime.timedelta(days=32), active=True) + resp = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertIn("Foo SE2", resp.rendered_content) + self.assertNotIn("Foo SE1", resp.rendered_content) + resp = self.client.get('/%s/%s/?year=%d&month=%d' % (self.orga.slug, self.event.slug, se1.date_from.year, + se1.date_from.month)) + self.assertIn("Foo SE1", resp.rendered_content) + self.assertNotIn("Foo SE2", resp.rendered_content) + + def test_subevents(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se1) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0) + q.items.add(item) + + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se1.pk)) + self.assertIn("Early-bird", resp.rendered_content) + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se2.pk)) + self.assertNotIn("Early-bird", resp.rendered_content) + + def test_subevent_prices(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=15) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se1) + q.items.add(item) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se2) + q.items.add(item) + SubEventItem.objects.create(subevent=se1, item=item, price=12) + + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se1.pk)) + self.assertIn("12.00", resp.rendered_content) + self.assertNotIn("15.00", resp.rendered_content) + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se2.pk)) + self.assertIn("15.00", resp.rendered_content) + self.assertNotIn("12.00", resp.rendered_content) + + def test_subevent_net_prices(self): + self.event.has_subevents = True + self.event.save() + self.event.settings.display_net_prices = True + se1 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=15, + tax_rate=19) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se1) + q.items.add(item) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se2) + q.items.add(item) + SubEventItem.objects.create(subevent=se1, item=item, price=12) + + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se1.pk)) + self.assertIn("10.08", resp.rendered_content) + self.assertNotIn("12.00", resp.rendered_content) + self.assertNotIn("15.00", resp.rendered_content) + resp = self.client.get('/%s/%s/%d/' % (self.orga.slug, self.event.slug, se2.pk)) + self.assertIn("12.61", resp.rendered_content) + self.assertNotIn("12.00", resp.rendered_content) + self.assertNotIn("15.00", resp.rendered_content) + def test_no_variations_in_quota(self): c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0) q = Quota.objects.create(event=self.event, name='Quota', size=2) @@ -357,6 +446,99 @@ class VoucherRedeemItemDisplayTest(EventTestMixin, SoupTest): html = self.client.get('/%s/%s/redeem?voucher=%s' % (self.orga.slug, self.event.slug, 'ABC'), follow=True) assert "alert-danger" in html.rendered_content + def test_subevent_net_prices(self): + self.event.settings.display_net_prices = True + self.event.has_subevents = True + self.event.save() + self.item.tax_rate = 19 + self.item.save() + se1 = self.event.subevents.create(name='SE1', date_from=now(), active=True) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se1) + + var1 = ItemVariation.objects.create(item=self.item, value='Red', position=1) + var2 = ItemVariation.objects.create(item=self.item, value='Black', position=2) + q.variations.add(var1) + q.variations.add(var2) + SubEventItemVariation.objects.create(subevent=se1, variation=var1, price=10) + + self.v.value = Decimal("2.00") + self.v.price_mode = 'subtract' + self.v.save() + html = self.client.get('/%s/%s/redeem?voucher=%s&subevent=%s' % ( + self.orga.slug, self.event.slug, self.v.code, se1.pk + )) + assert "SE1" in html.rendered_content + assert "Early-bird" in html.rendered_content + assert "8.40" in html.rendered_content + assert "6.72" in html.rendered_content + + def test_subevent_prices(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name='SE1', date_from=now(), active=True) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se1) + + var1 = ItemVariation.objects.create(item=self.item, value='Red', position=1) + var2 = ItemVariation.objects.create(item=self.item, value='Black', position=2) + q.variations.add(var1) + q.variations.add(var2) + SubEventItemVariation.objects.create(subevent=se1, variation=var1, price=10) + + self.v.value = Decimal("2.00") + self.v.price_mode = 'subtract' + self.v.save() + html = self.client.get('/%s/%s/redeem?voucher=%s&subevent=%s' % ( + self.orga.slug, self.event.slug, self.v.code, se1.pk + )) + assert "SE1" in html.rendered_content + assert "Early-bird" in html.rendered_content + assert "10.00" in html.rendered_content + assert "8.00" in html.rendered_content + assert "variation_%d_%d" % (self.item.pk, var1.pk) in html.rendered_content + assert "variation_%d_%d" % (self.item.pk, var2.pk) in html.rendered_content + + def test_voucher_ignore_other_subevent(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name='SE1', date_from=now(), active=True) + se2 = self.event.subevents.create(name='SE2', date_from=now(), active=True) + q = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se1) + + var1 = ItemVariation.objects.create(item=self.item, value='Red', position=1) + var2 = ItemVariation.objects.create(item=self.item, value='Black', position=2) + q.variations.add(var1) + q.variations.add(var2) + + self.v.subevent = se1 + self.v.save() + html = self.client.get('/%s/%s/redeem?voucher=%s&subevent=%s' % ( + self.orga.slug, self.event.slug, self.v.code, se2.pk + )) + assert "SE1" in html.rendered_content + + def test_voucher_quota(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name='SE1', date_from=now(), active=True) + se2 = self.event.subevents.create(name='SE2', date_from=now(), active=True) + q = Quota.objects.create(event=self.event, name='Quota', size=0, subevent=se1) + q2 = Quota.objects.create(event=self.event, name='Quota', size=2, subevent=se2) + + var1 = ItemVariation.objects.create(item=self.item, value='Red', position=1) + var2 = ItemVariation.objects.create(item=self.item, value='Black', position=2) + q.variations.add(var1) + q2.variations.add(var1) + q.variations.add(var2) + q2.variations.add(var1) + + self.v.save() + html = self.client.get('/%s/%s/redeem?voucher=%s&subevent=%s' % ( + self.orga.slug, self.event.slug, self.v.code, se1.pk + )) + assert "SE1" in html.rendered_content + assert "variation_%d_%d" % (self.item.pk, var1.pk) not in html.rendered_content + assert "variation_%d_%d" % (self.item.pk, var2.pk) not in html.rendered_content + class WaitingListTest(EventTestMixin, SoupTest): def setUp(self): @@ -376,7 +558,7 @@ class WaitingListTest(EventTestMixin, SoupTest): self.assertEqual(response.status_code, 200) self.assertNotIn('waitinglist', response.rendered_content) response = self.client.get( - '/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk + 1) + '/%s/%s/waitinglist/?item=%d' % (self.orga.slug, self.event.slug, self.item.pk + 1) ) self.assertEqual(response.status_code, 302) @@ -389,12 +571,12 @@ class WaitingListTest(EventTestMixin, SoupTest): def test_submit_form(self): response = self.client.get( - '/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk) + '/%s/%s/waitinglist/?item=%d' % (self.orga.slug, self.event.slug, self.item.pk) ) self.assertEqual(response.status_code, 200) self.assertIn('waiting list', response.rendered_content) response = self.client.post( - '/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk), { + '/%s/%s/waitinglist/?item=%d' % (self.orga.slug, self.event.slug, self.item.pk), { 'email': 'foo@bar.com' } ) @@ -406,17 +588,77 @@ class WaitingListTest(EventTestMixin, SoupTest): assert wle.voucher is None assert wle.locale == 'en' - def test_invalid_item(self): + def test_subevent_valid(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now(), active=True) + se2 = self.event.subevents.create(name="Foobar", date_from=now(), active=True) + self.q.subevent = se1 + self.q.save() + q2 = self.event.quotas.create(name="Foobar", size=100, subevent=se2) + q2.items.add(self.item) response = self.client.get( - '/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk + 1) + '/%s/%s/waitinglist/?item=%d&subevent=%d' % (self.orga.slug, self.event.slug, self.item.pk, se1.pk) + ) + self.assertEqual(response.status_code, 200) + self.assertIn('waiting list', response.rendered_content) + response = self.client.post( + '/%s/%s/waitinglist/?item=%d&subevent=%d' % (self.orga.slug, self.event.slug, self.item.pk, se1.pk), { + 'email': 'foo@bar.com' + } ) self.assertEqual(response.status_code, 302) + wle = WaitingListEntry.objects.get(email='foo@bar.com') + assert wle.event == self.event + assert wle.item == self.item + assert wle.subevent == se1 + + def test_invalid_item(self): + response = self.client.get( + '/%s/%s/waitinglist/?item=%d' % (self.orga.slug, self.event.slug, self.item.pk + 1) + ) + self.assertEqual(response.status_code, 302) + + def test_invalid_subevent(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now(), active=False) + response = self.client.get( + '/%s/%s/waitinglist/?item=%d' % (self.orga.slug, self.event.slug, self.item.pk) + ) + self.assertEqual(response.status_code, 302) + response = self.client.get( + '/%s/%s/waitinglist/?item=%d&subevent=%d' % (self.orga.slug, self.event.slug, self.item.pk, se1.pk + 100) + ) + self.assertEqual(response.status_code, 404) + response = self.client.get( + '/%s/%s/waitinglist/?item=%d&subevent=%d' % (self.orga.slug, self.event.slug, self.item.pk, se1.pk) + ) + self.assertEqual(response.status_code, 404) def test_available(self): self.q.size = 1 self.q.save() response = self.client.post( - '/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk), { + '/%s/%s/waitinglist/?item=%d' % (self.orga.slug, self.event.slug, self.item.pk), { + 'email': 'foo@bar.com' + } + ) + self.assertEqual(response.status_code, 302) + self.assertFalse(WaitingListEntry.objects.filter(email='foo@bar.com').exists()) + + def test_subevent_available(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now(), active=True) + se2 = self.event.subevents.create(name="Foobar", date_from=now(), active=True) + self.q.size = 1 + self.q.subevent = se1 + self.q.save() + q2 = self.event.quotas.create(name="Foobar", size=0, subevent=se2) + q2.items.add(self.item) + response = self.client.post( + '/%s/%s/waitinglist/?item=%d&subevent=%d' % (self.orga.slug, self.event.slug, self.item.pk, se1.pk), { 'email': 'foo@bar.com' } ) @@ -575,14 +817,14 @@ class EventIcalDownloadTest(EventTestMixin, SoupTest): self.event.save() def test_response_type(self): - ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)) + ical = self.client.get('/%s/%s/ical/' % (self.orga.slug, self.event.slug)) self.assertEqual(ical['Content-Type'], 'text/calendar') - self.assertEqual(ical['Content-Disposition'], 'attachment; filename="{}-{}.ics"'.format( + self.assertEqual(ical['Content-Disposition'], 'attachment; filename="{}-{}-0.ics"'.format( self.orga.slug, self.event.slug )) def test_header_footer(self): - ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + ical = self.client.get('/%s/%s/ical/' % (self.orga.slug, self.event.slug)).content.decode() self.assertTrue(ical.startswith('BEGIN:VCALENDAR'), 'missing VCALENDAR header') self.assertTrue(ical.strip().endswith('END:VCALENDAR'), 'missing VCALENDAR footer') self.assertIn('BEGIN:VEVENT', ical, 'missing VEVENT header') @@ -591,7 +833,7 @@ class EventIcalDownloadTest(EventTestMixin, SoupTest): def test_timezone_header_footer(self): self.event.settings.timezone = 'Asia/Tokyo' self.event.save() - ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + ical = self.client.get('/%s/%s/ical/' % (self.orga.slug, self.event.slug)).content.decode() self.assertTrue(ical.startswith('BEGIN:VCALENDAR'), 'missing VCALENDAR header') self.assertTrue(ical.strip().endswith('END:VCALENDAR'), 'missing VCALENDAR footer') self.assertIn('BEGIN:VEVENT', ical, 'missing VEVENT header') @@ -600,20 +842,20 @@ class EventIcalDownloadTest(EventTestMixin, SoupTest): self.assertIn('END:VTIMEZONE', ical, 'missing VTIMEZONE footer') def test_metadata(self): - ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + ical = self.client.get('/%s/%s/ical/' % (self.orga.slug, self.event.slug)).content.decode() self.assertIn('VERSION:2.0', ical, 'incorrect version tag - 2.0') self.assertIn('-//pretix//%s//' % settings.PRETIX_INSTANCE_NAME, ical, 'incorrect PRODID') def test_event_info(self): - ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + ical = self.client.get('/%s/%s/ical/' % (self.orga.slug, self.event.slug)).content.decode() self.assertIn('SUMMARY:%s' % self.event.name, ical, 'incorrect correct summary') self.assertIn('LOCATION:DUMMY ARENA', ical, 'incorrect location') self.assertIn('ORGANIZER:%s' % self.event.organizer.name, ical, 'incorrect organizer') self.assertTrue(re.search(r'DTSTAMP:\d{8}T\d{6}Z', ical), 'incorrect timestamp') - self.assertTrue(re.search(r'UID:\w*-\w*-\d{20}', ical), 'missing UID key') + self.assertTrue(re.search(r'UID:\w*-\w*-0-\d{20}', ical), 'missing UID key') def test_utc_timezone(self): - ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + ical = self.client.get('/%s/%s/ical/' % (self.orga.slug, self.event.slug)).content.decode() # according to icalendar spec, timezone must NOT be shown if it is UTC self.assertIn('DTSTART:%s' % self.event.date_from.strftime('%Y%m%dT%H%M%SZ'), ical, 'incorrect start time') self.assertIn('DTEND:%s' % self.event.date_to.strftime('%Y%m%dT%H%M%SZ'), ical, 'incorrect end time') @@ -621,7 +863,7 @@ class EventIcalDownloadTest(EventTestMixin, SoupTest): def test_include_timezone(self): self.event.settings.timezone = 'Asia/Tokyo' self.event.save() - ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + ical = self.client.get('/%s/%s/ical/' % (self.orga.slug, self.event.slug)).content.decode() # according to icalendar spec, timezone must be shown if it is not UTC fmt = '%Y%m%dT%H%M%S' self.assertIn('DTSTART;TZID=%s:%s' % @@ -637,7 +879,7 @@ class EventIcalDownloadTest(EventTestMixin, SoupTest): def test_no_time(self): self.event.settings.show_times = False self.event.save() - ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + ical = self.client.get('/%s/%s/ical/' % (self.orga.slug, self.event.slug)).content.decode() self.assertIn('DTSTART;VALUE=DATE:%s' % self.event.date_from.strftime('%Y%m%d'), ical, 'incorrect start date') self.assertIn('DTEND;VALUE=DATE:%s' % self.event.date_to.strftime('%Y%m%d'), ical, 'incorrect end date') @@ -645,7 +887,7 @@ class EventIcalDownloadTest(EventTestMixin, SoupTest): self.event.settings.timezone = 'Asia/Tokyo' self.event.settings.show_date_to = False self.event.save() - ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + ical = self.client.get('/%s/%s/ical/' % (self.orga.slug, self.event.slug)).content.decode() fmt = '%Y%m%dT%H%M%S' self.assertIn('DTSTART;TZID=%s:%s' % (self.event.settings.timezone, @@ -657,7 +899,7 @@ class EventIcalDownloadTest(EventTestMixin, SoupTest): self.event.settings.show_date_to = False self.event.settings.show_times = False self.event.save() - ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + ical = self.client.get('/%s/%s/ical/' % (self.orga.slug, self.event.slug)).content.decode() self.assertIn('DTSTART;VALUE=DATE:%s' % self.event.date_from.strftime('%Y%m%d'), ical, 'incorrect start date') self.assertNotIn('DTEND', ical, 'unexpected end time attribute') @@ -667,10 +909,35 @@ class EventIcalDownloadTest(EventTestMixin, SoupTest): self.event.settings.timezone = 'Asia/Tokyo' self.event.settings.show_times = False self.event.save() - ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + ical = self.client.get('/%s/%s/ical/' % (self.orga.slug, self.event.slug)).content.decode() self.assertIn('DTSTART;VALUE=DATE:20131227', ical, 'incorrect start date') self.assertIn('DTEND;VALUE=DATE:20131229', ical, 'incorrect end date') + def test_subevent_required(self): + self.event.has_subevents = True + self.event.save() + resp = self.client.get('/%s/%s/ical/' % (self.orga.slug, self.event.slug)) + assert resp.status_code == 404 + resp = self.client.get('/%s/%s/ical/100/' % (self.orga.slug, self.event.slug)) + assert resp.status_code == 404 + + def test_subevent(self): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create( + name='My fancy subevent', + location='Heeeeeere', + date_from=datetime.datetime(2014, 12, 26, 21, 57, 58, tzinfo=datetime.timezone.utc), + date_to=datetime.datetime(2014, 12, 28, 21, 57, 58, tzinfo=datetime.timezone.utc), + active=True + ) + self.event.settings.show_times = False + ical = self.client.get('/%s/%s/ical/%d/' % (self.orga.slug, self.event.slug, se1.pk)).content.decode() + self.assertIn('DTSTART;VALUE=DATE:20141226', ical, 'incorrect start date') + self.assertIn('DTEND;VALUE=DATE:20141228', ical, 'incorrect end date') + self.assertIn('SUMMARY:%s' % se1.name, ical, 'incorrect correct summary') + self.assertIn('LOCATION:Heeeeeere', ical, 'incorrect location') + class EventSlugBlacklistValidatorTest(EventTestMixin, SoupTest): def test_slug_validation(self): diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index 0843cbd46..075f61d29 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -9,6 +9,7 @@ from pretix.base.models import ( Event, Item, ItemCategory, ItemVariation, Order, OrderPosition, Organizer, Question, Quota, ) +from pretix.base.reldate import RelativeDate, RelativeDateWrapper from pretix.base.services.invoices import generate_invoice @@ -350,6 +351,21 @@ class OrdersTest(TestCase): self.order.secret), target_status_code=200) + self.event.date_from = now() + datetime.timedelta(days=3) + self.event.save() + self.event.settings.set('ticket_download_date', RelativeDateWrapper(RelativeDate( + base_date_name='date_from', days_before=2, time=None + ))) + response = self.client.get( + '/%s/%s/order/%s/%s/download/%d/testdummy' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret, self.ticket_pos.pk), + follow=True + ) + self.assertRedirects(response, + '/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, + self.order.secret), + target_status_code=200) + del self.event.settings['ticket_download_date'] response = self.client.get( '/%s/%s/order/%s/%s/download/%d/testdummy' % (self.orga.slug, self.event.slug, self.order.code, diff --git a/src/tests/presale/test_organizer_page.py b/src/tests/presale/test_organizer_page.py index 74f320136..725f1297b 100644 --- a/src/tests/presale/test_organizer_page.py +++ b/src/tests/presale/test_organizer_page.py @@ -1,7 +1,8 @@ -from datetime import timedelta +from datetime import datetime, timedelta import pytest from django.utils.timezone import now +from pytz import UTC from pretix.base.models import Event, Organizer @@ -95,3 +96,26 @@ def test_different_organizer_not_shown(env, client): ) r = client.get('/mrmcd/') assert '32C3' not in r.rendered_content + + +@pytest.mark.django_db +def test_calendar(env, client): + env[0].settings.event_list_type = 'calendar' + e = Event.objects.create( + organizer=env[0], name='MRMCD2017', slug='2017', + date_from=datetime(now().year + 1, 9, 1, tzinfo=UTC), + live=True + ) + r = client.get('/mrmcd/') + assert 'MRMCD2017' not in r.rendered_content + e.is_public = True + e.save() + r = client.get('/mrmcd/') + assert 'MRMCD2017' in r.rendered_content + assert 'September %d' % (now().year + 1) in r.rendered_content + r = client.get('/mrmcd/events/2017/10/') + assert 'MRMCD2017' not in r.rendered_content + assert 'October 2017' in r.rendered_content + r = client.get('/mrmcd/events/?month=10&year=2017') + assert 'MRMCD2017' not in r.rendered_content + assert 'October 2017' in r.rendered_content