diff --git a/doc/development/models.rst b/doc/development/models.rst index 0bbf533607..255b99e231 100644 --- a/doc/development/models.rst +++ b/doc/development/models.rst @@ -54,6 +54,9 @@ Carts and Orders .. autoclass:: pretix.base.models.Order :members: +.. autoclass:: pretix.base.models.AbstractPosition +:members: + .. autoclass:: pretix.base.models.OrderPosition :members: @@ -68,3 +71,11 @@ Logging .. autoclass:: pretix.base.models.LogEntry :members: + +Vouchers +-------- + +.. autoclass:: pretix.base.models.Voucher + :members: + +.. _cleanerversion: https://github.com/swisscom/cleanerversion diff --git a/src/pretix/base/migrations/0002_auto_20151213_1144.py b/src/pretix/base/migrations/0002_auto_20160209_0940.py similarity index 87% rename from src/pretix/base/migrations/0002_auto_20151213_1144.py rename to src/pretix/base/migrations/0002_auto_20160209_0940.py index cc6e400834..06cc0df6f3 100644 --- a/src/pretix/base/migrations/0002_auto_20151213_1144.py +++ b/src/pretix/base/migrations/0002_auto_20160209_0940.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9 on 2015-12-13 11:44 +# Generated by Django 1.9 on 2016-02-09 09:40 from __future__ import unicode_literals import uuid +from decimal import Decimal import django.core.validators import django.db.models.deletion @@ -46,17 +47,18 @@ class Migration(migrations.Migration): name='CartPosition', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('cart_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Cart ID (e.g. session key)')), ('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Price')), + ('attendee_name', models.CharField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=255, null=True, verbose_name='Attendee name')), + ('voucher_discount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)), + ('base_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('cart_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Cart ID (e.g. session key)')), ('datetime', models.DateTimeField(auto_now_add=True, verbose_name='Date')), ('expires', models.DateTimeField(verbose_name='Expiration date')), - ('attendee_name', models.CharField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=255, null=True, verbose_name='Attendee name')), ], options={ 'verbose_name_plural': 'Cart positions', 'verbose_name': 'Cart position', }, - bases=(pretix.base.models.orders.ObjectWithAnswers, models.Model), ), migrations.CreateModel( name='Event', @@ -161,7 +163,7 @@ class Migration(migrations.Migration): ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variations', to='pretixbase.Item')), ], options={ - 'ordering': ('position',), + 'ordering': ('position', 'id'), 'verbose_name_plural': 'Product variations', 'verbose_name': 'Product variation', }, @@ -211,7 +213,9 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Price')), ('attendee_name', models.CharField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=255, null=True, verbose_name='Attendee name')), - ('item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='positions', to='pretixbase.Item', verbose_name='Item')), + ('voucher_discount', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10)), + ('base_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Item', verbose_name='Item')), ('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='positions', to='pretixbase.Order', verbose_name='Order')), ('variation', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.ItemVariation', verbose_name='Variation')), ], @@ -219,7 +223,6 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Order positions', 'verbose_name': 'Order position', }, - bases=(pretix.base.models.orders.ObjectWithAnswers, models.Model), ), migrations.CreateModel( name='Organizer', @@ -299,11 +302,33 @@ class Migration(migrations.Migration): }, bases=(models.Model, pretix.base.models.base.LoggingMixin), ), + migrations.CreateModel( + name='Voucher', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=255, verbose_name='Voucher code')), + ('valid_until', models.DateTimeField(blank=True, null=True, verbose_name='Valid until')), + ('block_quota', models.BooleanField(default=False, help_text="If activated, this voucher will be substracted from the affected product's quotas, such that it is guaranteed that anyone with this voucher code does receive a ticket.", verbose_name='Reserve ticket from quota')), + ('allow_ignore_quota', models.BooleanField(default=False, help_text='If activated, a holder of this voucher code can buy tickets, even if there are none left.', verbose_name='Allow to bypass quota')), + ('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Set product price to')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='vouchers', to='pretixbase.Event', verbose_name='Event')), + ('item', models.ManyToManyField(help_text="This product is added to the user's cart if the voucher is redeemed.", related_name='vouchers', to='pretixbase.Item', verbose_name='Product')), + ], + options={ + 'verbose_name_plural': 'Vouchers', + 'verbose_name': 'Voucher', + }, + ), migrations.AddField( model_name='organizer', name='permitted', field=models.ManyToManyField(related_name='organizers', through='pretixbase.OrganizerPermission', to=settings.AUTH_USER_MODEL), ), + migrations.AddField( + model_name='orderposition', + name='voucher', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher'), + ), migrations.AddField( model_name='item', name='category', @@ -332,16 +357,25 @@ class Migration(migrations.Migration): migrations.AddField( model_name='cartposition', name='item', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Item', verbose_name='Item'), + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Item', verbose_name='Item'), ), migrations.AddField( model_name='cartposition', name='variation', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.ItemVariation', verbose_name='Variation'), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.ItemVariation', verbose_name='Variation'), + ), + migrations.AddField( + model_name='cartposition', + name='voucher', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher'), ), migrations.AddField( model_name='cachedticket', name='order', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order'), ), + migrations.AlterUniqueTogether( + name='voucher', + unique_together=set([('event', 'code')]), + ), ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 1d19acbd9c..496f5cb62c 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -6,14 +6,16 @@ from .items import ( ) from .log import LogEntry from .orders import ( - CachedTicket, CartPosition, ObjectWithAnswers, Order, OrderPosition, + AbstractPosition, CachedTicket, CartPosition, Order, OrderPosition, QuestionAnswer, generate_secret, ) from .organizer import Organizer, OrganizerPermission, OrganizerSetting +from .vouchers import Voucher __all__ = [ - 'User', 'CachedFile', 'Organizer', 'OrganizerPermission', 'Event', 'EventPermission', - 'ItemCategory', 'Item', 'ItemVariation', 'Question', 'Quota', 'Order', 'CachedTicket', 'QuestionAnswer', - 'ObjectWithAnswers', 'OrderPosition', 'CartPosition', 'EventSetting', 'OrganizerSetting', 'EventLock', - 'cachedfile_name', 'itempicture_upload_to', 'generate_secret', 'LogEntry' + 'Versionable', 'User', 'CachedFile', 'Organizer', 'OrganizerPermission', 'Event', 'EventPermission', + 'ItemCategory', 'Item', 'Property', 'PropertyValue', 'ItemVariation', 'VariationsField', 'Question', + 'BaseRestriction', 'Quota', 'Order', 'CachedTicket', 'QuestionAnswer', 'AbstractPosition', 'OrderPosition', + 'CartPosition', 'EventSetting', 'OrganizerSetting', 'EventLock', 'cachedfile_name', 'itempicture_upload_to', + 'generate_secret', 'Voucher', 'LogEntry' ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index b42dcd1862..776a5bb831 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1,6 +1,7 @@ import random import string from datetime import datetime +from decimal import Decimal from django.db import models, transaction from django.utils.timezone import now @@ -263,7 +264,63 @@ class QuestionAnswer(models.Model): answer = models.TextField() -class ObjectWithAnswers: +class AbstractPosition(models.Model): + """ + A position can either be one line of an order or an item placed in a cart. + + :param item: The selected item + :type item: Item + :param variation: The selected ItemVariation or null, if the item has no properties + :type variation: ItemVariation + :param datetime: The datetime this item was put into the cart + :type datetime: datetime + :param expires: The date until this item is guarenteed to be reserved + :type expires: datetime + :param price: The price of this item + :type price: decimal.Decimal + :param attendee_name: The attendee's name, if entered. + :type attendee_name: str + :param voucher: A voucher that has been applied to this sale + :type voucher: Voucher + :param voucher_discount: The absolute discount granted by the applied voucher + :type voucher_discount: decimal.Decimal + :param base_price: The base price without any discounts applied + :type base_price: decimal.Decimal + """ + item = models.ForeignKey( + Item, + verbose_name=_("Item"), + on_delete=models.PROTECT + ) + variation = models.ForeignKey( + ItemVariation, + null=True, blank=True, + verbose_name=_("Variation"), + on_delete=models.PROTECT + ) + price = models.DecimalField( + decimal_places=2, max_digits=10, + verbose_name=_("Price") + ) + attendee_name = models.CharField( + max_length=255, + verbose_name=_("Attendee name"), + blank=True, null=True, + help_text=_("Empty, if this product is not an admission ticket") + ) + voucher = models.ForeignKey( + 'Voucher', null=True, blank=True + ) + voucher_discount = models.DecimalField( + default=Decimal('0.00'), decimal_places=2, max_digits=10 + ) + base_price = models.DecimalField( + decimal_places=2, max_digits=10, null=True, blank=True + ) + + class Meta: + abstract = True + def cache_answers(self): """ Creates two properties on the object. @@ -281,22 +338,22 @@ class ObjectWithAnswers: q.answer = "" self.questions.append(q) + def save(self, *args, **kwargs): + if self.voucher is None and self.base_price is None: + self.base_price = self.price + if self.voucher_discount != Decimal('0.00') and self.base_price is not None: + self.price = self.base_price - self.voucher_discount + return super().save(*args, **kwargs) -class OrderPosition(ObjectWithAnswers, models.Model): + +class OrderPosition(AbstractPosition): """ An OrderPosition is one line of an order, representing one ordered items - of a specified type (or variation). + of a specified type (or variation). This has all properties of + AbstractPosition. :param order: The order this is a part of :type order: Order - :param item: The ordered item - :type item: Item - :param variation: The ordered ItemVariation or null, if the item has no properties - :type variation: ItemVariation - :param price: The price of this item - :type price: decimal.Decimal - :param attendee_name: The attendee's name, if entered. - :type attendee_name: str """ order = models.ForeignKey( Order, @@ -304,28 +361,6 @@ class OrderPosition(ObjectWithAnswers, models.Model): related_name='positions', on_delete=models.PROTECT ) - item = models.ForeignKey( - Item, - verbose_name=_("Item"), - related_name='positions', - on_delete=models.PROTECT - ) - variation = models.ForeignKey( - ItemVariation, - null=True, blank=True, - verbose_name=_("Variation"), - on_delete=models.PROTECT - ) - price = models.DecimalField( - decimal_places=2, max_digits=10, - verbose_name=_("Price") - ) - attendee_name = models.CharField( - max_length=255, - verbose_name=_("Attendee name"), - blank=True, null=True, - help_text=_("Empty, if this product is not an admission ticket") - ) class Meta: verbose_name = _("Order position") @@ -335,11 +370,9 @@ class OrderPosition(ObjectWithAnswers, models.Model): def transform_cart_positions(cls, cp: List, order) -> list: ops = [] for cartpos in cp: - op = OrderPosition( - order=order, item=cartpos.item, variation=cartpos.variation, - price=cartpos.price, attendee_name=cartpos.attendee_name - ) - op.save() + op = OrderPosition(order=order) + for f in AbstractPosition._meta.fields: + setattr(op, f.name, getattr(cartpos, f.name)) for answ in cartpos.answers.all(): answ.orderposition = op answ.cartposition = None @@ -348,31 +381,19 @@ class OrderPosition(ObjectWithAnswers, models.Model): ops.append(op) -class CartPosition(ObjectWithAnswers, models.Model): +class CartPosition(AbstractPosition): """ A cart position is similar to a order line, except that it is not yet part of a binding order but just placed by some user in his or her cart. It therefore normally has a much shorter expiration time than an ordered position, but still blocks an item in the quota pool as we do not want to throw out users while they're clicking through - the checkout process. + the checkout process. This has all properties of AbstractPosition. :param event: The event this belongs to :type event: Evnt - :param item: The selected item - :type item: Item :param cart_id: The user session that contains this cart position :type cart_id: str - :param variation: The selected ItemVariation or null, if the item has no properties - :type variation: ItemVariation - :param datetime: The datetime this item was put into the cart - :type datetime: datetime - :param expires: The date until this item is guarenteed to be reserved - :type expires: datetime - :param price: The price of this item - :type price: decimal.Decimal - :param attendee_name: The attendee's name, if entered. - :type attendee_name: str """ event = models.ForeignKey( Event, @@ -382,21 +403,6 @@ class CartPosition(ObjectWithAnswers, models.Model): max_length=255, null=True, blank=True, verbose_name=_("Cart ID (e.g. session key)") ) - item = models.ForeignKey( - Item, - verbose_name=_("Item"), - on_delete=models.CASCADE - ) - variation = models.ForeignKey( - ItemVariation, - null=True, blank=True, - verbose_name=_("Variation"), - on_delete=models.CASCADE - ) - price = models.DecimalField( - decimal_places=2, max_digits=10, - verbose_name=_("Price") - ) datetime = models.DateTimeField( verbose_name=_("Date"), auto_now_add=True @@ -404,12 +410,6 @@ class CartPosition(ObjectWithAnswers, models.Model): expires = models.DateTimeField( verbose_name=_("Expiration date") ) - attendee_name = models.CharField( - max_length=255, - verbose_name=_("Attendee name"), - blank=True, null=True, - help_text=_("Empty, if this product is not an admission ticket") - ) class Meta: verbose_name = _("Cart position") diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py new file mode 100644 index 0000000000..55f56b4cc4 --- /dev/null +++ b/src/pretix/base/models/vouchers.py @@ -0,0 +1,68 @@ +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from .event import Event +from .items import Item +from .orders import CartPosition, OrderPosition + + +class Voucher(models.Model): + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="vouchers", + verbose_name=_("Event"), + ) + code = models.CharField( + verbose_name=_("Voucher code"), + max_length=255 + ) + valid_until = models.DateTimeField( + blank=True, null=True, + verbose_name=_("Valid until") + ) + block_quota = models.BooleanField( + default=False, + verbose_name=_("Reserve ticket from quota"), + help_text=_( + "If activated, this voucher will be substracted from the affected product\'s quotas, such that it is " + "guaranteed that anyone with this voucher code does receive a ticket." + ) + ) + allow_ignore_quota = models.BooleanField( + default=False, + verbose_name=_("Allow to bypass quota"), + help_text=_( + "If activated, a holder of this voucher code can buy tickets, even if there are none left." + ) + ) + price = models.DecimalField( + verbose_name=_("Set product price to"), + decimal_places=2, max_digits=10, null=True, blank=True + ) + item = models.ManyToManyField( + Item, related_name='vouchers', + verbose_name=_("Product"), + help_text=_( + "This product is added to the user's cart if the voucher is redeemed." + ) + ) + + class Meta: + verbose_name = _("Voucher") + verbose_name_plural = _("Vouchers") + unique_together = (("event", "code"),) + + def save(self, *args, **kwargs): + self.code = self.code.upper() + super().save(*args, **kwargs) + + def is_ordered(self) -> int: + return OrderPosition.objects.current.filter( + voucher=self.voucher + ).exists() + + def is_in_cart(self) -> int: + return CartPosition.objects.current.filter( + voucher=self.voucher + ).count() diff --git a/src/pretix/multidomain/migrations/0001_initial.py b/src/pretix/multidomain/migrations/0001_initial.py index 1b37be6885..3bc372ede2 100644 --- a/src/pretix/multidomain/migrations/0001_initial.py +++ b/src/pretix/multidomain/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.9 on 2015-12-13 11:44 +# Generated by Django 1.9 on 2016-02-09 09:40 from __future__ import unicode_literals import django.db.models.deletion @@ -11,7 +11,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('pretixbase', '0002_auto_20151213_1144'), + ('pretixbase', '0002_auto_20160209_0940'), ] operations = [