diff --git a/doc/development/models.rst b/doc/development/models.rst index d581bf5da8..0bbf533607 100644 --- a/doc/development/models.rst +++ b/doc/development/models.rst @@ -39,19 +39,12 @@ Items .. autoclass:: pretix.base.models.ItemCategory :members: -.. autoclass:: pretix.base.models.Property - :members: - -.. autoclass:: pretix.base.models.PropertyValue +.. autoclass:: pretix.base.models.ItemVariation :members: .. autoclass:: pretix.base.models.Question :members: -.. autoclass:: pretix.base.models.ItemVariation - :members: - :exclude-members: add_values_from_string - .. autoclass:: pretix.base.models.Quota :members: diff --git a/src/make_testdata.py b/src/make_testdata.py index 93f2c1dc81..b78251098b 100644 --- a/src/make_testdata.py +++ b/src/make_testdata.py @@ -52,24 +52,9 @@ item_shirt = Item.objects.create( event=event, category=cat_merch, name='T-Shirt', default_price=15, tax_rate=19 ) -size_prop = Property.objects.create( - event=event, name='Size', item=item_shirt -) -size_s = PropertyValue.objects.create( - prop=size_prop, value='S' -) -size_l = PropertyValue.objects.create( - prop=size_prop, value='L' -) -size_m = PropertyValue.objects.create( - prop=size_prop, value='M' -) -var_s = ItemVariation.objects.create(item=item_shirt) -var_s.values.add(size_s) -var_m = ItemVariation.objects.create(item=item_shirt) -var_m.values.add(size_m) -var_l = ItemVariation.objects.create(item=item_shirt) -var_l.values.add(size_l) +var_s = ItemVariation.objects.create(item=item_shirt, value='S') +var_m = ItemVariation.objects.create(item=item_shirt, value='M') +var_l = ItemVariation.objects.create(item=item_shirt, value='L') ticket_quota = Quota.objects.create( event=event, name='Ticket quota', size=400, ) diff --git a/src/pretix/base/exporter.py b/src/pretix/base/exporter.py index b66a447b24..0654d7f08f 100644 --- a/src/pretix/base/exporter.py +++ b/src/pretix/base/exporter.py @@ -120,7 +120,7 @@ class JSONExporter(BaseExporter): { 'code': order.code, 'status': order.status, - 'user': order.user.email, + 'user': order.email, 'datetime': order.datetime, 'payment_fee': order.payment_fee, 'total': order.total, @@ -140,8 +140,7 @@ class JSONExporter(BaseExporter): } for position in order.positions.all() ] } for order in - self.event.orders.all().prefetch_related('positions', 'positions__answers').select_related( - 'user') + self.event.orders.all().prefetch_related('positions', 'positions__answers') ], 'quotas': [ { diff --git a/src/pretix/base/i18n.py b/src/pretix/base/i18n.py index b6d86cc7cc..7a0532a651 100644 --- a/src/pretix/base/i18n.py +++ b/src/pretix/base/i18n.py @@ -5,6 +5,7 @@ from django import forms from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder from django.db.models import Model, QuerySet, TextField +from django.forms import BaseModelFormSet from django.utils import translation from django.utils.safestring import mark_safe from typing import Dict, List @@ -251,3 +252,29 @@ class I18nJSONEncoder(DjangoJSONEncoder): return {'type': obj.__class__.__name__, 'id': obj.id} else: return super().default(obj) + + +class I18nFormSet(BaseModelFormSet): + """ + This is equivalent to a normal BaseModelFormset, but cares for the special needs + of I18nForms (see there for more information). + """ + + def __init__(self, *args, **kwargs): + self.event = kwargs.pop('event', None) + super().__init__(*args, **kwargs) + + def _construct_form(self, i, **kwargs): + kwargs['event'] = self.event + 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, + event=self.event + ) + self.add_fields(form, None) + return form diff --git a/src/pretix/base/migrations/0001_initial.py b/src/pretix/base/migrations/0001_initial.py index 91e65ce6c5..17aae79ca1 100644 --- a/src/pretix/base/migrations/0001_initial.py +++ b/src/pretix/base/migrations/0001_initial.py @@ -1,19 +1,9 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import uuid - -import django.core.validators -import django.db.models.deletion -from django.conf import settings from django.contrib.auth.hashers import make_password from django.db import migrations, models -import pretix.base.i18n -import pretix.base.models.base -import pretix.base.models.items -import pretix.base.models.orders - def initial_user(apps, schema_editor): User = apps.get_model("pretixbase", "User") @@ -54,335 +44,5 @@ class Migration(migrations.Migration): 'verbose_name_plural': 'Users', }, ), - migrations.CreateModel( - name='CachedFile', - fields=[ - ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), - ('expires', models.DateTimeField(blank=True, null=True)), - ('date', models.DateTimeField(blank=True, null=True)), - ('filename', models.CharField(max_length=255)), - ('type', models.CharField(max_length=255)), - ('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.base.cachedfile_name)), - ], - ), - migrations.CreateModel( - name='CachedTicket', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('provider', models.CharField(max_length=255)), - ('cachedfile', models.ForeignKey(to='pretixbase.CachedFile')), - ], - ), - migrations.CreateModel( - name='CartPosition', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('cart_id', models.CharField(verbose_name='Cart ID (e.g. session key)', max_length=255, blank=True, null=True)), - ('price', models.DecimalField(verbose_name='Price', max_digits=10, decimal_places=2)), - ('datetime', models.DateTimeField(verbose_name='Date', auto_now_add=True)), - ('expires', models.DateTimeField(verbose_name='Expiration date')), - ('attendee_name', models.CharField(verbose_name='Attendee name', max_length=255, blank=True, null=True, help_text='Empty, if this product is not an admission ticket')), - ], - options={ - 'verbose_name': 'Cart position', - 'verbose_name_plural': 'Cart positions', - }, - bases=(pretix.base.models.orders.ObjectWithAnswers, models.Model), - ), - migrations.CreateModel( - name='Event', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('name', pretix.base.i18n.I18nCharField(verbose_name='Name', max_length=200)), - ('slug', models.SlugField(verbose_name='Slug', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.')), - ('currency', models.CharField(verbose_name='Default currency', default='EUR', max_length=10)), - ('date_from', models.DateTimeField(verbose_name='Event start time')), - ('date_to', models.DateTimeField(verbose_name='Event end time', blank=True, null=True)), - ('is_public', models.BooleanField(verbose_name='Visible in public lists', default=False, help_text="If selected, this event may show up on the ticket system's start page or an organization profile.")), - ('presale_end', models.DateTimeField(verbose_name='End of presale', help_text='No products will be sold after this date.', blank=True, null=True)), - ('presale_start', models.DateTimeField(verbose_name='Start of presale', help_text='No products will be sold before this date.', blank=True, null=True)), - ('plugins', models.TextField(verbose_name='Plugins', blank=True, null=True)), - ], - options={ - 'verbose_name': 'Event', - 'verbose_name_plural': 'Events', - 'ordering': ('date_from', 'name'), - }, - ), - migrations.CreateModel( - name='EventLock', - fields=[ - ('event', models.CharField(max_length=36, primary_key=True, serialize=False)), - ('date', models.DateTimeField(auto_now=True)), - ('token', models.UUIDField(default=uuid.uuid4)), - ], - ), - migrations.CreateModel( - name='EventPermission', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('can_change_settings', models.BooleanField(verbose_name='Can change event settings', default=True)), - ('can_change_items', models.BooleanField(verbose_name='Can change product settings', default=True)), - ('can_view_orders', models.BooleanField(verbose_name='Can view orders', default=True)), - ('can_change_permissions', models.BooleanField(verbose_name='Can change permissions', default=True)), - ('can_change_orders', models.BooleanField(verbose_name='Can change orders', default=True)), - ('event', models.ForeignKey(to='pretixbase.Event', related_name='user_perms')), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='event_perms')), - ], - options={ - 'verbose_name': 'Event permission', - 'verbose_name_plural': 'Event permissions', - }, - ), - migrations.CreateModel( - name='EventSetting', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('key', models.CharField(max_length=255)), - ('value', models.TextField()), - ('object', models.ForeignKey(to='pretixbase.Event', related_name='setting_objects')), - ], - ), - migrations.CreateModel( - name='Item', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('name', pretix.base.i18n.I18nCharField(verbose_name='Item name', max_length=255)), - ('active', models.BooleanField(verbose_name='Active', default=True)), - ('description', pretix.base.i18n.I18nTextField(verbose_name='Description', help_text='This is shown below the product name in lists.', blank=True, null=True)), - ('default_price', models.DecimalField(verbose_name='Default price', max_digits=7, decimal_places=2, null=True)), - ('tax_rate', models.DecimalField(verbose_name='Taxes included in percent', max_digits=7, blank=True, null=True, decimal_places=2)), - ('admission', models.BooleanField(verbose_name='Is an admission ticket', default=False, help_text='Whether or not buying this product allows a person to enter your event')), - ('position', models.IntegerField(default=0)), - ('picture', models.ImageField(verbose_name='Product picture', blank=True, null=True, upload_to=pretix.base.models.items.itempicture_upload_to)), - ('available_from', models.DateTimeField(verbose_name='Available from', help_text='This product will not be sold before the given date.', blank=True, null=True)), - ('available_until', models.DateTimeField(verbose_name='Available until', help_text='This product will not be sold after the given date.', blank=True, null=True)), - ], - options={ - 'verbose_name': 'Product', - 'verbose_name_plural': 'Products', - 'ordering': ('category__position', 'category', 'position'), - }, - ), - migrations.CreateModel( - name='ItemCategory', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('name', pretix.base.i18n.I18nCharField(verbose_name='Category name', max_length=255)), - ('position', models.IntegerField(default=0)), - ('event', models.ForeignKey(to='pretixbase.Event', related_name='categories')), - ], - options={ - 'verbose_name': 'Product category', - 'verbose_name_plural': 'Product categories', - 'ordering': ('position', 'id'), - }, - ), - migrations.CreateModel( - name='ItemVariation', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('active', models.BooleanField(verbose_name='Active', default=True)), - ('default_price', models.DecimalField(verbose_name='Default price', max_digits=7, blank=True, null=True, decimal_places=2)), - ('item', models.ForeignKey(to='pretixbase.Item', related_name='variations')), - ], - options={ - 'verbose_name': 'Product variation', - 'verbose_name_plural': 'Product variations', - }, - ), - migrations.CreateModel( - name='Order', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('code', models.CharField(verbose_name='Order code', max_length=16)), - ('status', models.CharField(verbose_name='Status', max_length=3, choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')])), - ('email', models.EmailField(verbose_name='E-mail', max_length=254, blank=True, null=True)), - ('locale', models.CharField(verbose_name='Locale', max_length=32, blank=True, null=True)), - ('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)), - ('datetime', models.DateTimeField(verbose_name='Date')), - ('expires', models.DateTimeField(verbose_name='Expiration date')), - ('payment_date', models.DateTimeField(verbose_name='Payment date', blank=True, null=True)), - ('payment_provider', models.CharField(verbose_name='Payment provider', max_length=255, blank=True, null=True)), - ('payment_fee', models.DecimalField(verbose_name='Payment method fee', default=0, max_digits=10, decimal_places=2)), - ('payment_info', models.TextField(verbose_name='Payment information', blank=True, null=True)), - ('payment_manual', models.BooleanField(verbose_name='Payment state was manually modified', default=False)), - ('total', models.DecimalField(verbose_name='Total amount', max_digits=10, decimal_places=2)), - ('event', models.ForeignKey(to='pretixbase.Event', verbose_name='Event', related_name='orders')), - ], - options={ - 'verbose_name': 'Order', - 'verbose_name_plural': 'Orders', - 'ordering': ('-datetime',), - }, - ), - migrations.CreateModel( - name='OrderPosition', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('price', models.DecimalField(verbose_name='Price', max_digits=10, decimal_places=2)), - ('attendee_name', models.CharField(verbose_name='Attendee name', max_length=255, blank=True, null=True, help_text='Empty, if this product is not an admission ticket')), - ('item', models.ForeignKey(to='pretixbase.Item', on_delete=django.db.models.deletion.PROTECT, verbose_name='Item', related_name='positions')), - ('order', models.ForeignKey(to='pretixbase.Order', on_delete=django.db.models.deletion.PROTECT, verbose_name='Order', related_name='positions')), - ('variation', models.ForeignKey(blank=True, to='pretixbase.ItemVariation', on_delete=django.db.models.deletion.PROTECT, verbose_name='Variation', null=True)), - ], - options={ - 'verbose_name': 'Order position', - 'verbose_name_plural': 'Order positions', - }, - bases=(pretix.base.models.orders.ObjectWithAnswers, models.Model), - ), - migrations.CreateModel( - name='Organizer', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('name', models.CharField(verbose_name='Name', max_length=200)), - ('slug', models.SlugField(verbose_name='Slug', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.')), - ], - options={ - 'verbose_name': 'Organizer', - 'verbose_name_plural': 'Organizers', - 'ordering': ('name',), - }, - ), - migrations.CreateModel( - name='OrganizerPermission', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('can_create_events', models.BooleanField(verbose_name='Can create events', default=True)), - ('organizer', models.ForeignKey(to='pretixbase.Organizer', related_name='user_perms')), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='organizer_perms')), - ], - options={ - 'verbose_name': 'Organizer permission', - 'verbose_name_plural': 'Organizer permissions', - }, - ), - migrations.CreateModel( - name='OrganizerSetting', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('key', models.CharField(max_length=255)), - ('value', models.TextField()), - ('object', models.ForeignKey(to='pretixbase.Organizer', related_name='setting_objects')), - ], - ), - migrations.CreateModel( - name='Property', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('name', pretix.base.i18n.I18nCharField(verbose_name='Property name', max_length=250)), - ('event', models.ForeignKey(to='pretixbase.Event', related_name='properties')), - ('item', models.ForeignKey(blank=True, to='pretixbase.Item', null=True, related_name='properties')), - ], - options={ - 'verbose_name': 'Product property', - 'verbose_name_plural': 'Product properties', - }, - ), - migrations.CreateModel( - name='PropertyValue', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('value', pretix.base.i18n.I18nCharField(verbose_name='Value', max_length=250)), - ('position', models.IntegerField(default=0)), - ('prop', models.ForeignKey(to='pretixbase.Property', related_name='values')), - ], - options={ - 'verbose_name': 'Property value', - 'verbose_name_plural': 'Property values', - 'ordering': ('position', 'id'), - }, - ), - migrations.CreateModel( - name='Question', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('question', pretix.base.i18n.I18nTextField(verbose_name='Question')), - ('type', models.CharField(verbose_name='Question type', max_length=5, choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No')])), - ('required', models.BooleanField(verbose_name='Required question', default=False)), - ('event', models.ForeignKey(to='pretixbase.Event', related_name='questions')), - ('items', models.ManyToManyField(verbose_name='Products', help_text='This question will be asked to buyers of the selected products', blank=True, to='pretixbase.Item', related_name='questions')), - ], - options={ - 'verbose_name': 'Question', - 'verbose_name_plural': 'Questions', - }, - ), - migrations.CreateModel( - name='QuestionAnswer', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('answer', models.TextField()), - ('cartposition', models.ForeignKey(blank=True, to='pretixbase.CartPosition', null=True, related_name='answers')), - ('orderposition', models.ForeignKey(blank=True, to='pretixbase.OrderPosition', null=True, related_name='answers')), - ('question', models.ForeignKey(to='pretixbase.Question', related_name='answers')), - ], - ), - migrations.CreateModel( - name='Quota', - fields=[ - ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True, serialize=False)), - ('name', models.CharField(verbose_name='Name', max_length=200)), - ('size', models.PositiveIntegerField(verbose_name='Total capacity', help_text='Leave empty for an unlimited number of tickets.', blank=True, null=True)), - ('event', models.ForeignKey(to='pretixbase.Event', verbose_name='Event', related_name='quotas')), - ('items', models.ManyToManyField(verbose_name='Item', to='pretixbase.Item', blank=True, related_name='quotas')), - ('variations', pretix.base.models.items.VariationsField(verbose_name='Variations', to='pretixbase.ItemVariation', blank=True, related_name='quotas')), - ], - options={ - 'verbose_name': 'Quota', - 'verbose_name_plural': 'Quotas', - }, - ), - migrations.AddField( - model_name='organizer', - name='permitted', - field=models.ManyToManyField(to=settings.AUTH_USER_MODEL, through='pretixbase.OrganizerPermission', related_name='organizers'), - ), - migrations.AddField( - model_name='itemvariation', - name='values', - field=models.ForeignKey(to='pretixbase.PropertyValue', related_name='variations'), - ), - migrations.AddField( - model_name='item', - name='category', - field=models.ForeignKey(blank=True, to='pretixbase.ItemCategory', on_delete=django.db.models.deletion.PROTECT, verbose_name='Category', null=True, related_name='items'), - ), - migrations.AddField( - model_name='item', - name='event', - field=models.ForeignKey(to='pretixbase.Event', on_delete=django.db.models.deletion.PROTECT, verbose_name='Event', related_name='items'), - ), - migrations.AddField( - model_name='event', - name='organizer', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Organizer', related_name='events'), - ), - migrations.AddField( - model_name='event', - name='permitted', - field=models.ManyToManyField(to=settings.AUTH_USER_MODEL, through='pretixbase.EventPermission', related_name='events'), - ), - migrations.AddField( - model_name='cartposition', - name='event', - field=models.ForeignKey(verbose_name='Event', to='pretixbase.Event'), - ), - migrations.AddField( - model_name='cartposition', - name='item', - field=models.ForeignKey(verbose_name='Item', to='pretixbase.Item'), - ), - migrations.AddField( - model_name='cartposition', - name='variation', - field=models.ForeignKey(blank=True, to='pretixbase.ItemVariation', verbose_name='Variation', null=True), - ), - migrations.AddField( - model_name='cachedticket', - name='order', - field=models.ForeignKey(to='pretixbase.Order'), - ), migrations.RunPython(initial_user), ] diff --git a/src/pretix/base/migrations/0002_auto_20151212_1123.py b/src/pretix/base/migrations/0002_auto_20151212_1123.py deleted file mode 100644 index 45708bd5cf..0000000000 --- a/src/pretix/base/migrations/0002_auto_20151212_1123.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('pretixbase', '0001_initial'), - ] - - operations = [ - migrations.RemoveField( - model_name='itemvariation', - name='values', - ), - migrations.AddField( - model_name='itemvariation', - name='values', - field=models.ManyToManyField(related_name='variations', to='pretixbase.PropertyValue'), - ), - ] diff --git a/src/pretix/base/migrations/0002_auto_20151213_1144.py b/src/pretix/base/migrations/0002_auto_20151213_1144.py new file mode 100644 index 0000000000..cc6e400834 --- /dev/null +++ b/src/pretix/base/migrations/0002_auto_20151213_1144.py @@ -0,0 +1,347 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-13 11:44 +from __future__ import unicode_literals + +import uuid + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import pretix.base.i18n +import pretix.base.models.base +import pretix.base.models.items +import pretix.base.models.orders + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('pretixbase', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CachedFile', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('expires', models.DateTimeField(blank=True, null=True)), + ('date', models.DateTimeField(blank=True, null=True)), + ('filename', models.CharField(max_length=255)), + ('type', models.CharField(max_length=255)), + ('file', models.FileField(blank=True, null=True, upload_to=pretix.base.models.base.cachedfile_name)), + ], + ), + migrations.CreateModel( + name='CachedTicket', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('provider', models.CharField(max_length=255)), + ('cachedfile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.CachedFile')), + ], + ), + migrations.CreateModel( + 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')), + ('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', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', pretix.base.i18n.I18nCharField(max_length=200, verbose_name='Name')), + ('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')), + ('currency', models.CharField(default='EUR', max_length=10, verbose_name='Default currency')), + ('date_from', models.DateTimeField(verbose_name='Event start time')), + ('date_to', models.DateTimeField(blank=True, null=True, verbose_name='Event end time')), + ('is_public', models.BooleanField(default=False, help_text="If selected, this event may show up on the ticket system's start page or an organization profile.", verbose_name='Visible in public lists')), + ('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')), + ('plugins', models.TextField(blank=True, null=True, verbose_name='Plugins')), + ], + options={ + 'ordering': ('date_from', 'name'), + 'verbose_name_plural': 'Events', + 'verbose_name': 'Event', + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.CreateModel( + name='EventLock', + fields=[ + ('event', models.CharField(max_length=36, primary_key=True, serialize=False)), + ('date', models.DateTimeField(auto_now=True)), + ('token', models.UUIDField(default=uuid.uuid4)), + ], + ), + migrations.CreateModel( + name='EventPermission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('can_change_settings', models.BooleanField(default=True, verbose_name='Can change event settings')), + ('can_change_items', models.BooleanField(default=True, verbose_name='Can change product settings')), + ('can_view_orders', models.BooleanField(default=True, verbose_name='Can view orders')), + ('can_change_permissions', models.BooleanField(default=True, verbose_name='Can change permissions')), + ('can_change_orders', models.BooleanField(default=True, verbose_name='Can change orders')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_perms', to='pretixbase.Event')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_perms', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'Event permissions', + 'verbose_name': 'Event permission', + }, + ), + migrations.CreateModel( + name='EventSetting', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=255)), + ('value', models.TextField()), + ('object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='setting_objects', to='pretixbase.Event')), + ], + ), + migrations.CreateModel( + name='Item', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', pretix.base.i18n.I18nCharField(max_length=255, verbose_name='Item name')), + ('active', models.BooleanField(default=True, verbose_name='Active')), + ('description', pretix.base.i18n.I18nTextField(blank=True, help_text='This is shown below the product name in lists.', null=True, verbose_name='Description')), + ('default_price', models.DecimalField(decimal_places=2, max_digits=7, null=True, verbose_name='Default price')), + ('tax_rate', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='Taxes included in percent')), + ('admission', models.BooleanField(default=False, help_text='Whether or not buying this product allows a person to enter your event', verbose_name='Is an admission ticket')), + ('position', models.IntegerField(default=0)), + ('picture', models.ImageField(blank=True, null=True, upload_to=pretix.base.models.items.itempicture_upload_to, verbose_name='Product picture')), + ('available_from', models.DateTimeField(blank=True, help_text='This product will not be sold before the given date.', null=True, verbose_name='Available from')), + ('available_until', models.DateTimeField(blank=True, help_text='This product will not be sold after the given date.', null=True, verbose_name='Available until')), + ], + options={ + 'ordering': ('category__position', 'category', 'position'), + 'verbose_name_plural': 'Products', + 'verbose_name': 'Product', + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.CreateModel( + name='ItemCategory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', pretix.base.i18n.I18nCharField(max_length=255, verbose_name='Category name')), + ('position', models.IntegerField(default=0)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='pretixbase.Event')), + ], + options={ + 'ordering': ('position', 'id'), + 'verbose_name_plural': 'Product categories', + 'verbose_name': 'Product category', + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.CreateModel( + name='ItemVariation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('value', pretix.base.i18n.I18nCharField(max_length=255, verbose_name='Description')), + ('active', models.BooleanField(default=True, verbose_name='Active')), + ('position', models.PositiveIntegerField(default=0, verbose_name='Position')), + ('default_price', models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True, verbose_name='Default price')), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variations', to='pretixbase.Item')), + ], + options={ + 'ordering': ('position',), + 'verbose_name_plural': 'Product variations', + 'verbose_name': 'Product variation', + }, + ), + migrations.CreateModel( + name='LogEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('datetime', models.DateTimeField(auto_now_add=True)), + ('action_type', models.CharField(max_length=255)), + ('data', models.TextField(default='{}')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=16, verbose_name='Order code')), + ('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')), + ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail')), + ('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')), + ('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)), + ('datetime', models.DateTimeField(verbose_name='Date')), + ('expires', models.DateTimeField(verbose_name='Expiration date')), + ('payment_date', models.DateTimeField(blank=True, null=True, verbose_name='Payment date')), + ('payment_provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider')), + ('payment_fee', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Payment method fee')), + ('payment_info', models.TextField(blank=True, null=True, verbose_name='Payment information')), + ('payment_manual', models.BooleanField(default=False, verbose_name='Payment state was manually modified')), + ('total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Total amount')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='pretixbase.Event', verbose_name='Event')), + ], + options={ + 'ordering': ('-datetime',), + 'verbose_name_plural': 'Orders', + 'verbose_name': 'Order', + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.CreateModel( + name='OrderPosition', + fields=[ + ('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')), + ('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')), + ], + options={ + 'verbose_name_plural': 'Order positions', + 'verbose_name': 'Order position', + }, + bases=(pretix.base.models.orders.ObjectWithAnswers, models.Model), + ), + migrations.CreateModel( + name='Organizer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('slug', models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$')], verbose_name='Slug')), + ], + options={ + 'ordering': ('name',), + 'verbose_name_plural': 'Organizers', + 'verbose_name': 'Organizer', + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.CreateModel( + name='OrganizerPermission', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('can_create_events', models.BooleanField(default=True, verbose_name='Can create events')), + ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_perms', to='pretixbase.Organizer')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='organizer_perms', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'Organizer permissions', + 'verbose_name': 'Organizer permission', + }, + ), + migrations.CreateModel( + name='OrganizerSetting', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=255)), + ('value', models.TextField()), + ('object', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='setting_objects', to='pretixbase.Organizer')), + ], + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question', pretix.base.i18n.I18nTextField(verbose_name='Question')), + ('type', models.CharField(choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No')], max_length=5, verbose_name='Question type')), + ('required', models.BooleanField(default=False, verbose_name='Required question')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='pretixbase.Event')), + ('items', models.ManyToManyField(blank=True, help_text='This question will be asked to buyers of the selected products', related_name='questions', to='pretixbase.Item', verbose_name='Products')), + ], + options={ + 'verbose_name_plural': 'Questions', + 'verbose_name': 'Question', + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.CreateModel( + name='QuestionAnswer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('answer', models.TextField()), + ('cartposition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.CartPosition')), + ('orderposition', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.OrderPosition')), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='pretixbase.Question')), + ], + ), + migrations.CreateModel( + name='Quota', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Name')), + ('size', models.PositiveIntegerField(blank=True, help_text='Leave empty for an unlimited number of tickets.', null=True, verbose_name='Total capacity')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quotas', to='pretixbase.Event', verbose_name='Event')), + ('items', models.ManyToManyField(blank=True, related_name='quotas', to='pretixbase.Item', verbose_name='Item')), + ('variations', models.ManyToManyField(blank=True, related_name='quotas', to='pretixbase.ItemVariation', verbose_name='Variations')), + ], + options={ + 'verbose_name_plural': 'Quotas', + 'verbose_name': 'Quota', + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + 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='item', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='items', to='pretixbase.ItemCategory', verbose_name='Category'), + ), + migrations.AddField( + model_name='item', + name='event', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='items', to='pretixbase.Event', verbose_name='Event'), + ), + migrations.AddField( + model_name='event', + name='organizer', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='events', to='pretixbase.Organizer'), + ), + migrations.AddField( + model_name='event', + name='permitted', + field=models.ManyToManyField(related_name='events', through='pretixbase.EventPermission', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='cartposition', + name='event', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event', verbose_name='Event'), + ), + migrations.AddField( + model_name='cartposition', + name='item', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, 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'), + ), + migrations.AddField( + model_name='cachedticket', + name='order', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Order'), + ), + ] diff --git a/src/pretix/base/migrations/0003_logentry.py b/src/pretix/base/migrations/0003_logentry.py deleted file mode 100644 index 2bbd029d29..0000000000 --- a/src/pretix/base/migrations/0003_logentry.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.9 on 2015-12-12 13:32 -from __future__ import unicode_literals - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('contenttypes', '0002_remove_content_type_name'), - ('pretixbase', '0002_auto_20151212_1123'), - ] - - operations = [ - migrations.CreateModel( - name='LogEntry', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('object_id', models.PositiveIntegerField()), - ('datetime', models.DateTimeField(auto_now_add=True)), - ('action_type', models.CharField(max_length=255)), - ('data', models.TextField(default='{}')), - ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), - ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index dd203ea60d..1d19acbd9c 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -2,8 +2,7 @@ from .auth import User from .base import CachedFile, cachedfile_name from .event import Event, EventLock, EventPermission, EventSetting from .items import ( - Item, ItemCategory, ItemVariation, Property, PropertyValue, Question, - Quota, VariationsField, itempicture_upload_to, + Item, ItemCategory, ItemVariation, Question, Quota, itempicture_upload_to, ) from .log import LogEntry from .orders import ( @@ -14,8 +13,7 @@ from .organizer import Organizer, OrganizerPermission, OrganizerSetting __all__ = [ 'User', 'CachedFile', 'Organizer', 'OrganizerPermission', 'Event', 'EventPermission', - 'ItemCategory', 'Item', 'Property', 'PropertyValue', 'ItemVariation', 'VariationsField', 'Question', - 'Quota', 'Order', 'CachedTicket', 'QuestionAnswer', 'ObjectWithAnswers', 'OrderPosition', - 'CartPosition', 'EventSetting', 'OrganizerSetting', 'EventLock', 'cachedfile_name', 'itempicture_upload_to', - 'generate_secret', 'LogEntry' + 'ItemCategory', 'Item', 'ItemVariation', 'Question', 'Quota', 'Order', 'CachedTicket', 'QuestionAnswer', + 'ObjectWithAnswers', 'OrderPosition', 'CartPosition', 'EventSetting', 'OrganizerSetting', 'EventLock', + 'cachedfile_name', 'itempicture_upload_to', 'generate_secret', 'LogEntry' ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 6f6975f8e6..ed6928f644 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -1,18 +1,16 @@ import sys from datetime import datetime -from itertools import product from django.db import models from django.db.models import Q, Case, Count, Sum, When from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ -from typing import List, Tuple +from typing import Tuple from pretix.base.i18n import I18nCharField, I18nTextField from pretix.base.models.base import LoggedModel -from ..types import VariationDict from .event import Event @@ -196,117 +194,6 @@ class Item(LoggedModel): return False return True - def get_all_variations(self, use_cache: bool=False) -> List[VariationDict]: - """ - This method returns a list containing all variations of this - item. The list contains one VariationDict per variation, where - the Proprty IDs are keys and the PropertyValue objects are - values. If an ItemVariation object exists, it is available in - the dictionary via the special key 'variation'. - - VariationDicts differ from dicts only by specifying some extra - methods. - - :param use_cache: If this parameter is set to ``True``, a second call to this method - on the same model instance won't query the database again but return - the previous result again. - :type use_cache: bool - """ - if use_cache and hasattr(self, '_get_all_variations_cache'): - return self._get_all_variations_cache - - all_variations = self.variations.all().prefetch_related("values") - all_properties = self.properties.all().prefetch_related("values") - variations_cache = {} - for var in all_variations: - key = [] - for v in var.values.all(): - key.append((v.prop_id, v.id)) - key = tuple(sorted(key)) - variations_cache[key] = var - - result = [] - for comb in product(*[prop.values.all() for prop in all_properties]): - if len(comb) == 0: - result.append(VariationDict()) - continue - key = [] - var = VariationDict() - for v in comb: - key.append((v.prop.id, v.id)) - var[v.prop.id] = v - key = tuple(sorted(key)) - if key in variations_cache: - var['variation'] = variations_cache[key] - result.append(var) - - self._get_all_variations_cache = result - return result - - def _get_all_generated_variations(self): - propids = set([p.id for p in self.properties.all()]) - if len(propids) == 0: - variations = [VariationDict()] - else: - all_variations = list( - self.variations.annotate( - qc=Count('quotas') - ).filter(qc__gt=0).prefetch_related( - "values", "values__prop", "quotas__event" - ) - ) - variations = [] - for var in all_variations: - values = list(var.values.all()) - # Make sure we don't expose stale ItemVariation objects which are - # still around altough they have an old set of properties - if set([v.prop.id for v in values]) != propids: - continue - vardict = VariationDict() - for v in values: - vardict[v.prop.id] = v - vardict['variation'] = var - variations.append(vardict) - return variations - - def get_all_available_variations(self, use_cache: bool=False): - """ - This method returns a list of all variations which are theoretically - possible for sale. It DOES only return variations which DO have an ItemVariation - object, as all variations without one CAN NOT be part of a Quota and therefore can - never be available for sale. The only exception is the empty variation - for items without properties, which never has an ItemVariation object. - - This DOES NOT take into account quotas itself. Use ``is_available`` on the - ItemVariation objects (or the Item it self, if it does not have variations) to - determine availability by the terms of quotas. - - It is recommended to call:: - - .prefetch_related('properties', 'variations__values__prop') - - when retrieving Item objects you are going to use this method on. - """ - if use_cache and hasattr(self, '_get_all_available_variations_cache'): - return self._get_all_available_variations_cache - - variations = self._get_all_generated_variations() - - for i, var in enumerate(variations): - var['available'] = var['variation'].active if 'variation' in var else True - if 'variation' in var: - if var['variation'].default_price is not None: - var['price'] = var['variation'].default_price - else: - var['price'] = self.default_price - else: - var['price'] = self.default_price - - variations = [var for var in variations if var['available']] - - self._get_all_available_variations_cache = variations - return variations - def check_quotas(self): """ This method is used to determine whether this Item is currently available @@ -314,130 +201,24 @@ class Item(LoggedModel): :returns: any of the return codes of :py:meth:`Quota.availability()`. - :raises ValueError: if you call this on an item which has properties associated with it. + :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. """ - if self.properties.count() > 0: # NOQA - raise ValueError('Do not call this directly on items which have properties ' + if self.variations.count() > 0: # NOQA + raise ValueError('Do not call this directly on items which have variations ' 'but call this on their ItemVariation objects') return min([q.availability() for q in self.quotas.all()], key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize)) -class Property(models.Model): - """ - A property is a modifier which can be applied to an Item. For example - 'Size' would be a property associated with the item 'T-Shirt'. - - :param event: The event this belongs to - :type event: Event - :param name: The name of this property. - :type name: str - """ - - event = models.ForeignKey( - Event, - related_name="properties" - ) - item = models.ForeignKey( - Item, related_name='properties', null=True, blank=True - ) - name = I18nCharField( - max_length=250, - verbose_name=_("Property name") - ) - - class Meta: - verbose_name = _("Product property") - verbose_name_plural = _("Product properties") - - def __str__(self): - return str(self.name) - - def delete(self, *args, **kwargs): - super().delete(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if self.event: - self.event.get_cache().clear() - - -class PropertyValue(models.Model): - """ - A value of a property. If the property would be 'T-Shirt size', - this could be 'M' or 'L'. - - :param prop: The property this value is a valid option for. - :type prop: Property - :param value: The value, as a human-readable string - :type value: str - :param position: An integer, used for sorting - :type position: int - """ - - prop = models.ForeignKey( - Property, - on_delete=models.CASCADE, - related_name="values" - ) - value = I18nCharField( - max_length=250, - verbose_name=_("Value"), - ) - position = models.IntegerField( - default=0 - ) - - class Meta: - verbose_name = _("Property value") - verbose_name_plural = _("Property values") - ordering = ("position", "id") - - def __str__(self): - return "%s: %s" % (self.prop.name, self.value) - - def delete(self, *args, **kwargs): - super().delete(*args, **kwargs) - if self.prop: - self.prop.event.get_cache().clear() - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - if self.prop: - self.prop.event.get_cache().clear() - - @property - def sortkey(self) -> Tuple[int, datetime]: - return self.position, self.id - - def __lt__(self, other) -> bool: - return self.sortkey < other.sortkey - - class ItemVariation(models.Model): """ - A variation is an item combined with values for all properties - associated with the item. For example, if your item is 'T-Shirt' - and your properties are 'Size' and 'Color', then an example for an - variation would be 'T-Shirt XL read'. - - Attention: _ALL_ combinations of PropertyValues _ALWAYS_ exist, - even if there is no ItemVariation object for them! ItemVariation objects - do NOT prove existance, they are only available to make it possible - to override default values (like the price) for certain combinations - of property values. However, appropriate ItemVariation objects will be - created as soon as you add your variations to a quota. - - They also allow to explicitly EXCLUDE certain combinations of property - values by creating an ItemVariation object for them with active set to - False. + A variation of a product. For example, if your item is 'T-Shirt' + then an example for a variation would be 'T-Shirt XL'. :param item: The item this variation belongs to :type item: Item - :param values: A set of ``PropertyValue`` objects defining this variation + :param value: A string defining this variation :param active: Whether this value is to be sold. :type active: bool :param default_price: This variation's default price @@ -447,14 +228,18 @@ class ItemVariation(models.Model): Item, related_name='variations' ) - values = models.ManyToManyField( - PropertyValue, - related_name='variations', + value = I18nCharField( + max_length=255, + verbose_name=_('Description') ) active = models.BooleanField( default=True, verbose_name=_("Active"), ) + position = models.PositiveIntegerField( + default=0, + verbose_name=_("Position") + ) default_price = models.DecimalField( decimal_places=2, max_digits=7, null=True, blank=True, @@ -464,9 +249,10 @@ class ItemVariation(models.Model): class Meta: verbose_name = _("Product variation") verbose_name_plural = _("Product variations") + ordering = ("position", "id") def __str__(self): - return str(self.to_variation_dict()) + return str(self.value) def delete(self, *args, **kwargs): super().delete(*args, **kwargs) @@ -488,56 +274,10 @@ class ItemVariation(models.Model): return min([q.availability() for q in self.quotas.all()], key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize)) - def to_variation_dict(self) -> VariationDict: - """ - :return: a :py:class:`VariationDict` representing this variation. - """ - vd = VariationDict() - for v in self.values.all(): - vd[v.prop.id] = v - vd['variation'] = self - return vd - - def add_values_from_string(self, pk): - """ - Add values to this ItemVariation using a serialized string of the form - ``property-id:value-id,ṗroperty-id:value-id`` - """ - for pair in pk.split(","): - prop, value = pair.split(":") - self.values.add( - PropertyValue.objects.get( - id=value, - prop_id=prop - ) - ) - - -class VariationsField(models.ManyToManyField): - """ - This is a ManyToManyField using the pretixcontrol.views.forms.VariationsField - form field by default. - """ - - def formfield(self, **kwargs): - from pretix.control.forms import VariationsField as FVariationsField - from django.db.models.fields.related import RelatedField - - defaults = { - 'form_class': FVariationsField, - # We don't need a queryset - 'queryset': ItemVariation.objects.none(), - } - defaults.update(kwargs) - # If initial is passed in, it's a list of related objects, but the - # MultipleChoiceField takes a list of IDs. - if defaults.get('initial') is not None: - initial = defaults['initial'] - if callable(initial): - initial = initial() - defaults['initial'] = [i.id for i in initial] - # Skip ManyToManyField in dependency chain - return super(RelatedField, self).formfield(**defaults) + def __lt__(self, other): + if self.position == other.position: + return self.id < other.id + return self.position < other.position class Question(LoggedModel): @@ -686,7 +426,7 @@ class Quota(LoggedModel): related_name="quotas", blank=True ) - variations = VariationsField( + variations = models.ManyToManyField( ItemVariation, related_name="quotas", blank=True, diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 4908597ea1..6a2a8ef915 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -77,7 +77,7 @@ def _add_new_items(event: Event, items: List[Tuple[int, Optional[int], int]], variations_query = ItemVariation.objects.filter( item__event=event, id__in=[i[1] for i in items if i[1] is not None] - ).select_related("item", "item__event").prefetch_related("quotas", "values", "values__prop") + ).select_related("item", "item__event").prefetch_related("quotas") variations_cache = {v.id: v for v in variations_query} for i in items: diff --git a/src/pretix/base/services/stats.py b/src/pretix/base/services/stats.py index b042481e8a..f9152020a9 100644 --- a/src/pretix/base/services/stats.py +++ b/src/pretix/base/services/stats.py @@ -31,8 +31,6 @@ def tuplesum(tuples: Iterable[Tuple]) -> Tuple: def order_overview(event: Event) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]: items = event.items.all().select_related( 'category', # for re-grouping - ).prefetch_related( - 'properties', # for .get_all_available_variations() ).order_by('category__position', 'category_id', 'name') num_total = { @@ -73,22 +71,27 @@ def order_overview(event: Event) -> Tuple[List[Tuple[ItemCategory, List[Item]]], } for item in items: - item.all_variations = sorted(item.get_all_variations(), - key=lambda vd: vd.ordered_values()) - for var in item.all_variations: - variid = var['variation'].id if 'variation' in var else None - var.num_total = num_total.get((item.id, variid), (0, 0)) - var.num_pending = num_pending.get((item.id, variid), (0, 0)) - var.num_cancelled = num_cancelled.get((item.id, variid), (0, 0)) - var.num_refunded = num_refunded.get((item.id, variid), (0, 0)) - var.num_paid = num_paid.get((item.id, variid), (0, 0)) - item.has_variations = (len(item.all_variations) != 1 - or not item.all_variations[0].empty()) - item.num_total = tuplesum(var.num_total for var in item.all_variations) - item.num_pending = tuplesum(var.num_pending for var in item.all_variations) - item.num_cancelled = tuplesum(var.num_cancelled for var in item.all_variations) - item.num_refunded = tuplesum(var.num_refunded for var in item.all_variations) - item.num_paid = tuplesum(var.num_paid for var in item.all_variations) + item.all_variations = list(item.variations.all()) + item.has_variations = (len(item.all_variations) > 0) + if item.has_variations: + for var in item.all_variations: + variid = var.id + var.num_total = num_total.get((item.id, variid), (0, 0)) + var.num_pending = num_pending.get((item.id, variid), (0, 0)) + var.num_cancelled = num_cancelled.get((item.id, variid), (0, 0)) + var.num_refunded = num_refunded.get((item.id, variid), (0, 0)) + var.num_paid = num_paid.get((item.id, variid), (0, 0)) + item.num_total = tuplesum(var.num_total for var in item.all_variations) + item.num_pending = tuplesum(var.num_pending for var in item.all_variations) + item.num_cancelled = tuplesum(var.num_cancelled for var in item.all_variations) + item.num_refunded = tuplesum(var.num_refunded for var in item.all_variations) + item.num_paid = tuplesum(var.num_paid for var in item.all_variations) + else: + item.num_total = num_total.get((item.id, None), (0, 0)) + item.num_pending = num_pending.get((item.id, None), (0, 0)) + item.num_cancelled = num_cancelled.get((item.id, None), (0, 0)) + item.num_refunded = num_refunded.get((item.id, None), (0, 0)) + item.num_paid = num_paid.get((item.id, None), (0, 0)) nonecat = ItemCategory(name=_('Uncategorized')) # Regroup those by category @@ -106,6 +109,7 @@ def order_overview(event: Event) -> Tuple[List[Tuple[ItemCategory, List[Item]]], for c in items_by_category: c[0].num_total = tuplesum(item.num_total for item in c[1]) + print(c[1], c[0].num_total, [item.num_total for item in c[1]]) c[0].num_pending = tuplesum(item.num_pending for item in c[1]) c[0].num_cancelled = tuplesum(item.num_cancelled for item in c[1]) c[0].num_refunded = tuplesum(item.num_refunded for item in c[1]) diff --git a/src/pretix/base/types.py b/src/pretix/base/types.py deleted file mode 100644 index 0913b88c3a..0000000000 --- a/src/pretix/base/types.py +++ /dev/null @@ -1,96 +0,0 @@ -from typing import Iterable, List, Tuple - - -class VariationDict(dict): - """ - A VariationDict object behaves exactle the same as the Python built-in - ``dict`` does, but adds some special methods. It is used for the dicts - returned by ``Item.get_all_variations()`` to avoid duplicate code in the - code calling this method. - """ - IGNORE_KEYS = ('variation', 'key', 'available', 'price') - - def relevant_items(self) -> Iterable[Tuple]: - """ - Iterate over all items with numeric keys. - - This is in use because the variation dictionaries use property ids - as key and have some special keys like 'variation'. - """ - return (i for i in self.items() if i[0] not in self.IGNORE_KEYS) - - def relevant_values(self) -> Iterable["PropertyValue"]: - """ - Iterate over all values with numeric keys. - - This is in use because the variation dictionaries use property ids - as key and have some special keys like 'variation'. - """ - return (i[1] for i in self.items() if i[0] not in self.IGNORE_KEYS) - - def identify(self) -> str: - """ - Build a simple and unique identifier for this dict. This can be any string - used to compare one VariationDict to others. - - In the current implementation, it is a string containing a list of - the PropertyValue id's, sorted by the Property id's and is therefore - unique among one item. - """ - def order_key(i): - return i[0] - return ",".join(( - str(v[1].pk) for v in sorted(self.relevant_items(), key=order_key) - )) - - def key(self) -> str: - """ - Build an identifier for this dict which exactly specifies the combination - for this variation without any doubt. This can be used to "talk" about a - variation in network communication. - - In the current implementation, it is a string containing a list of - the propertyid:valueid tuples without any specific order and is therefore - not useful to compare two VariationDicts for equality. - """ - return ",".join(( - str(v[0]) + ":" + str(v[1].pk) for v in self.relevant_items() - )) - - def __eq__(self, other): - if type(other) is type(self): - return self.identify() == other.identify() - else: - return super().__eq__(other) - - def empty(self) -> bool: - """ - Returns true, if this VariationDict does not contain any "real" data like - references to PropertyValues, but only "metadata". - """ - return not next(self.relevant_items(), False) - - def ordered_values(self) -> List["ItemVariation"]: - """ - Returns a list of values ordered by their keys - """ - return [ - i[1] for i - in sorted( - [it for it in self.relevant_items()], - key=lambda i: i[0] - ) - ] - - def __str__(self) -> str: - return " – ".join([str(v.value) for v in self.ordered_values()]) - - def copy(self) -> "VariationDict": - """ - Return a one-level deep copy of this object (create a new - VariationDict but make a shallow copy of the dict inside it). - """ - new = VariationDict() - for k, v in self.items(): - new[k] = v - return new diff --git a/src/pretix/control/forms/__init__.py b/src/pretix/control/forms/__init__.py index b4e63396e2..6855cb8d73 100644 --- a/src/pretix/control/forms/__init__.py +++ b/src/pretix/control/forms/__init__.py @@ -110,277 +110,6 @@ def selector(values, prop): ] -def sort(v, prop): - # Given a list of variations, this will sort them by their position - # on the x-axis - return v[prop.id].sortkey - - -class VariationsFieldRenderer(forms.widgets.CheckboxFieldRenderer): - """ - This is the default renderer for a VariationsField. Based on the choice input class - this renders a list or a matrix of checkboxes/radio buttons/... - """ - - def __init__(self, name, value, attrs, choices): - self.name = name - self.value = value - self.attrs = attrs - self.choices = choices - - def render(self): - """ - Outputs a grid for this set of choice fields. - """ - if len(self.choices) == 0: - raise ValueError("Can't handle empty lists") - - variations = [] - for key, value in self.choices: - value['key'] = key - variations.append(value) - - properties = [v.prop for v in variations[0].relevant_values()] - dimension = len(properties) - - id_ = self.attrs.get('id', None) - start_tag = format_html('
', id_) if id_ else '
' - output = [start_tag] - - # TODO: This is very duplicate to pretixcontrol.views.item.ItemVariations.get_forms() - # Find a common abstraction to avoid the repetition. - if dimension == 0: - output.append(format_html('{0}', _("not applicable"))) - elif dimension == 1: - output = self.render_1d(output, variations, properties) - else: - output = self.render_nd(output, variations, properties) - output.append( - ('
{0} · ' - '{1}
').format( - _("Select all"), - _("Deselect all") - ) - ) - return mark_safe('\n'.join(output)) - - def render_1d(self, output, variations, properties): - output.append('') - return output - - def render_nd(self, output, variations, properties): - # prop1 is the property on all the grid's y-axes - prop1 = properties[0] - prop1v = list(prop1.values.all()) - # prop2 is the property on all the grid's x-axes - prop2 = properties[1] - prop2v = list(prop2.values.all()) - - # We now iterate over the cartesian product of all the other - # properties which are NOT on the axes of the grid because we - # create one grid for any combination of them. - for gridrow in product(*[prop.values.all() for prop in properties[2:]]): - if len(gridrow) > 0: - output.append('') - output.append(", ".join([str(value.value) for value in gridrow])) - output.append('') - output.append('') - for val2 in prop2v: - output.append(format_html('', val2.value)) - output.append('') - for val1 in prop1v: - output.append(format_html('', val1.value)) - # We are now inside one of the rows of the grid and have to - # select the variations to display in this row. In order to - # achieve this, we use the 'selector' lambda defined above. - # It gives us a normalized, comparable version of a set of - # PropertyValue objects. In this case, we compute the - # selector of our row as the selector of the sum of the - # values defining our grind and the value defining our row. - selection = selector(gridrow + (val1,), prop2) - # We now iterate over all variations who generate the same - # selector as 'selection'. - filtered = [v for v in variations if selector(v.relevant_values(), prop2) == selection] - for variation in sorted(filtered, key=partial(sort, prop=prop2)): - final_attrs = dict( - self.attrs.copy(), type=self.choice_input_class.input_type, - name=self.name, value=variation['key'] - ) - if variation['key'] in self.value: - final_attrs['checked'] = 'checked' - output.append(format_html('', flatatt(final_attrs))) - output.append('') - output.append('
{0}
{0}
') - return output - - -class VariationsCheckboxRenderer(VariationsFieldRenderer): - """ - This is the same as VariationsFieldRenderer but with the choice input class - forced to checkboxes - """ - choice_input_class = forms.widgets.CheckboxChoiceInput - - -class VariationsSelectMultiple(forms.CheckboxSelectMultiple): - """ - This is the default widget for a VariationsField - """ - renderer = VariationsCheckboxRenderer - _empty_value = [] - - -class VariationsField(forms.ModelMultipleChoiceField): - """ - This form field is intended to be used to let the user select a - variation of a certain item, for example in a restriction plugin. - - As this field expects the non-standard keyword parameter ``item`` - at initialization time, this is field is normally named ``variations`` - and lives inside a ``pretixcontrol.views.forms.RestrictionForm``, which - does some magic to provide this parameter. - """ - - def __init__(self, *args, item=None, **kwargs): - self.item = item - if 'widget' not in args or kwargs['widget'] is None: - kwargs['widget'] = VariationsSelectMultiple - super().__init__(*args, **kwargs) - - def set_item(self, item: Item): - assert isinstance(item, Item) - self.item = item - self._set_choices(self._get_choices()) - - def _get_choices(self) -> "list[(str, VariationDict)]": - """ - We can't use a normal QuerySet as there theoretically might be - two types of variations: Some who already have a ItemVariation - object associated with them and some who don't. We therefore use - the item's ``get_all_variations`` method. In the first case, we - use the ItemVariation objects primary key as our choice, key, - in the latter case we use a string constructed from the values - (see VariationDict.key() for implementation details). - """ - if self.item is None: - return () - variations = self.item.get_all_variations(use_cache=True) - return ( - ( - v['variation'].id if 'variation' in v else v.key(), - v - ) for v in variations - ) - - def clean(self, value: "list[int]"): - """ - At cleaning time, we have to clean up the mess we produced with our - _get_choices implementation. In the case of ItemVariation object ids - we don't to anything to them, but if one of the selected items is a - list of PropertyValue objects (see _get_choices), we need to create - a new ItemVariation object for this combination and then add this to - our list of selected items. - """ - if self.item is None: - raise ValueError( - "VariationsField object was not properly initialized. Please" - "use a pretixcontrol.views.forms.RestrictionForm form instead of" - "a plain Django ModelForm" - ) - - # Standard validation foo - if self.required and not value: - raise ValidationError(self.error_messages['required'], code='required') - elif not self.required and not value: - return [] - if not isinstance(value, (list, tuple)): - raise ValidationError(self.error_messages['list'], code='list') - - cleaned_value = self._clean_value(value) - - qs = self.item.variations.filter(id__in=cleaned_value) - - # Re-check for consistency - pks = set(force_text(getattr(o, "id")) for o in qs) - for val in cleaned_value: - if force_text(val) not in pks: - raise ValidationError( - self.error_messages['invalid_choice'], - code='invalid_choice', - params={'value': val}, - ) - - # Since this overrides the inherited ModelChoiceField.clean - # we run custom validators here - self.run_validators(cleaned_value) - return qs - - def _clean_value(self, value): - # Build up a cache of variations having an ItemVariation object - # For implementation details, see ItemVariation.get_all_variations() - # which uses a very similar method - all_variations = self.item.variations.all().prefetch_related("values") - variations_cache = { - var.to_variation_dict().identify(): var.id for var in all_variations - } - - cleaned_value = [] - - # Wrap this in a transaction to prevent strange database state if we - # get a ValidationError half-way through - with transaction.atomic(): - for pk in value: - if ":" in pk: - # A combination of PropertyValues was given - - # Hash the combination in the same way as in our cache above - key = ",".join([pair.split(":")[1] for pair in sorted(pk.split(","))]) - - if key in variations_cache: - # An ItemVariation object already exists for this variation, - # so use this. (This might occur if the variation object was - # created _after_ the user loaded the form but _before_ he - # submitted it.) - cleaned_value.append(str(variations_cache[key])) - continue - - # No ItemVariation present, create one! - var = ItemVariation() - var.item_id = self.item.id - var.save() - # Add the values to the ItemVariation object - try: - var.add_values_from_string(pk) - except: - raise ValidationError( - self.error_messages['invalid_pk_value'], - code='invalid_pk_value', - params={'pk': value}, - ) - variations_cache[key] = var.id - cleaned_value.append(str(var.id)) - else: - # An ItemVariation id was given - cleaned_value.append(pk) - return cleaned_value - - choices = property(_get_choices, forms.ChoiceField._set_choices) - - class ExtFileField(forms.FileField): def __init__(self, *args, **kwargs): ext_whitelist = kwargs.pop("ext_whitelist") @@ -396,108 +125,3 @@ class ExtFileField(forms.FileField): if ext not in self.ext_whitelist: raise forms.ValidationError(_("Filetype not allowed!")) return data - - -class BaseNestedFormset(I18nFormSet): - - def add_fields(self, form, index): - # allow the super class to create the fields as usual - super().add_fields(form, index) - - form.nested = [] - for f in self.nested_formset_class: - inner_formset = f( - instance=form.instance, - data=form.data if form.is_bound else None, - prefix='%s-%s' % (form.prefix, f.get_default_prefix()), - queryset=form.instance.values.all(), - event=self.event - ) - form.nested.append(inner_formset) - - def is_valid(self): - result = super(BaseNestedFormset, self).is_valid() - - if self.is_bound: - # look at any nested formsets, as well - for form in self.forms: - if not self._should_delete_form(form): - for n in form.nested: - result = result and n.is_valid() - - return result - - def save(self, commit=True): - result = super(BaseNestedFormset, self).save(commit=commit) - - for form in self.forms: - if not self._should_delete_form(form): - for n in form.nested: - n.save(commit=commit) - - return result - - -def nestedformset_factory(model, nested_formset, form=ModelForm, - formset=BaseNestedFormset, fk_name=None, fields=None, - exclude=None, extra=3, can_order=False, - can_delete=True, max_num=None, - formfield_callback=None, widgets=None, - validate_max=False, localized_fields=None, - labels=None, help_texts=None, error_messages=None): - kwargs = { - 'form': form, - 'formfield_callback': formfield_callback, - 'formset': formset, - 'extra': extra, - 'can_delete': can_delete, - 'can_order': can_order, - 'fields': fields, - 'exclude': exclude, - 'max_num': max_num, - 'widgets': widgets, - 'validate_max': validate_max, - 'localized_fields': localized_fields, - 'labels': labels, - 'help_texts': help_texts, - 'error_messages': error_messages, - } - - nfs_class = modelformset_factory(model, **kwargs) - nfs_class.nested_formset_class = [] - for f in nested_formset: - nfs_class.nested_formset_class.append(f) - return nfs_class - - -class NestedInnerI18nInlineFormSet(I18nFormSet): - """A formset for child objects related to a parent.""" - - def __init__(self, data=None, files=None, instance=None, - save_as_new=False, prefix=None, queryset=None, **kwargs): - if instance is None: - self.instance = self.fk.rel.to() - else: - self.instance = instance - self.save_as_new = save_as_new - if queryset is None: - if self.instance is not None: - queryset = getattr(self.instance, self.fk.related_query_name()).all() - else: - queryset = self.model._default_manager - if self.instance.pk is not None: - qs = queryset - else: - qs = self.model._default_manager.none() - super().__init__(data, files, prefix=prefix, queryset=qs, **kwargs) - - @property - def empty_form(self): - form = self.form( - auto_id=self.auto_id, - prefix=self.add_prefix('__inner_prefix__'), - empty_permitted=True, - event=self.event - ) - self.add_fields(form, None) - return form diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 0b40c810c4..8c898687bb 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -1,15 +1,13 @@ import copy from django import forms -from django.forms import BooleanField +from django.forms import BooleanField, ModelMultipleChoiceField from django.utils.translation import ugettext_lazy as _ from pretix.base.forms import I18nModelForm from pretix.base.models import ( - Item, ItemCategory, ItemVariation, Property, PropertyValue, Question, - Quota, + Item, ItemCategory, ItemVariation, Question, Quota, ) -from pretix.control.forms import TolerantFormsetModelForm, VariationsField class CategoryForm(I18nModelForm): @@ -21,24 +19,6 @@ class CategoryForm(I18nModelForm): ] -class PropertyForm(I18nModelForm): - class Meta: - model = Property - localized_fields = '__all__' - fields = [ - 'name', - ] - - -class PropertyValueForm(TolerantFormsetModelForm): - class Meta: - model = PropertyValue - localized_fields = '__all__' - fields = [ - 'value', - ] - - class QuestionForm(I18nModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -75,13 +55,14 @@ class QuotaForm(I18nModelForm): active_variations = set() for item in items: - if len(item.properties.all()) > 0: - self.fields['item_%s' % item.id] = VariationsField( - item, label=_("Activate for"), + if len(item.variations.all()) > 0: + self.fields['item_%s' % item.id] = ModelMultipleChoiceField( + label=_("Activate for"), required=False, - initial=active_variations + initial=active_variations, + queryset=item.variations.all(), + widget=forms.CheckboxSelectMultiple ) - self.fields['item_%s' % item.id].set_item(item) else: self.fields['item_%s' % item.id] = BooleanField( label=_("Activate"), @@ -125,6 +106,7 @@ class ItemVariationForm(I18nModelForm): model = ItemVariation localized_fields = '__all__' fields = [ + 'value', 'active', 'default_price', ] diff --git a/src/pretix/control/templates/pretixcontrol/item/base.html b/src/pretix/control/templates/pretixcontrol/item/base.html index f19785ed1f..7e41d29cb1 100644 --- a/src/pretix/control/templates/pretixcontrol/item/base.html +++ b/src/pretix/control/templates/pretixcontrol/item/base.html @@ -6,7 +6,6 @@

