diff --git a/doc/api/resources/item_variations.rst b/doc/api/resources/item_variations.rst index c5ce44411b..2482e17158 100644 --- a/doc/api/resources/item_variations.rst +++ b/doc/api/resources/item_variations.rst @@ -43,8 +43,13 @@ available_until datetime The last date t hide_without_voucher boolean If ``true``, this variation is only shown during the voucher redemption process, but not in the normal shop frontend. +meta_data object Values set for event-specific meta data parameters. ===================================== ========================== ======================================================= +.. versionchanged:: 4.16 + + The ``meta_data`` attribute has been added. + Endpoints --------- @@ -94,6 +99,7 @@ Endpoints "default_price": "223.00", "price": 223.0, "original_price": null, + "meta_data": {} }, { "id": 3, @@ -108,7 +114,8 @@ Endpoints "description": {}, "position": 1, "default_price": null, - "price": 15.0 + "price": 15.0, + "meta_data": {} } ] } @@ -161,7 +168,8 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, - "position": 0 + "position": 0, + "meta_data": {} } :param organizer: The ``slug`` field of the organizer to fetch @@ -198,7 +206,8 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, - "position": 0 + "position": 0, + "meta_data": {} } **Example response**: @@ -225,7 +234,8 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, - "position": 0 + "position": 0, + "meta_data": {} } :param organizer: The ``slug`` field of the organizer of the event/item to create a variation for @@ -283,7 +293,8 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, - "position": 1 + "position": 1, + "meta_data": {} } :param organizer: The ``slug`` field of the organizer to modify diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 9ca964f911..0e060764cc 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -123,6 +123,7 @@ variations list of objects A list with one ├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher redemption process, but not in the normal shop frontend. +├ meta_data object Values set for event-specific meta data parameters. └ position integer An integer, used for sorting addons list of objects Definition of add-ons that can be chosen for this item. Only writable during creation, @@ -155,6 +156,10 @@ meta_data object Values set for The attributes ``require_membership_hidden`` attribute has been added. +.. versionchanged:: 4.16 + + The ``variations[x].meta_data`` attribute has been added. + Notes ----- @@ -247,6 +252,7 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, + "meta_data": {}, "position": 0 }, { @@ -262,6 +268,7 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, + "meta_data": {}, "position": 1 } ], @@ -361,6 +368,7 @@ Endpoints "available_from": null, "available_until": null, "hide_without_voucher": false, + "meta_data": {}, "position": 0 }, { @@ -376,6 +384,7 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, + "meta_data": {}, "position": 1 } ], @@ -455,6 +464,7 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, + "meta_data": {}, "position": 0 }, { @@ -470,6 +480,7 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, + "meta_data": {}, "position": 1 } ], @@ -538,6 +549,7 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, + "meta_data": {}, "position": 0 }, { @@ -553,6 +565,7 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, + "meta_data": {}, "position": 1 } ], @@ -652,6 +665,7 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, + "meta_data": {}, "position": 0 }, { @@ -667,6 +681,7 @@ Endpoints "available_until": null, "hide_without_voucher": false, "description": null, + "meta_data": {}, "position": 1 } ], diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index a1c22d6838..548db655ff 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -47,13 +47,14 @@ from pretix.api.serializers.fields import UploadedFileField from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.models import ( Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation, - Question, QuestionOption, Quota, + ItemVariationMetaValue, Question, QuestionOption, Quota, ) class InlineItemVariationSerializer(I18nAwareModelSerializer): price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10, coerce_to_string=True) + meta_data = MetaDataField(required=False, source='*') class Meta: model = ItemVariation @@ -61,16 +62,23 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer): 'position', 'default_price', 'price', 'original_price', 'require_approval', 'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until', - 'sales_channels', 'hide_without_voucher',) + 'sales_channels', 'hide_without_voucher', 'meta_data') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet) + def validate_meta_data(self, value): + for key in value['meta_data'].keys(): + if key not in self.parent.parent.item_meta_properties: + raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key)) + return value + class ItemVariationSerializer(I18nAwareModelSerializer): price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10, coerce_to_string=True) + meta_data = MetaDataField(required=False, source='*') class Meta: model = ItemVariation @@ -78,12 +86,63 @@ class ItemVariationSerializer(I18nAwareModelSerializer): 'position', 'default_price', 'price', 'original_price', 'require_approval', 'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until', - 'sales_channels', 'hide_without_voucher',) + 'sales_channels', 'hide_without_voucher', 'meta_data') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all() + @transaction.atomic + def create(self, validated_data): + meta_data = validated_data.pop('meta_data', None) + variation = ItemVariation.objects.create(**validated_data) + + # Meta data + if meta_data is not None: + for key, value in meta_data.items(): + ItemVariationMetaValue.objects.create( + property=self.item_meta_properties.get(key), + value=value, + variation=variation + ) + return variation + + @cached_property + def item_meta_properties(self): + return { + p.name: p for p in self.context['request'].event.item_meta_properties.all() + } + + def validate_meta_data(self, value): + for key in value['meta_data'].keys(): + if key not in self.item_meta_properties: + raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key)) + return value + + def update(self, instance, validated_data): + meta_data = validated_data.pop('meta_data', None) + variation = super().update(instance, validated_data) + + # Meta data + if meta_data is not None: + current = {mv.property: mv for mv in variation.meta_values.select_related('property')} + for key, value in meta_data.items(): + prop = self.item_meta_properties.get(key) + if prop in current: + current[prop].value = value + current[prop].save() + else: + variation.meta_values.create( + property=self.item_meta_properties.get(key), + value=value + ) + + for prop, current_object in current.items(): + if prop.name not in meta_data: + current_object.delete() + + return variation + class InlineItemBundleSerializer(serializers.ModelSerializer): class Meta: @@ -263,9 +322,19 @@ class ItemSerializer(I18nAwareModelSerializer): for variation_data in variations_data: require_membership_types = variation_data.pop('require_membership_types', []) + var_meta_data = variation_data.pop('meta_data', {}) v = ItemVariation.objects.create(item=item, **variation_data) if require_membership_types: v.require_membership_types.add(*require_membership_types) + + if var_meta_data is not None: + for key, value in var_meta_data.items(): + ItemVariationMetaValue.objects.create( + property=self.item_meta_properties.get(key), + value=value, + variation=v + ) + for addon_data in addons_data: ItemAddOn.objects.create(base_item=item, **addon_data) for bundle_data in bundles_data: diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index e99030c565..be189091a7 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -371,10 +371,19 @@ class PdfDataSerializer(serializers.Field): for k, v in ev._cached_meta_data.items(): res['meta:' + k] = v - if not hasattr(instance.item, '_cached_meta_data'): - instance.item._cached_meta_data = instance.item.meta_data - for k, v in instance.item._cached_meta_data.items(): - res['itemmeta:' + k] = v + if instance.variation_id: + print(instance, instance.variation, instance.variation_id, instance.item) + if not hasattr(instance.variation, '_cached_meta_data'): + instance.variation.item = instance.item # saves some database lookups + instance.variation._cached_meta_data = instance.variation.meta_data + print(instance.variation._cached_meta_data.items()) + for k, v in instance.variation._cached_meta_data.items(): + res['itemmeta:' + k] = v + else: + if not hasattr(instance.item, '_cached_meta_data'): + instance.item._cached_meta_data = instance.item.meta_data + for k, v in instance.item._cached_meta_data.items(): + res['itemmeta:' + k] = v res['images'] = {} diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index 24ae8ea1a6..3003aa7b96 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -84,7 +84,9 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): def get_queryset(self): return self.request.event.items.select_related('tax_rule').prefetch_related( - 'variations', 'addons', 'bundles', 'meta_values' + 'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property', + 'variations__meta_values', 'variations__meta_values__property', + 'require_membership_types', 'variations__require_membership_types', ).all() def perform_create(self, serializer): @@ -147,7 +149,11 @@ class ItemVariationViewSet(viewsets.ModelViewSet): return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) def get_queryset(self): - return self.item.variations.all() + return self.item.variations.all().prefetch_related( + 'meta_values', + 'meta_values__property', + 'require_membership_types' + ) def get_serializer_context(self): ctx = super().get_serializer_context() diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index f7e29b5637..6a824e064c 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -65,9 +65,10 @@ from pretix.api.views import RichOrderingFilter from pretix.base.i18n import language from pretix.base.models import ( CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue, - Invoice, InvoiceAddress, ItemMetaValue, Order, OrderFee, OrderPayment, - OrderPosition, OrderRefund, Quota, SubEvent, SubEventMetaValue, TaxRule, - TeamAPIToken, generate_secret, + Invoice, InvoiceAddress, ItemMetaValue, ItemVariation, + ItemVariationMetaValue, Order, OrderFee, OrderPayment, OrderPosition, + OrderRefund, Quota, SubEvent, SubEventMetaValue, TaxRule, TeamAPIToken, + generate_secret, ) from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret from pretix.base.payment import PaymentException @@ -232,7 +233,9 @@ class OrderViewSet(viewsets.ModelViewSet): Prefetch('item', queryset=self.request.event.items.prefetch_related( Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached') )), - 'variation', + Prefetch('variation', queryset=ItemVariation.objects.prefetch_related( + Prefetch('meta_values', ItemVariationMetaValue.objects.select_related('property'), to_attr='meta_values_cached') + )), 'answers', 'answers__options', 'answers__question', 'item__category', 'addon_to__answers', 'addon_to__answers__options', 'addon_to__answers__question', @@ -999,7 +1002,11 @@ class OrderPositionViewSet(viewsets.ModelViewSet): Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached') )), - 'variation', 'answers', 'answers__options', 'answers__question', + Prefetch('variation', queryset=self.request.event.items.prefetch_related( + Prefetch('meta_values', ItemVariationMetaValue.objects.select_related('property'), + to_attr='meta_values_cached') + )), + 'answers', 'answers__options', 'answers__question', 'item__category', Prefetch('subevent', queryset=self.request.event.subevents.prefetch_related( Prefetch('meta_values', to_attr='meta_values_cached', diff --git a/src/pretix/base/exporters/items.py b/src/pretix/base/exporters/items.py index 6988d8b4eb..596fe0fdd1 100644 --- a/src/pretix/base/exporters/items.py +++ b/src/pretix/base/exporters/items.py @@ -29,7 +29,7 @@ from openpyxl.utils import get_column_letter from ...helpers.safe_openpyxl import SafeCell from ..channels import get_all_sales_channels from ..exporter import ListExporter -from ..models import ItemMetaValue +from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue from ..signals import register_data_exporters @@ -106,18 +106,27 @@ class ItemDataExporter(ListExporter): yield row for i in self.event.items.prefetch_related( - 'variations', Prefetch( 'meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached' - ) + ), + Prefetch( + 'variations', + queryset=ItemVariation.objects.prefetch_related( + Prefetch( + 'meta_values', + ItemVariationMetaValue.objects.select_related('property'), + to_attr='meta_values_cached' + ), + ), + ), ).select_related('category', 'tax_rule'): - m = i.meta_data vars = list(i.variations.all()) if vars: for v in vars: + m = v.meta_data row = [ i.pk, v.pk, @@ -160,6 +169,7 @@ class ItemDataExporter(ListExporter): yield row else: + m = i.meta_data row = [ i.pk, "", diff --git a/src/pretix/base/exporters/json.py b/src/pretix/base/exporters/json.py index 0eb825b977..6d0cd75243 100644 --- a/src/pretix/base/exporters/json.py +++ b/src/pretix/base/exporters/json.py @@ -36,9 +36,11 @@ import json from decimal import Decimal from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import Prefetch from django.dispatch import receiver from ..exporter import BaseExporter +from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue from ..signals import register_data_exporters @@ -106,9 +108,26 @@ class JSONExporter(BaseExporter): 'available_from': variation.available_from, 'available_until': variation.available_until, 'hide_without_voucher': variation.hide_without_voucher, + 'meta_data': variation.meta_data, } for variation in item.variations.all() ] - } for item in self.event.items.select_related('tax_rule').prefetch_related('variations') + } for item in self.event.items.select_related('tax_rule').prefetch_related( + Prefetch( + 'meta_values', + ItemMetaValue.objects.select_related('property'), + to_attr='meta_values_cached' + ), + Prefetch( + 'variations', + queryset=ItemVariation.objects.prefetch_related( + Prefetch( + 'meta_values', + ItemVariationMetaValue.objects.select_related('property'), + to_attr='meta_values_cached' + ), + ), + ), + ) ], 'questions': [ { diff --git a/src/pretix/base/migrations/0226_itemvariationmetavalue.py b/src/pretix/base/migrations/0226_itemvariationmetavalue.py new file mode 100644 index 0000000000..277159e80a --- /dev/null +++ b/src/pretix/base/migrations/0226_itemvariationmetavalue.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.16 on 2022-12-09 10:06 + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0225_orderpayment_process_initiated'), + ] + + operations = [ + migrations.CreateModel( + name='ItemVariationMetaValue', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('value', models.TextField()), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variation_values', to='pretixbase.itemmetaproperty')), + ('variation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meta_values', to='pretixbase.itemvariation')), + ], + options={ + 'unique_together': {('variation', 'property')}, + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index c1687ad049..626d8263a5 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -34,8 +34,8 @@ from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction from .invoices import Invoice, InvoiceLine, invoice_filename from .items import ( Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue, - ItemVariation, Question, QuestionOption, Quota, SubEventItem, - SubEventItemVariation, itempicture_upload_to, + ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota, + SubEventItem, SubEventItemVariation, itempicture_upload_to, ) from .log import LogEntry from .memberships import Membership, MembershipType diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 0f6793f9d1..663657ea3a 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -728,7 +728,7 @@ class Event(EventMixin, LoggedModel): from ..signals import event_copy_data from . import ( Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, - Question, Quota, + ItemVariationMetaValue, Question, Quota, ) # Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin. @@ -804,12 +804,18 @@ class Event(EventMixin, LoggedModel): v.item = i v.save(force_insert=True) - for imv in ItemMetaValue.objects.filter(item__event=other).prefetch_related('item', 'property'): + for imv in ItemMetaValue.objects.filter(item__event=other): imv.pk = None - imv.property = item_meta_properties_map[imv.property.pk] + imv.property = item_meta_properties_map[imv.property_id] imv.item = item_map[imv.item.pk] imv.save(force_insert=True) + for imv in ItemVariationMetaValue.objects.filter(variation__item__event=other): + imv.pk = None + imv.property = item_meta_properties_map[imv.property_id] + imv.variation = variation_map[imv.variation_id] + imv.save(force_insert=True) + for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'): ia.pk = None ia.base_item = item_map[ia.base_item.pk] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 6a4fb6386d..94c60dffcb 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -1008,6 +1008,16 @@ class ItemVariation(models.Model): return False return True + @property + def meta_data(self): + data = self.item.meta_data + if hasattr(self, 'meta_values_cached'): + data.update({v.property.name: v.value for v in self.meta_values_cached}) + else: + data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()}) + + return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0])) + class ItemAddOn(models.Model): """ @@ -1787,8 +1797,21 @@ class ItemMetaValue(LoggedModel): class Meta: unique_together = ('item', 'property') - def delete(self, *args, **kwargs): - super().delete(*args, **kwargs) - def save(self, *args, **kwargs): - super().save(*args, **kwargs) +class ItemVariationMetaValue(LoggedModel): + """ + A meta-data value assigned to an item variation, overriding the value on the item. + + :param variation: The variation this metadata is valid for + :type variation: ItemVariation + :param property: The property this value belongs to + :type property: ItemMetaProperty + :param value: The actual value + :type value: str + """ + variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE, related_name='meta_values') + property = models.ForeignKey('ItemMetaProperty', on_delete=models.CASCADE, related_name='variation_values') + value = models.TextField() + + class Meta: + unique_together = ('variation', 'property') diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index 8eeffd8d39..b156e198fc 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -746,6 +746,8 @@ class Renderer: def replace(x): if x.group(1).startswith('itemmeta:'): + if op.variation_id: + return op.variation.meta_data.get(x.group(1)[9:]) or '' return op.item.meta_data.get(x.group(1)[9:]) or '' elif x.group(1).startswith('meta:'): return ev.meta_data.get(x.group(1)[5:]) or '' @@ -766,6 +768,8 @@ class Renderer: return re.sub(r'\{([a-zA-Z0-9:_]+)\}', replace, text) elif o['content'].startswith('itemmeta:'): + if op.variation_id: + return op.variation.meta_data.get(o['content'][9:]) or '' return op.item.meta_data.get(o['content'][9:]) or '' elif o['content'].startswith('meta:'): diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 3dbaae8606..38157b4709 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -44,6 +44,7 @@ from django.core.files.uploadedfile import UploadedFile from django.db.models import Max from django.forms.formsets import DELETION_FIELD_NAME from django.urls import reverse +from django.utils.functional import cached_property from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import ( @@ -436,12 +437,21 @@ class ItemCreateForm(I18nModelForm): v.pk = None v.item = instance v.save() + for mv in variation.meta_values.all(): + mv.pk = None + mv.variation = v + mv.save(force_insert=True) else: ItemVariation.objects.create( item=instance, value=__('Standard') ) if self.cleaned_data.get('copy_from'): + for mv in self.cleaned_data['copy_from'].meta_values.all(): + mv.pk = None + mv.item = instance + mv.save(force_insert=True) + for question in self.cleaned_data['copy_from'].questions.all(): question.items.add(instance) question.log_action('pretix.event.question.changed', user=self.user, data={ @@ -727,6 +737,31 @@ class ItemVariationForm(I18nModelForm): del self.fields['require_membership'] del self.fields['require_membership_types'] + self.meta_fields = [] + meta_defaults = {} + if self.instance.pk: + for mv in self.instance.meta_values.all(): + meta_defaults[mv.property_id] = mv.value + for p in self.meta_properties: + self.initial[f'meta_{p.name}'] = meta_defaults.get(p.pk) + self.fields[f'meta_{p.name}'] = forms.CharField( + label=p.name, + widget=forms.TextInput( + attrs={ + 'placeholder': _('Use value from product'), + 'data-typeahead-url': reverse('control:event.items.meta.typeahead', kwargs={ + 'organizer': self.event.organizer.slug, + 'event': self.event.slug + }) + '?' + urlencode({ + 'property': p.name, + }), + }, + ), + required=False, + + ) + self.meta_fields.append(f'meta_{p.name}') + class Meta: model = ItemVariation localized_fields = '__all__' @@ -757,6 +792,26 @@ class ItemVariationForm(I18nModelForm): }), } + def save(self, commit=True): + instance = super().save(commit) + self.meta_fields = [] + current_values = {v.property_id: v for v in instance.meta_values.all()} + for p in self.meta_properties: + if self.cleaned_data[f'meta_{p.name}']: + if p.pk in current_values: + current_values[p.pk].value = self.cleaned_data[f'meta_{p.name}'] + current_values[p.pk].save() + else: + instance.meta_values.create(property=p, value=self.cleaned_data[f'meta_{p.name}']) + elif p.pk in current_values: + current_values[p.pk].delete() + + @property + def meta_properties(self): + if not hasattr(self.event, '_cached_item_meta_properties'): + self.event._cached_item_meta_properties = self.event.item_meta_properties.all() + return self.event._cached_item_meta_properties + class ItemAddOnsFormSet(I18nFormSet): title = _('Add-ons') @@ -845,6 +900,7 @@ class ItemBundleFormSet(I18nFormSet): def _construct_form(self, i, **kwargs): kwargs['event'] = self.event kwargs['item'] = self.item + kwargs['item_qs'] = self.item_qs return super()._construct_form(i, **kwargs) @property @@ -856,12 +912,17 @@ class ItemBundleFormSet(I18nFormSet): empty_permitted=True, use_required_attribute=False, locales=self.locales, + item_qs=self.item_qs, item=self.item, event=self.event ) self.add_fields(form, None) return form + @cached_property + def item_qs(self): + return self.event.items.prefetch_related('variations').all() + def clean(self): super().clean() ivs = set() @@ -889,6 +950,7 @@ class ItemBundleForm(I18nModelForm): def __init__(self, *args, **kwargs): self.item = kwargs.pop('item') + self.item_qs = kwargs.pop('item_qs') super().__init__(*args, **kwargs) instance = kwargs.get('instance', None) initial = kwargs.get('initial', {}) @@ -906,7 +968,7 @@ class ItemBundleForm(I18nModelForm): super().__init__(*args, **kwargs) choices = [] - for i in self.event.items.prefetch_related('variations').all(): + for i in self.item_qs: pname = str(i) if not i.is_available(): pname += ' ({})'.format(_('inactive')) diff --git a/src/pretix/control/templates/pretixcontrol/item/include_variations.html b/src/pretix/control/templates/pretixcontrol/item/include_variations.html index 5632ac0194..2e49777bba 100644 --- a/src/pretix/control/templates/pretixcontrol/item/include_variations.html +++ b/src/pretix/control/templates/pretixcontrol/item/include_variations.html @@ -1,6 +1,7 @@ {% load i18n %} {% load bootstrap3 %} {% load formset_tags %} +{% load getitem %}
{{ formset.management_form }} {% bootstrap_formset_errors formset %} @@ -29,16 +30,20 @@ {% endif %}
- - - + + +
{% for k, c in sales_channels.items %} + data-toggle="tooltip" title="{% trans c.verbose_name %}"> {% endfor %}
@@ -69,6 +74,27 @@ {% bootstrap_field form.default_price addon_after=request.event.currency layout="control" %} {% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %} {% bootstrap_field form.description layout="control" %} + {% if form.meta_fields %} +
+ +
+ {% for fname in form.meta_fields %} + {% with form|getitem:fname as field %} +
+
+ +
+
+ {% bootstrap_field field layout="inline" %} +
+
+ {% endwith %} + {% endfor %} +
+
+ {% endif %} {% bootstrap_field form.available_from layout="control" %} {% bootstrap_field form.available_until layout="control" %} {% bootstrap_field form.sales_channels layout="control" %} @@ -110,16 +136,20 @@ {% endif %}
- - - + + +
{% for k, c in sales_channels.items %} + data-toggle="tooltip" title="{% trans c.verbose_name %}"> {% endfor %}
@@ -141,6 +171,27 @@ {% bootstrap_field formset.empty_form.default_price addon_after=request.event.currency layout="control" %} {% bootstrap_field formset.empty_form.original_price addon_after=request.event.currency layout="control" %} {% bootstrap_field formset.empty_form.description layout="control" %} + {% if formset.empty_form.meta_fields %} +
+ +
+ {% for fname in formset.empty_form.meta_fields %} + {% with formset.empty_form|getitem:fname as field %} +
+
+ +
+
+ {% bootstrap_field field layout="inline" %} +
+
+ {% endwith %} + {% endfor %} +
+
+ {% endif %} {% bootstrap_field formset.empty_form.available_from layout="control" %} {% bootstrap_field formset.empty_form.available_until layout="control" %} {% bootstrap_field formset.empty_form.sales_channels layout="control" %} diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index 2cdec6efc6..9fdd5d1e24 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -1426,7 +1426,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE can_order=True, can_delete=True, extra=0 )( self.request.POST if self.request.method == "POST" else None, - queryset=ItemVariation.objects.filter(item=self.get_object()), + queryset=ItemVariation.objects.filter(item=self.get_object()).prefetch_related('meta_values', 'require_membership_types'), event=self.request.event, prefix="variations" )), ('addons', inlineformset_factory( diff --git a/src/pretix/control/views/typeahead.py b/src/pretix/control/views/typeahead.py index 469d893685..5ef016ad04 100644 --- a/src/pretix/control/views/typeahead.py +++ b/src/pretix/control/views/typeahead.py @@ -48,7 +48,7 @@ from django.utils.translation import gettext as _, pgettext from pretix.base.models import ( EventMetaProperty, EventMetaValue, ItemMetaProperty, ItemMetaValue, - ItemVariation, Order, Organizer, User, Voucher, + ItemVariation, ItemVariationMetaValue, Order, Organizer, User, Voucher, ) from pretix.control.forms.event import EventWizardCopyForm from pretix.control.permissions import ( @@ -747,6 +747,10 @@ def item_meta_values(request, organizer, event): value__icontains=q, property__name=propname ) + var_matches = ItemVariationMetaValue.objects.filter( + value__icontains=q, + property__name=propname + ) defaults = ItemMetaProperty.objects.filter( name=propname, default__icontains=q @@ -758,6 +762,7 @@ def item_meta_values(request, organizer, event): defaults = defaults.filter(event__organizer_id=organizer.pk) matches = matches.filter(item__event__organizer_id=organizer.pk) + var_matches = var_matches.filter(variation__item__event__organizer_id=organizer.pk) all_access = ( request.user.has_active_staff_session(request.session.session_key) or request.user.teams.filter(all_events=True, organizer=organizer, can_change_items=True).exists() @@ -773,10 +778,19 @@ def item_meta_values(request, organizer, event): 'limit_events__id', flat=True ) ) + var_matches = matches.filter( + variation__item__event__id__in=request.user.teams.filter(can_change_items=True).values_list( + 'limit_events__id', flat=True + ) + ) return JsonResponse({ 'results': [ {'name': v, 'id': v} - for v in sorted(set(defaults.values_list('default', flat=True)[:10]) | set(matches.values_list('value', flat=True)[:10])) + for v in sorted( + set(defaults.values_list('default', flat=True)[:10]) | + set(matches.values_list('value', flat=True)[:10]) | + set(var_matches.values_list('value', flat=True)[:10]) + ) ] }) diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js index a9ea046023..7a92c5e21c 100644 --- a/src/pretix/static/pretixcontrol/js/ui/main.js +++ b/src/pretix/static/pretixcontrol/js/ui/main.js @@ -499,6 +499,10 @@ var form_handlers = function (el) { el.find('input[data-typeahead-url]').each(function () { var $inp = $(this); + if ($inp.data("ttTypeahead") || $inp.hasClass("tt-hint")) { + // Already initialized on this element + return; + } $inp.typeahead(null, { minLength: 1, highlight: true, diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index d040cf6fb9..80fe747565 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -384,7 +384,8 @@ def test_item_detail_variations(token_client, organizer, event, team, item): "available_from": None, "available_until": None, "hide_without_voucher": False, - "original_price": None + "original_price": None, + "meta_data": {} }] res["has_variations"] = True resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, @@ -551,7 +552,10 @@ def test_item_create_with_variation(token_client, organizer, event, item, catego "description": None, "position": 0, "default_price": None, - "price": "23.00" + "price": "23.00", + "meta_data": { + "day": "Wednesday", + }, } ] }, @@ -564,6 +568,7 @@ def test_item_create_with_variation(token_client, organizer, event, item, catego assert new_item.variations.first().value.localize('en') == "Comment" assert new_item.variations.first().require_approval is True assert set(new_item.variations.first().sales_channels) == set(get_all_sales_channels().keys()) + assert new_item.variations.first().meta_data == {"day": "Wednesday"} @pytest.mark.django_db @@ -1258,7 +1263,8 @@ TEST_VARIATIONS_RES = { "available_from": None, "available_until": None, "hide_without_voucher": False, - "original_price": None + "original_price": None, + "meta_data": {} } TEST_VARIATIONS_UPDATE = { @@ -1277,7 +1283,8 @@ TEST_VARIATIONS_UPDATE = { "available_from": None, "available_until": None, "hide_without_voucher": False, - "original_price": None + "original_price": None, + "meta_data": {} } @@ -1314,7 +1321,10 @@ def test_variations_create(token_client, organizer, event, item, variation): "position": 1, "default_price": None, "original_price": "23.42", - "price": 23.0 + "price": 23.0, + "meta_data": { + "day": "Wednesday", + }, }, format='json' ) @@ -1324,6 +1334,7 @@ def test_variations_create(token_client, organizer, event, item, variation): assert var.position == 1 assert var.price == 23.0 assert set(var.sales_channels) == set(get_all_sales_channels().keys()) + assert var.meta_data == {"day": "Wednesday"} @pytest.mark.django_db @@ -1355,6 +1366,7 @@ def test_variations_update(token_client, organizer, event, item, item3, variatio res["price"] = "20.00" res["default_price"] = "20.00" res["original_price"] = "50.00" + res["meta_data"] = {"day": "Thursday"} resp = token_client.patch( '/api/v1/organizers/{}/events/{}/items/{}/variations/{}/'.format(organizer.slug, event.slug, item.pk, variation.pk), { @@ -1364,7 +1376,10 @@ def test_variations_update(token_client, organizer, event, item, item3, variatio "position": 1, "sales_channels": ["web"], "default_price": "20.00", - "original_price": "50.00" + "original_price": "50.00", + "meta_data": { + "day": "Thursday", + }, }, format='json' ) diff --git a/src/tests/base/test_event_clone.py b/src/tests/base/test_event_clone.py index 3e7b15a0ea..8fbd9de6ee 100644 --- a/src/tests/base/test_event_clone.py +++ b/src/tests/base/test_event_clone.py @@ -73,6 +73,7 @@ def test_full_clone_same_organizer(): assert item1.meta_data item2 = event.items.create(category=category, tax_rule=tax_rule, name="T-shirt", default_price=15) item2v = item2.variations.create(value="red", default_price=15) + item2v.meta_values.create(property=item_meta, value="Bar") item2.require_membership_types.add(membership_type) ItemAddOn.objects.create(base_item=item1, addon_category=category) ItemBundle.objects.create(base_item=item1, bundled_item=item2, bundled_variation=item2v) @@ -147,6 +148,7 @@ def test_full_clone_same_organizer(): assert copied_item1.tax_rule == copied_event.tax_rules.get() assert copied_item1.category == copied_event.categories.get() assert copied_item1.meta_data == item1.meta_data + assert copied_item2.variations.get().meta_data == item2v.meta_data assert copied_item1.hidden_if_available == copied_q2 assert copied_item1.grant_membership_type == membership_type assert copied_item2.variations.count() == 1 diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index e36bbcfc1b..e07e079339 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -1962,6 +1962,32 @@ class ItemTest(TestCase): assert not Item.objects.filter_available().exists() assert Item.objects.filter_available(voucher=v).exists() + @classscope(attr='o') + def test_meta_data_inheritance(self): + prop = self.event.item_meta_properties.create(name="day", default="Monday") + i = Item.objects.create( + event=self.event, name="Ticket", default_price=23, + active=True, available_until=now() + timedelta(days=1), + ) + v = i.variations.create(value="Day 1") + + assert i.meta_data == {"day": "Monday"} + assert v.meta_data == {"day": "Monday"} + + i.meta_values.create(property=prop, value="Tuesday") + i = Item.objects.get(pk=i.pk) + v = ItemVariation.objects.get(pk=v.pk) + + assert i.meta_data == {"day": "Tuesday"} + assert v.meta_data == {"day": "Tuesday"} + + v.meta_values.create(property=prop, value="Wednesday") + i = Item.objects.get(pk=i.pk) + v = ItemVariation.objects.get(pk=v.pk) + + assert i.meta_data == {"day": "Tuesday"} + assert v.meta_data == {"day": "Wednesday"} + class EventTest(TestCase): @classmethod diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py index 5379802d42..0ee3b91301 100644 --- a/src/tests/control/test_items.py +++ b/src/tests/control/test_items.py @@ -637,6 +637,8 @@ class ItemsTest(ItemFormTest): q = Question.objects.create(event=self.event1, question="Size", type="N") q.items.add(self.item2) self.item2.sales_channels = ["web", "bar"] + prop = self.event1.item_meta_properties.create(name="Foo") + self.item2.meta_values.create(property=prop, value="Bar") self.client.post('/control/event/%s/%s/items/add' % (self.orga1.slug, self.event1.slug), { 'name_0': 'Intermediate', @@ -657,6 +659,7 @@ class ItemsTest(ItemFormTest): assert i_new.hide_without_voucher == i_old.hide_without_voucher assert i_new.allow_cancel == i_old.allow_cancel assert i_new.sales_channels == i_old.sales_channels + assert i_new.meta_data == i_old.meta_data == {"Foo": "Bar"} assert set(i_new.questions.all()) == set(i_old.questions.all()) assert set([str(v.value) for v in i_new.variations.all()]) == set([str(v.value) for v in i_old.variations.all()])