{% trans "Modify product:" %} {{ object.name }}

{% else %} diff --git a/src/pretix/control/templates/pretixcontrol/item/properties.html b/src/pretix/control/templates/pretixcontrol/item/properties.html deleted file mode 100644 index 34e3af91cc..0000000000 --- a/src/pretix/control/templates/pretixcontrol/item/properties.html +++ /dev/null @@ -1,149 +0,0 @@ -{% extends "pretixcontrol/item/base.html" %} -{% load i18n %} -{% load bootstrap3 %} -{% load formset_tags %} -{% block inside %} -
- {% csrf_token %} - {% if state %} -
{{ state.message|safe }}
- {% endif %} -
- {{ formset.management_form }} - {% bootstrap_formset_errors formset %} -
- {% for form in formset %} -
-
- {{ form.id }} - {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} -
-
-

- {% bootstrap_field form.name layout='inline' form_group_class="" %} -

-
- -
- {% bootstrap_form_errors form %} -
-
- {{ form.nested.0.management_form }} - {% for f in form.nested.0 %} -
- {{ f.id }} -
- {% bootstrap_field f.value form_group_class="" layout="inline" %} -
-
- {% bootstrap_field f.ORDER form_group_class="" layout="inline" %} - {% bootstrap_field f.DELETE form_group_class="" layout="inline" %} -
-
- - - -
-
- {% endfor %} -
- -

- -

-
-
-
- {% endfor %} -
- ' }} - -

- -

-
-
- - {% endescapescript %} - -

- -

- -
- -
- -{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/item/variations.html b/src/pretix/control/templates/pretixcontrol/item/variations.html new file mode 100644 index 0000000000..890a83ee3b --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/item/variations.html @@ -0,0 +1,87 @@ +{% extends "pretixcontrol/item/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load formset_tags %} +{% block inside %} +
+ {% csrf_token %} +
+ {{ formset.management_form }} + {% bootstrap_formset_errors formset %} +
+ {% for form in formset %} +
+
+ {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} + {% bootstrap_field form.ORDER form_group_class="" layout="inline" %} +
+
+

+
+
+ {% bootstrap_field form.value layout='inline' form_group_class="" %} +
+
+ + + +
+
+

+
+
+ {% bootstrap_form_errors form %} + {% bootstrap_field form.active layout='horizontal' %} + {% bootstrap_field form.default_price layout='horizontal' %} +
+
+ {% endfor %} +
+ +

+ +

+
+
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/item/variations_0d.html b/src/pretix/control/templates/pretixcontrol/item/variations_0d.html deleted file mode 100644 index fba908837d..0000000000 --- a/src/pretix/control/templates/pretixcontrol/item/variations_0d.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends "pretixcontrol/item/base.html" %} -{% load i18n %} -{% load bootstrap3 %} -{% block inside %} -

{% trans "You have to define and select propreties to be able to configure variations." %}

-{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/item/variations_1d.html b/src/pretix/control/templates/pretixcontrol/item/variations_1d.html deleted file mode 100644 index 534ec1189e..0000000000 --- a/src/pretix/control/templates/pretixcontrol/item/variations_1d.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "pretixcontrol/item/base.html" %} -{% load i18n %} -{% load bootstrap3 %} -{% block inside %} -
- {% csrf_token %} -
- - - - - - - - - - {% for form in forms %} - {% bootstrap_form_errors form type='all' layout='inline' %} - - - - - - {% endfor %} - -
{{ properties.0 }}{% trans "Active" %}{% trans "Price" %}
{{ form.values.0 }}{% bootstrap_field form.active layout='inline' %}{% bootstrap_field form.default_price layout='inline' %} {{ form.default_price.errors }}
-
-
- -
-
- -{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/item/variations_nd.html b/src/pretix/control/templates/pretixcontrol/item/variations_nd.html deleted file mode 100644 index 094312c8e3..0000000000 --- a/src/pretix/control/templates/pretixcontrol/item/variations_nd.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends "pretixcontrol/item/base.html" %} -{% load i18n %} -{% load bootstrap3 %} -{% block inside %} -
- {% csrf_token %} - {% for major in forms %} - {% if major.row %} -

{{ major.row }}

- {% endif %} -
- - - - - {% for val in properties.1.values.all %} - - {% endfor %} - - - - {% for sub in major.forms %} - - - {% for form in sub.forms %} - - {% endfor %} - - {% endfor %} - -
{{ val.value }}
{{ sub.row.value }} -
-
- {% bootstrap_field form.active layout='inline' %} -
-
- {% bootstrap_field form.default_price layout='inline' %} -
-
- {{ form.default_price.errors }} -
-
- {% endfor %} -
- -
-
- -{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index ce31610447..ca7dc1cb2f 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -30,8 +30,6 @@ urlpatterns = [ url(r'^items/(?P\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'), url(r'^items/(?P\d+)/variations$', item.ItemVariations.as_view(), name='event.item.variations'), - url(r'^items/(?P\d+)/properties$', item.ItemProperties.as_view(), - name='event.item.properties'), url(r'^items/(?P\d+)/up$', item.item_move_up, name='event.items.up'), url(r'^items/(?P\d+)/down$', item.item_move_down, name='event.items.down'), url(r'^items/(?P\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'), diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index b2eb09c144..6efd893d79 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -1,9 +1,7 @@ -from itertools import product - from django.contrib import messages from django.core.urlresolvers import resolve, reverse from django.db import transaction -from django.forms.models import inlineformset_factory +from django.forms.models import ModelMultipleChoiceField, inlineformset_factory from django.http import Http404, HttpResponseRedirect from django.shortcuts import redirect from django.utils.functional import cached_property @@ -13,16 +11,12 @@ from django.views.generic.base import TemplateView from django.views.generic.detail import SingleObjectMixin from django.views.generic.edit import DeleteView +from pretix.base.i18n import I18nFormSet from pretix.base.models import ( - Item, ItemCategory, ItemVariation, Property, PropertyValue, Question, - Quota, -) -from pretix.control.forms import ( - NestedInnerI18nInlineFormSet, VariationsField, nestedformset_factory, + Item, ItemCategory, ItemVariation, Question, Quota, ) from pretix.control.forms.item import ( - CategoryForm, ItemFormGeneral, ItemVariationForm, PropertyForm, - PropertyValueForm, QuestionForm, QuotaForm, + CategoryForm, ItemFormGeneral, ItemVariationForm, QuestionForm, QuotaForm, ) from pretix.control.permissions import ( EventPermissionRequiredMixin, event_permission_required, @@ -350,7 +344,7 @@ class QuotaList(ListView): class QuotaEditorMixin: @cached_property def items(self) -> "List[Item]": - return list(self.request.event.items.all().prefetch_related("properties", "variations")) + return list(self.request.event.items.all().prefetch_related("variations")) def get_form(self, form_class=QuotaForm): if not hasattr(self, '_form'): @@ -376,7 +370,7 @@ class QuotaEditorMixin: for item in self.items: field = form.fields['item_%s' % item.id] data = form.cleaned_data['item_%s' % item.id] - if isinstance(field, VariationsField): + if isinstance(field, ModelMultipleChoiceField): for v in data: selected_variations.append(v) if data and item not in items: @@ -548,264 +542,59 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie return super().form_valid(form) -class ItemProperties(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView): - permission = 'can_change_items' - template_name = 'pretixcontrol/item/properties.html' - - def get_success_url(self) -> str: - return reverse('control:event.item.properties', kwargs={ - 'organizer': self.request.event.organizer.slug, - 'event': self.request.event.slug, - 'item': self.get_object().id, - }) - - def get_inner_formset_class(self): - formsetclass = inlineformset_factory( - Property, PropertyValue, - form=PropertyValueForm, - formset=NestedInnerI18nInlineFormSet, - can_order=True, extra=0 - ) - return formsetclass - - def get_outer_formset(self): - formsetclass = nestedformset_factory( - Property, [self.get_inner_formset_class()], - form=PropertyForm, can_order=False, can_delete=True, extra=0 - ) - formset = formsetclass(self.request.POST if self.request.method == "POST" else None, - queryset=Property.objects.filter(item=self.object).prefetch_related('values'), - event=self.request.event) - return formset - - def get_context_data(self, **kwargs): - self.object = self.get_object() - ctx = super().get_context_data(**kwargs) - ctx['formset'] = self.get_outer_formset() - return ctx - - @transaction.atomic() - def form_valid(self, formset): - for f in formset: - f.instance.event = self.request.event - f.instance.item = self.get_object() - is_created = not f.instance.pk - f.instance.save() - if f.has_changed() and not is_created: - change_data = { - k: f.cleaned_data.get(k) for k in f.changed_data - } - change_data['id'] = f.instance.pk - f.instance.item.log_action( - 'pretix.event.item.property.changed', user=self.request.user, data=change_data - ) - elif is_created: - change_data = dict(f.cleaned_data) - change_data['id'] = f.instance.pk - f.instance.item.log_action( - 'pretix.event.item.property.added', user=self.request.user, data=change_data - ) - - for n in f.nested: - - for fn in n.deleted_forms: - f.instance.item.log_action( - 'pretix.event.item.property.value.deleted', user=self.request.user, data={ - 'id': fn.instance.pk - } - ) - fn.instance.delete() - fn.instance.pk = None - - for i, fn in enumerate(n.ordered_forms + [ef for ef in n.extra_forms if ef not in n.ordered_forms]): - fn.instance.position = i - fn.instance.prop = f.instance - fn.save() - if f.has_changed(): - change_data = {k: f.cleaned_data.get(k) for k in f.changed_data} - change_data['id'] = f.instance.pk - f.instance.item.log_action( - 'pretix.event.item.property.value.changed', user=self.request.user, data=change_data - ) - - for form in n.extra_forms: - if not form.has_changed(): - continue - if n.can_delete and n._should_delete_form(form): - continue - change_data = dict(f.cleaned_data) - n.save_new(form) - change_data['id'] = form.instance.pk - f.instance.item.log_action( - 'pretix.event.item.property.value.added', user=self.request.user, data=change_data - ) - messages.success(self.request, _('Your changes have been saved.')) - return redirect(self.get_success_url()) - - def post(self, request, *args, **kwargs): - self.object = self.get_object() - formset = self.get_outer_formset() - if formset.is_valid(): - return self.form_valid(formset) - else: - return self.get(request, *args, **kwargs) - - class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView): permission = 'can_change_items' + template_name = 'pretixcontrol/item/variations.html' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.item = None - def get_form(self, variation, data=None) -> ItemVariationForm: - """ - Return the dict for one given variation. Variations are expected to be - dictionaries in the format of Item.get_all_variations() - """ - # Values are all dictionary ite - values = [i[1] for i in sorted([it for it in variation.items() if it[0] != 'variation'])] - if 'variation' in variation: - form = ItemVariationForm( - data, - instance=variation['variation'], - prefix=",".join([str(i.id) for i in values]), - ) - else: - inst = ItemVariation(item=self.object) - inst.item_id = self.object.id - inst.creation = True - form = ItemVariationForm( - data, - instance=inst, - prefix=",".join([str(i.id) for i in values]), - ) - form.values = values - return form - - def get_forms(self) -> tuple: - """ - Returns one form per possible item variation. The forms are returned - twice: The first entry in the returned tuple contains a 1-, 2- or - 3-dimensional list, depending on the number of properties associated - with this item (this is being used for form display), the second - contains all forms in one single list (this is used for processing). - - The first, hierarchical list, is a list of dicts on all levels but the - last one, where the dict contains the two entries 'row' containing a - string describing this layer and 'forms' which contains the forms or - the next list of dicts. - """ - forms = [] - forms_flat = [] - variations = self.object.get_all_variations() - data = self.request.POST if self.request.method == 'POST' else None - - if self.dimension == 1: - # For one-dimensional structures we just have a list of forms - for variation in variations: - form = self.get_form(variation, data) - forms.append(form) - forms_flat = forms - - elif self.dimension >= 2: - # For 2 or more dimensional structures we display a list of grids - # of forms - - # prop1 is the property on all the grid's y-axes - prop1 = self.properties[0] - # prop2 is the property on all the grid's x-axes - prop2 = self.properties[1] - - def selector(values): - # Given an iterable of PropertyValue objects, this will return a - # list of their primary keys, ordered by the primary keys of the - # properties they belong to EXCEPT the value for the property prop2. - # We'll see later why we need this. - return [ - v.id for v in sorted(values, key=lambda v: v.prop.id) - if v.prop.id != prop2.id - ] - - def sort(v): - # Given a list of variations, this will sort them by their position - # on the x-axis - return v[prop2.id].sortkey - - # We now iterate over the cartesian product of all the other - # properties which are NOT on the axes of the grid because we - # create one grid for any combination of them. - for gridrow in product(*[prop.values.all() for prop in self.properties[2:]]): - grids = [] - for val1 in prop1.values.all(): - formrow = [] - # We are now inside one of the rows of the grid and have to - # select the variations to display in this row. In order to - # achieve this, we use the 'selector' lambda defined above. - # It gives us a normalized, comparable version of a set of - # PropertyValue objects. In this case, we compute the - # selector of our row as the selector of the sum of the - # values defining our grind and the value defining our row. - selection = selector(gridrow + (val1,)) - # We now iterate over all variations who generate the same - # selector as 'selection'. - filtered = [v for v in variations if selector(v.relevant_values()) == selection] - for variation in sorted(filtered, key=sort): - form = self.get_form(variation, data) - formrow.append(form) - forms_flat.append(form) - - grids.append({'row': val1, 'forms': formrow}) - - forms.append({'row': ", ".join([str(value.value) for value in gridrow]), 'forms': grids}) - - return forms, forms_flat - - def main(self, request, *args, **kwargs): - self.object = self.get_object() - self.properties = list(self.object.properties.all().prefetch_related("values")) - self.dimension = len(self.properties) - self.forms, self.forms_flat = self.get_forms() - - def get(self, request, *args, **kwargs): - self.main(request, *args, **kwargs) - context = self.get_context_data(object=self.object) - return self.render_to_response(context) + @cached_property + def formset(self): + formsetclass = inlineformset_factory( + Item, ItemVariation, + form=ItemVariationForm, formset=I18nFormSet, + can_order=True, can_delete=True, extra=0 + ) + return formsetclass(self.request.POST if self.request.method == "POST" else None, + queryset=ItemVariation.objects.filter(item=self.get_object()), + event=self.request.event) def post(self, request, *args, **kwargs): - self.main(request, *args, **kwargs) - context = self.get_context_data(object=self.object) - valid = True with transaction.atomic(): - for form in self.forms_flat: - if form.is_valid() and form.has_changed(): - form.save() - change_data = { - k: form.cleaned_data.get(k) for k in form.changed_data - } - change_data['id'] = form.instance.pk - self.object.log_action( - 'pretix.event.item.variation.changed', user=self.request.user, data=change_data + if self.formset.is_valid(): + for form in self.formset.deleted_forms: + if not form.instance.pk: + continue + self.get_object().log_action( + 'pretix.event.item.variation.deleted', user=self.request.user, data={ + 'id': form.instance.pk + } ) - if hasattr(form.instance, 'creation') and form.instance.creation: - # We need this special 'creation' field set to true in get_form - # for newly created items as cleanerversion does already set the - # primary key in its post_init hook - form.instance.values.add(*form.values) - elif not form.is_valid and form.has_changed(): - valid = False - if valid: - messages.success(self.request, _('Your changes have been saved.')) - return redirect(self.get_success_url()) - return self.render_to_response(context) + form.instance.delete() + form.instance.pk = None - def get_template_names(self) -> "List[str]": - if self.dimension == 0: - return ['pretixcontrol/item/variations_0d.html'] - elif self.dimension == 1: - return ['pretixcontrol/item/variations_1d.html'] - elif self.dimension >= 2: - return ['pretixcontrol/item/variations_nd.html'] + for i, form in enumerate(self.formset.ordered_forms + [ + ef for ef in self.formset.extra_forms if (ef not in self.formset.ordered_forms and ef not in + self.formset.deleted_forms) + ]): + form.instance.position = i + form.instance.item = self.get_object() + created = not form.instance.pk + form.save() + if form.has_changed(): + change_data = {k: form.cleaned_data.get(k) for k in form.changed_data} + change_data['id'] = form.instance.pk + self.get_object().log_action( + 'pretix.event.item.variation.changed' if not created else + 'pretix.event.item.variation.added', + user=self.request.user, data=change_data + ) + + messages.success(self.request, _('Your changes have been saved.')) + return redirect(self.get_success_url()) + return self.get(request, *args, **kwargs) def get_success_url(self) -> str: return reverse('control:event.item.variations', kwargs={ @@ -815,9 +604,9 @@ class ItemVariations(ItemDetailMixin, EventPermissionRequiredMixin, TemplateView }) def get_context_data(self, **kwargs) -> dict: + self.object = self.get_object() context = super().get_context_data(**kwargs) - context['forms'] = self.forms - context['properties'] = self.properties + context['formset'] = self.formset return context @@ -835,13 +624,13 @@ class ItemDelete(EventPermissionRequiredMixin, DeleteView): def is_allowed(self) -> bool: return not self.get_object().positions.exists() - def get_object(self, queryset=None) -> Property: + def get_object(self, queryset=None) -> Item: if not hasattr(self, 'object') or not self.object: try: self.object = self.request.event.items.get( id=self.kwargs['item'] ) - except Property.DoesNotExist: + except Item.DoesNotExist: raise Http404(_("The requested product does not exist.")) return self.object diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 9d782cc146..cc3f5e7ac8 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -126,8 +126,7 @@ class OrderDetail(OrderView): ).select_related( 'item', 'variation' ).prefetch_related( - 'variation__values', 'variation__values__prop', 'item__questions', - 'answers' + 'item__questions', 'answers' ) # Group items of the same variation diff --git a/src/pretix/multidomain/migrations/0001_initial.py b/src/pretix/multidomain/migrations/0001_initial.py index 708deb8e46..1b37be6885 100644 --- a/src/pretix/multidomain/migrations/0001_initial.py +++ b/src/pretix/multidomain/migrations/0001_initial.py @@ -1,21 +1,25 @@ # -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2015-12-13 11:44 from __future__ import unicode_literals +import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): + initial = True + dependencies = [ - ('pretixbase', '0001_initial'), + ('pretixbase', '0002_auto_20151213_1144'), ] operations = [ migrations.CreateModel( name='KnownDomain', fields=[ - ('domainname', models.CharField(primary_key=True, max_length=255, serialize=False)), - ('organizer', models.ForeignKey(to='pretixbase.Organizer', null=True, blank=True, related_name='domains')), + ('domainname', models.CharField(max_length=255, primary_key=True, serialize=False)), + ('organizer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='pretixbase.Organizer')), ], options={ 'verbose_name_plural': 'Known domains', diff --git a/src/pretix/presale/templates/pretixpresale/event/index.html b/src/pretix/presale/templates/pretixpresale/event/index.html index 7eb1899525..34cb2ac39c 100644 --- a/src/pretix/presale/templates/pretixpresale/event/index.html +++ b/src/pretix/presale/templates/pretixpresale/event/index.html @@ -109,7 +109,7 @@
+ name="variation_{{ item.id }}_{{ var.id }}">
{% else %} {% include "pretixpresale/event/fragment_availability.html" with avail=var.cached_availability.0 %} diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index f08c47bd84..f2d3ac2b0a 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -21,7 +21,6 @@ class CartMixin: ).select_related( 'item', 'variation' ).prefetch_related( - 'variation__values', 'variation__values__prop', 'item__questions', 'answers' )) @@ -30,7 +29,7 @@ class CartMixin: cart_id=self.request.session.session_key, event=self.request.event ) - prefetch = ['variation__values', 'variation__values__prop'] + prefetch = [] if answers: prefetch.append('item__questions') prefetch.append('answers') diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 71328f473e..f5151ace34 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -20,35 +20,32 @@ class EventIndex(EventViewMixin, CartMixin, TemplateView): ).select_related( 'category', # for re-grouping ).prefetch_related( - 'properties', # for .get_all_available_variations() 'quotas', 'variations__quotas', 'quotas__event' # for .availability() ).annotate(quotac=Count('quotas')).filter( quotac__gt=0 ).order_by('category__position', 'category_id', 'position', 'name') for item in items: - item.available_variations = sorted(item.get_all_available_variations(), - key=lambda vd: vd.ordered_values()) - item.has_variations = (len(item.available_variations) != 1 - or not item.available_variations[0].empty()) + item.available_variations = list(item.variations.filter(active=True, quotas__isnull=False).distinct()) + item.has_variations = item.variations.exists() if not item.has_variations: item.cached_availability = list(item.check_quotas()) item.cached_availability[1] = min((item.cached_availability[1] if item.cached_availability[1] is not None else sys.maxsize), int(self.request.event.settings.max_items_per_order)) - item.price = item.available_variations[0]['price'] + item.price = item.default_price else: for var in item.available_variations: - var.cached_availability = list(var['variation'].check_quotas()) + var.cached_availability = list(var.check_quotas()) var.cached_availability[1] = min(var.cached_availability[1] if var.cached_availability[1] is not None else sys.maxsize, int(self.request.event.settings.max_items_per_order)) - var.price = var.get('price', item.default_price) + var.price = var.default_price if var.default_price is not None else item.default_price if len(item.available_variations) > 0: item.min_price = min([v.price for v in item.available_variations]) item.max_price = max([v.price for v in item.available_variations]) - items = [item for item in items if len(item.available_variations) > 0] + items = [item for item in items if len(item.available_variations) > 0 or not item.has_variations] # Regroup those by category context['items_by_category'] = sorted( diff --git a/src/static/pretixcontrol/js/ui/main.js b/src/static/pretixcontrol/js/ui/main.js index 84aa06e261..c883207571 100644 --- a/src/static/pretixcontrol/js/ui/main.js +++ b/src/static/pretixcontrol/js/ui/main.js @@ -3,27 +3,12 @@ $(function () { "use strict"; - var nested_formset_config = { - form: '[data-nested-formset-form]', - emptyForm: 'script[type=form-template][data-nested-formset-empty-form]', - body: '[data-nested-formset-body]', - add: '[data-nested-formset-add]', - deleteButton: '[data-nested-formset-delete-button]', - moveUpButton: '[data-nested-formset-move-up-button]', - moveDownButton: '[data-nested-formset-move-down-button]', - animateForms: true, - reorderMode: 'animate', - empty_prefix: '__inner_prefix__' - }; $("[data-formset]").formset( { animateForms: true, reorderMode: 'animate' } - ).on("formAdded", "[data-formset-form]", function () { - $(this).find(".nested-formset").formset(nested_formset_config); - }); - $(".nested-formset").formset(nested_formset_config); + ); $(document).on("click", ".variations .variations-select-all", function (e) { $(this).parent().parent().find("input[type=checkbox]").prop("checked", true).change(); e.stopPropagation(); diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index 6c276808da..0e75d64890 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -7,143 +7,9 @@ from django.utils.timezone import now from pretix.base.models import ( CachedFile, CartPosition, Event, Item, ItemCategory, ItemVariation, Order, - OrderPosition, Organizer, Property, PropertyValue, Question, Quota, User, + OrderPosition, Organizer, Question, Quota, User, ) from pretix.base.services.orders import mark_order_paid -from pretix.base.types import VariationDict - - -class ItemVariationsTest(TestCase): - """ - This test case tests various methods around the properties / - variations concept. - """ - - @classmethod - def setUpTestData(cls): - o = Organizer.objects.create(name='Dummy', slug='dummy') - cls.event = Event.objects.create( - organizer=o, name='Dummy', slug='dummy', - date_from=now(), - ) - cls.p_size = Property.objects.create(event=cls.event, name='Size') - cls.pv_size_s = PropertyValue.objects.create(prop=cls.p_size, value='S') - cls.pv_size_m = PropertyValue.objects.create(prop=cls.p_size, value='M') - PropertyValue.objects.create(prop=cls.p_size, value='L') - cls.p_color = Property.objects.create(event=cls.event, name='Color') - cls.pv_color_black = PropertyValue.objects.create(prop=cls.p_color, value='black') - PropertyValue.objects.create(prop=cls.p_color, value='blue') - - def test_variationdict(self): - i = Item.objects.create(event=self.event, name='Dummy', default_price=0) - self.p_size.item = i - self.p_size.save() - iv = ItemVariation.objects.create(item=i) - iv.values.add(self.pv_size_s) - - variations = i.get_all_variations() - - for vd in variations: - for i, v in vd.relevant_items(): - self.assertIs(type(v), PropertyValue) - - for v in vd.relevant_values(): - self.assertIs(type(v), PropertyValue) - - if vd[self.p_size.pk] == self.pv_size_s: - vd1 = vd - - vd2 = VariationDict() - vd2[self.p_size.pk] = self.pv_size_s - - self.assertEqual(vd2.identify(), vd1.identify()) - self.assertEqual(vd2, vd1) - - vd2[self.p_size.pk] = self.pv_size_m - - self.assertNotEqual(vd2.identify(), vd.identify()) - self.assertNotEqual(vd2, vd1) - - vd3 = vd2.copy() - self.assertEqual(vd3, vd2) - - vd2[self.p_size.pk] = self.pv_size_s - self.assertNotEqual(vd3, vd2) - - vd4 = VariationDict() - vd4[4] = 'b' - vd4[2] = 'a' - self.assertEqual(vd4.ordered_values(), ['a', 'b']) - - def test_get_all_variations(self): - i = Item.objects.create(event=self.event, name='Dummy', default_price=0) - - # No properties available - v = i.get_all_variations() - self.assertEqual(len(v), 1) - self.assertEqual(v[0], {}) - - # One property, no variations - self.p_size.item = i - self.p_size.save() - v = i.get_all_variations() - self.assertIs(type(v), list) - self.assertEqual(len(v), 3) - values = [] - for var in v: - self.assertIs(type(var), VariationDict) - self.assertIn(self.p_size.pk, var) - self.assertIs(type(var[self.p_size.pk]), PropertyValue) - values.append(var[self.p_size.pk].value) - self.assertEqual(sorted([str(V) for V in values]), sorted(['S', 'M', 'L'])) - - # One property, one variation - iv = ItemVariation.objects.create(item=i, default_price=0) - iv.values.add(self.pv_size_s) - v = i.get_all_variations() - self.assertIs(type(v), list) - self.assertEqual(len(v), 3) - values = [] - num_variations = 0 - for var in v: - self.assertIs(type(var), VariationDict) - if 'variation' in var and type(var['variation']) is ItemVariation: - self.assertEqual(iv.pk, var['variation'].pk) - values.append(var['variation'].values.all()[0].value) - num_variations += 1 - elif self.p_size.pk in var: - self.assertIs(type(var[self.p_size.pk]), PropertyValue) - values.append(var[self.p_size.pk].value) - self.assertEqual(sorted([str(V) for V in values]), sorted(['S', 'M', 'L'])) - self.assertEqual(num_variations, 1) - - # Two properties, one variation - self.p_color.item = i - self.p_color.save() - iv.values.add(self.pv_color_black) - v = i.get_all_variations() - self.assertIs(type(v), list) - self.assertEqual(len(v), 6) - values = [] - num_variations = 0 - for var in v: - self.assertIs(type(var), VariationDict) - if 'variation' in var: - self.assertEqual(iv.pk, var['variation'].pk) - values.append(sorted([str(ivv.value) for ivv in iv.values.all()])) - self.assertEqual(sorted([str(ivv.value) for ivv in iv.values.all()]), sorted(['S', 'black'])) - num_variations += 1 - else: - values.append(sorted([str(pv.value) for pv in var.values()])) - self.assertEqual(sorted(values), sorted([ - ['S', 'black'], - ['S', 'blue'], - ['M', 'black'], - ['M', 'blue'], - ['L', 'black'], - ['L', 'blue'], - ])) - self.assertEqual(num_variations, 1) class UserTestCase(TestCase): @@ -184,12 +50,7 @@ class BaseQuotaTestCase(TestCase): self.item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, admission=True) self.item2 = Item.objects.create(event=self.event, name="T-Shirt", default_price=23) - p = Property.objects.create(event=self.event, name='Size', item=self.item2) - pv1 = PropertyValue.objects.create(prop=p, value='S') - PropertyValue.objects.create(prop=p, value='M') - PropertyValue.objects.create(prop=p, value='L') - self.var1 = ItemVariation.objects.create(item=self.item2) - self.var1.values.add(pv1) + self.var1 = ItemVariation.objects.create(item=self.item2, value='S') class QuotaTestCase(BaseQuotaTestCase): diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py index ce984e724c..e24135a53c 100644 --- a/src/tests/control/test_items.py +++ b/src/tests/control/test_items.py @@ -3,13 +3,12 @@ import os import time import unittest -from django.utils.timezone import now from selenium.webdriver.support.select import Select from tests.base import BrowserTest from pretix.base.models import ( - Event, EventPermission, Item, ItemCategory, Organizer, OrganizerPermission, - Property, PropertyValue, Question, Quota, User, + Event, EventPermission, Item, ItemCategory, ItemVariation, Organizer, + OrganizerPermission, Question, Quota, User, ) @@ -105,62 +104,6 @@ class CategoriesTest(ItemFormTest): assert not ItemCategory.objects.filter(id=c.id).exists() -class PropertiesTest(ItemFormTest): - """ - Properties have moved from their original place, skip this for now - """ - - @unittest.skip - def test_create(self): - self.driver.get('%s/control/event/%s/%s/properties/add' % ( - self.live_server_url, self.orga1.slug, self.event1.slug - )) - self.driver.find_element_by_css_selector("#id_name_0").send_keys('Size') - self.driver.find_element_by_name("values-0-value_0").send_keys('S') - self.driver.find_element_by_name("values-1-value_0").send_keys('M') - self.scroll_and_click(self.driver.find_element_by_class_name("btn-save")) - self.driver.find_element_by_class_name("alert-success") - self.assertIn("Size", self.driver.find_element_by_css_selector("#page-wrapper table").text) - self.driver.find_element_by_partial_link_text("Size").click() - self.assertEqual("S", self.driver.find_element_by_name("values-0-value_0").get_attribute("value")) - self.assertEqual("M", self.driver.find_element_by_name("values-1-value_0").get_attribute("value")) - - @unittest.skip - def test_update(self): - c = Property.objects.create(event=self.event1, name="Size") - p1 = PropertyValue.objects.create(prop=c, position=0, value="S") - p2 = PropertyValue.objects.create(prop=c, position=1, value="M") - self.driver.get('%s/control/event/%s/%s/properties/%s/' % ( - self.live_server_url, self.orga1.slug, self.event1.slug, c.id - )) - self.driver.find_element_by_css_selector("#id_name_0").clear() - self.driver.find_element_by_css_selector("#id_name_0").send_keys('Color') - self.driver.find_elements_by_css_selector("div.form-group button.btn-danger")[0].click() - self.scroll_into_view(self.driver.find_element_by_name("values-1-value_0")) - self.driver.find_element_by_name("values-1-value_0").clear() - self.driver.find_element_by_name("values-1-value_0").send_keys('red') - self.driver.find_element_by_css_selector("button[data-formset-add]").click() - self.driver.find_element_by_name("values-2-value_0").send_keys('blue') - self.driver.find_element_by_class_name("btn-save").click() - self.driver.find_element_by_class_name("alert-success") - self.assertEqual("red", self.driver.find_element_by_name("values-0-value_0").get_attribute("value")) - self.assertEqual("blue", self.driver.find_element_by_name("values-1-value_0").get_attribute("value")) - assert str(Property.objects.get(id=c.id).name) == 'Color' - assert str(PropertyValue.objects.get(id=p2.id).value) == 'red' - assert not PropertyValue.objects.filter(id=p1.id).exists() - - @unittest.skip - def test_delete(self): - c = Property.objects.create(event=self.event1, name="Size") - self.driver.get('%s/control/event/%s/%s/properties/%s/delete' % ( - self.live_server_url, self.orga1.slug, self.event1.slug, c.id - )) - self.driver.find_element_by_class_name("btn-danger").click() - self.driver.find_element_by_class_name("alert-success") - self.assertNotIn("Size", self.driver.find_element_by_css_selector("#page-wrapper table").text) - assert not Property.objects.filter(id=c.id).exists() - - class QuestionsTest(ItemFormTest): def test_create(self): @@ -215,9 +158,8 @@ class QuotaTest(ItemFormTest): c = Quota.objects.create(event=self.event1, name="Full house", size=500) item1 = Item.objects.create(event=self.event1, name="Standard", default_price=0) item2 = Item.objects.create(event=self.event1, name="Business", default_price=0) - prop1 = Property.objects.create(event=self.event1, name="Level", item=item2) - PropertyValue.objects.create(prop=prop1, value="Silver") - PropertyValue.objects.create(prop=prop1, value="Gold") + ItemVariation.objects.create(item=item2, value="Silver") + ItemVariation.objects.create(item=item2, value="Gold") self.driver.get('%s/control/event/%s/%s/quotas/%s/' % ( self.live_server_url, self.orga1.slug, self.event1.slug, c.id )) diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 87da5b62cf..f4b3c24ded 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -35,7 +35,6 @@ event_urls = [ "items/add", "items/1/", "items/1/variations", - "items/1/properties", "categories/", "categories/add", "categories/2/", diff --git a/src/tests/plugins/test_pretixdroid.py b/src/tests/plugins/test_pretixdroid.py index fc647b210e..0c9c3a9c7a 100644 --- a/src/tests/plugins/test_pretixdroid.py +++ b/src/tests/plugins/test_pretixdroid.py @@ -6,7 +6,7 @@ from django.utils.timezone import now from pretix.base.models import ( Event, EventPermission, Item, ItemVariation, Order, OrderPosition, - Organizer, Property, PropertyValue, User, + Organizer, User, ) @@ -20,13 +20,8 @@ def env(): user = User.objects.create_user('dummy@dummy.dummy', 'dummy') EventPermission.objects.create(user=user, event=event) shirt = Item.objects.create(event=event, name='T-Shirt', default_price=12) - prop1 = Property.objects.create(event=event, name="Color", item=shirt) - val1 = PropertyValue.objects.create(prop=prop1, value="Red", position=0) - val2 = PropertyValue.objects.create(prop=prop1, value="Black", position=1) - shirt_red = ItemVariation.objects.create(item=shirt, default_price=14) - shirt_red.values.add(val1) - var2 = ItemVariation.objects.create(item=shirt) - var2.values.add(val2) + 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_PENDING, diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index 45aba51ae6..4d5046859d 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -8,7 +8,7 @@ from django.utils.timezone import now from pretix.base.models import ( CartPosition, Event, Item, ItemCategory, ItemVariation, Organizer, - Property, PropertyValue, Question, QuestionAnswer, Quota, User, + Question, QuestionAnswer, Quota, ) @@ -23,14 +23,9 @@ class CartTestMixin: self.category = ItemCategory.objects.create(event=self.event, name="Everything", position=0) self.quota_shirts = Quota.objects.create(event=self.event, name='Shirts', size=2) self.shirt = Item.objects.create(event=self.event, name='T-Shirt', category=self.category, default_price=12) - prop1 = Property.objects.create(event=self.event, name="Color", item=self.shirt) - val1 = PropertyValue.objects.create(prop=prop1, value="Red", position=0) - val2 = PropertyValue.objects.create(prop=prop1, value="Black", position=1) self.quota_shirts.items.add(self.shirt) - self.shirt_red = ItemVariation.objects.create(item=self.shirt, default_price=14) - self.shirt_red.values.add(val1) - var2 = ItemVariation.objects.create(item=self.shirt) - var2.values.add(val2) + self.shirt_red = ItemVariation.objects.create(item=self.shirt, default_price=14, value='Red') + var2 = ItemVariation.objects.create(item=self.shirt, value='Blue') self.quota_shirts.variations.add(self.shirt_red) self.quota_shirts.variations.add(var2) self.quota_tickets = Quota.objects.create(event=self.event, name='Tickets', size=5) diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 106193f0df..2886b39f0e 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -6,8 +6,7 @@ from django.utils.timezone import now from tests.base import BrowserTest from pretix.base.models import ( - Event, Item, ItemCategory, ItemVariation, Organizer, Property, - PropertyValue, Quota, User, + Event, Item, ItemCategory, ItemVariation, Organizer, Quota, ) @@ -99,9 +98,7 @@ class ItemDisplayTest(EventTestMixin, BrowserTest): c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0) q = Quota.objects.create(event=self.event, name='Quota', size=2) item = Item.objects.create(event=self.event, name='Early-bird ticket', category=c, default_price=0) - prop1 = Property.objects.create(event=self.event, name="Color", item=item) - PropertyValue.objects.create(prop=prop1, value="Red") - PropertyValue.objects.create(prop=prop1, value="Black") + ItemVariation.objects.create(item=item, value='Blue') q.items.add(item) self.driver.get('%s/%s/%s/' % (self.live_server_url, self.orga.slug, self.event.slug)) resp = self.client.get('%s/%s/%s/' % (self.live_server_url, self.orga.slug, self.event.slug)) @@ -111,12 +108,9 @@ class ItemDisplayTest(EventTestMixin, BrowserTest): c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0) q = Quota.objects.create(event=self.event, name='Quota', size=2) item = Item.objects.create(event=self.event, name='Early-bird ticket', category=c, default_price=0) - prop1 = Property.objects.create(event=self.event, name="Color", item=item) - val1 = PropertyValue.objects.create(prop=prop1, value="Red") - PropertyValue.objects.create(prop=prop1, value="Black") + var1 = ItemVariation.objects.create(item=item, value='Red') + ItemVariation.objects.create(item=item, value='Blue') q.items.add(item) - var1 = ItemVariation.objects.create(item=item) - var1.values.add(val1) q.variations.add(var1) self._assert_variation_found() @@ -124,12 +118,9 @@ class ItemDisplayTest(EventTestMixin, BrowserTest): c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0) q = Quota.objects.create(event=self.event, name='Quota', size=None) item = Item.objects.create(event=self.event, name='Early-bird ticket', category=c, default_price=0) - prop1 = Property.objects.create(event=self.event, name="Color", item=item) - val1 = PropertyValue.objects.create(prop=prop1, value="Red") - PropertyValue.objects.create(prop=prop1, value="Black") + var1 = ItemVariation.objects.create(item=item, value='Red') + ItemVariation.objects.create(item=item, value='Blue') q.items.add(item) - var1 = ItemVariation.objects.create(item=item) - var1.values.add(val1) q.variations.add(var1) self._assert_variation_found() @@ -149,16 +140,11 @@ class ItemDisplayTest(EventTestMixin, BrowserTest): c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0) q = Quota.objects.create(event=self.event, name='Quota', size=2) item = Item.objects.create(event=self.event, name='Early-bird ticket', category=c, default_price=12) - prop1 = Property.objects.create(event=self.event, name="Color", item=item) - val1 = PropertyValue.objects.create(prop=prop1, value="Red", position=0) - val2 = PropertyValue.objects.create(prop=prop1, value="Black", position=1) - q.items.add(item) - var1 = ItemVariation.objects.create(item=item, default_price=14) - var1.values.add(val1) - var2 = ItemVariation.objects.create(item=item) - var2.values.add(val2) + var1 = ItemVariation.objects.create(item=item, value='Red', default_price=14, position=1) + var2 = ItemVariation.objects.create(item=item, value='Black', position=2) q.variations.add(var1) q.variations.add(var2) + q.items.add(item) self.driver.get('%s/%s/%s/' % (self.live_server_url, self.orga.slug, self.event.slug)) self.assertIn("Early-bird", self.driver.find_element_by_css_selector("section:nth-of-type(1) div:nth-of-type(1)").text) diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index b20f864f7a..8235b61e7d 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -7,7 +7,7 @@ from django.utils.timezone import now from pretix.base.models import ( Event, Item, ItemCategory, ItemVariation, Order, OrderPosition, Organizer, - Property, PropertyValue, Question, Quota, + Question, Quota, ) @@ -26,15 +26,9 @@ class OrdersTest(TestCase): self.category = ItemCategory.objects.create(event=self.event, name="Everything", position=0) self.quota_shirts = Quota.objects.create(event=self.event, name='Shirts', size=2) self.shirt = Item.objects.create(event=self.event, name='T-Shirt', category=self.category, default_price=12) - prop1 = Property.objects.create(event=self.event, name="Color", item=self.shirt) - self.shirt.properties.add(prop1) - val1 = PropertyValue.objects.create(prop=prop1, value="Red", position=0) - val2 = PropertyValue.objects.create(prop=prop1, value="Black", position=1) self.quota_shirts.items.add(self.shirt) - self.shirt_red = ItemVariation.objects.create(item=self.shirt, default_price=14) - self.shirt_red.values.add(val1) - var2 = ItemVariation.objects.create(item=self.shirt) - var2.values.add(val2) + self.shirt_red = ItemVariation.objects.create(item=self.shirt, default_price=14, value="Red") + var2 = ItemVariation.objects.create(item=self.shirt, value="Blue") self.quota_shirts.variations.add(self.shirt_red) self.quota_shirts.variations.add(var2) self.quota_tickets = Quota.objects.create(event=self.event, name='Tickets', size=5)