Add meta_data for items (#1576)

* PoC for ItemMetaProperties/Values

* Missing is_valid

* ItemMetaProperties/Values in editable via API, cloneable

* Tests

* Add Docs

* Fix import order

* Fix another import sorting...

* Typeahead for ItemMetaValues

* Test for editing event-objects

* Fix typeahead permission checks

* Further access restriction

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
This commit is contained in:
Martin Gross
2020-02-26 15:06:25 +01:00
committed by GitHub
parent dd1e5fa929
commit 76aaf61e19
22 changed files with 573 additions and 25 deletions

View File

@@ -1203,3 +1203,11 @@ QuickSetupProductFormSet = formset_factory(
formset=BaseQuickSetupProductFormSet,
can_order=False, can_delete=True, extra=0
)
class ItemMetaPropertyForm(forms.ModelForm):
class Meta:
fields = ['name', 'default']
widgets = {
'default': forms.TextInput()
}

View File

@@ -1,4 +1,5 @@
from decimal import Decimal
from urllib.parse import urlencode
from django import forms
from django.core.exceptions import ValidationError
@@ -18,7 +19,7 @@ from pretix.base.forms import I18nFormSet, I18nModelForm
from pretix.base.models import (
Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota,
)
from pretix.base.models.items import ItemAddOn, ItemBundle
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2
@@ -756,3 +757,27 @@ class ItemBundleForm(I18nModelForm):
'count',
'designated_price',
]
class ItemMetaValueForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
self.property = kwargs.pop('property')
super().__init__(*args, **kwargs)
self.fields['value'].required = False
self.fields['value'].widget.attrs['placeholder'] = self.property.default
self.fields['value'].widget.attrs['data-typeahead-url'] = (
reverse('control:event.items.meta.typeahead', kwargs={
'organizer': self.property.event.organizer.slug,
'event': self.property.event.slug
}) + '?' + urlencode({
'property': self.property.name,
})
)
class Meta:
model = ItemMetaValue
fields = ['value']
widgets = {
'value': forms.TextInput()
}

View File

@@ -3,6 +3,7 @@
{% load bootstrap3 %}
{% load static %}
{% load hierarkey_form %}
{% load formset_tags %}
{% block custom_header %}
{{ block.super }}
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
@@ -139,6 +140,65 @@
{% bootstrap_field sform.waiting_list_auto layout="control" %}
{% bootstrap_field sform.waiting_list_hours layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Item metadata" %}</legend>
<p>
{% blocktrans trimmed %}
You can here define a set of metadata properties (i.e. variables) that you can later set for your
items and re-use in places like ticket layouts. This is an useful timesaver if you create lots and
lots of items.
{% endblocktrans %}
</p>
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div class="row" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-md-5">
{% bootstrap_form_errors form %}
{% bootstrap_field form.name layout='inline' form_group_class="" %}
</div>
<div class="col-md-5 col-lg-6">
{% bootstrap_field form.default layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 col-lg-1 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="row" data-formset-form>
<div class="sr-only">
{{ formset.empty_form.id }}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-md-5">
{% bootstrap_field formset.empty_form.name layout='inline' form_group_class="" %}
</div>
<div class="col-md-5 col-lg-6">
{% bootstrap_field formset.empty_form.default layout='inline' form_group_class="" %}
</div>
<div class="col-md-2 col-lg-1 text-right flip">
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add property" %}</button>
</p>
</div>
</fieldset>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">

View File

@@ -20,6 +20,26 @@
{% bootstrap_field form.description layout="control" %}
{% bootstrap_field form.picture layout="control" %}
{% bootstrap_field form.require_approval layout="control" %}
{% if meta_forms %}
<div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Meta data" %}</label>
<div class="col-md-9">
{% for form in meta_forms %}
<div class="row">
<div class="col-md-4">
<label for="{{ form.value.id_for_label }}">
{{ form.property.name }}
</label>
</div>
<div class="col-md-8">
{% bootstrap_form form layout="inline" %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Price" %}</legend>

View File

@@ -344,6 +344,11 @@
{% trans "Event attribute:" %} {{ p.name }}
</option>
{% endfor %}
{% for p in request.event.item_meta_properties.all %}
<option value="itemmeta:{{ p.name }}">
{% trans "Item attribute:" %} {{ p.name }}
</option>
{% endfor %}
<option value="other">{% trans "Other…" %}</option>
</select>
<textarea type="text" value="" class="input-block-level form-control"

View File

@@ -159,6 +159,7 @@ urlpatterns = [
url(r'^items/(?P<item>\d+)/up$', item.item_move_up, name='event.items.up'),
url(r'^items/(?P<item>\d+)/down$', item.item_move_down, name='event.items.down'),
url(r'^items/(?P<item>\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'),
url(r'^items/typeahead/meta/$', typeahead.item_meta_values, name='event.items.meta.typeahead'),
url(r'^categories/$', item.CategoryList.as_view(), name='event.items.categories'),
url(r'^categories/select2$', typeahead.category_select2, name='event.items.categories.select2'),
url(r'^categories/(?P<category>\d+)/delete$', item.CategoryDelete.as_view(),

View File

@@ -11,6 +11,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.files import File
from django.db import transaction
from django.db.models import ProtectedError
from django.forms import inlineformset_factory
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed,
JsonResponse,
@@ -39,9 +40,9 @@ from pretix.base.signals import register_ticket_outputs
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.control.forms.event import (
CancelSettingsForm, CommentForm, EventDeleteForm, EventMetaValueForm,
EventSettingsForm, EventUpdateForm, InvoiceSettingsForm, MailSettingsForm,
PaymentSettingsForm, ProviderForm, QuickSetupForm,
QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet,
EventSettingsForm, EventUpdateForm, InvoiceSettingsForm,
ItemMetaPropertyForm, MailSettingsForm, PaymentSettingsForm, ProviderForm,
QuickSetupForm, QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet,
TicketSettingsForm, WidgetCodeForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
@@ -51,6 +52,7 @@ from pretix.multidomain.urlreverse import get_domain
from pretix.plugins.stripe.payment import StripeSettingsHolder
from pretix.presale.style import regenerate_css
from ...base.models.items import ItemMetaProperty
from ..logdisplay import OVERVIEW_BANLIST
from . import CreateView, PaginationMixin, UpdateView
@@ -137,6 +139,7 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired
context = super().get_context_data(*args, **kwargs)
context['sform'] = self.sform
context['meta_forms'] = self.meta_forms
context['formset'] = self.formset
return context
@transaction.atomic
@@ -144,6 +147,7 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired
self._save_decoupled(self.sform)
self.sform.save()
self.save_meta()
self.save_formset(self.object)
change_css = False
if self.sform.has_changed():
@@ -183,7 +187,8 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid() and self.sform.is_valid() and all([f.is_valid() for f in self.meta_forms]):
if form.is_valid() and self.sform.is_valid() and all([f.is_valid() for f in self.meta_forms]) and \
self.formset.is_valid():
# reset timezone
zone = timezone(self.sform.cleaned_data['timezone'])
event = form.instance
@@ -200,6 +205,33 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired
def reset_timezone(tz, dt):
return tz.localize(dt.replace(tzinfo=None)) if dt is not None else None
@cached_property
def formset(self):
formsetclass = inlineformset_factory(
Event, ItemMetaProperty,
form=ItemMetaPropertyForm, can_order=False, can_delete=True, extra=0
)
return formsetclass(self.request.POST if self.request.method == "POST" else None,
instance=self.object, queryset=self.object.item_meta_properties.all())
def save_formset(self, obj):
for form in self.formset.initial_forms:
if form in self.formset.deleted_forms:
if not form.instance.pk:
continue
form.instance.delete()
form.instance.pk = None
elif form.has_changed():
form.save()
for form in self.formset.extra_forms:
if not form.has_changed():
continue
if self.formset._should_delete_form(form):
continue
form.instance.event = obj
form.save()
class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, TemplateView, SingleObjectMixin):
model = Event

View File

@@ -30,13 +30,14 @@ from pretix.base.models import (
QuestionAnswer, QuestionOption, Quota, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemAddOn, ItemBundle
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability
from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemUpdateForm, ItemVariationForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemUpdateForm,
ItemVariationForm, ItemVariationsFormSet, QuestionForm, QuestionOptionForm,
QuotaForm,
)
from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required,
@@ -939,6 +940,41 @@ class ItemDetailMixin(SingleObjectMixin):
raise Http404(_("The requested item does not exist."))
class MetaDataEditorMixin:
meta_form = ItemMetaValueForm
meta_model = ItemMetaValue
@cached_property
def meta_forms(self):
if hasattr(self, 'object') and self.object:
val_instances = {
v.property_id: v for v in self.object.meta_values.all()
}
else:
val_instances = {}
formlist = []
for p in self.request.event.item_meta_properties.all():
formlist.append(self._make_meta_form(p, val_instances))
return formlist
def _make_meta_form(self, p, val_instances):
return self.meta_form(
prefix='prop-{}'.format(p.pk),
property=p,
instance=val_instances.get(p.pk, self.meta_model(property=p, item=self.object)),
data=(self.request.POST if self.request.method == "POST" else None)
)
def save_meta(self):
for f in self.meta_forms:
if f.cleaned_data.get('value'):
f.save()
elif f.instance and f.instance.pk:
f.instance.delete()
class ItemCreate(EventPermissionRequiredMixin, CreateView):
form_class = ItemCreateForm
template_name = 'pretixcontrol/item/create.html'
@@ -985,7 +1021,7 @@ class ItemCreate(EventPermissionRequiredMixin, CreateView):
return super().form_invalid(form)
class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateView):
class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataEditorMixin, UpdateView):
form_class = ItemUpdateForm
template_name = 'pretixcontrol/item/index.html'
permission = 'can_change_items'
@@ -1038,7 +1074,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie
def post(self, request, *args, **kwargs):
self.get_object()
form = self.get_form()
if self.is_valid(form):
if self.is_valid(form) and all([f.is_valid() for f in self.meta_forms]):
return self.form_valid(form)
else:
return self.form_invalid(form)
@@ -1088,6 +1124,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie
@transaction.atomic
def form_valid(self, form):
self.save_meta()
messages.success(self.request, _('Your changes have been saved.'))
if form.has_changed() or any(f.has_changed() for f in self.plugin_forms):
data = {
@@ -1137,6 +1174,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['plugin_forms'] = self.plugin_forms
ctx['meta_forms'] = self.meta_forms
ctx['formsets'] = self.formsets
if not ctx['item'].active and ctx['item'].bundled_with.count() > 0:

View File

@@ -13,7 +13,8 @@ from django.utils.timezone import make_aware
from django.utils.translation import pgettext, ugettext as _
from pretix.base.models import (
EventMetaProperty, EventMetaValue, Order, Organizer, User, Voucher,
EventMetaProperty, EventMetaValue, ItemMetaProperty, ItemMetaValue, Order,
Organizer, User, Voucher,
)
from pretix.control.forms.event import EventWizardCopyForm
from pretix.control.permissions import event_permission_required
@@ -589,3 +590,46 @@ def meta_values(request):
for v in sorted(set(defaults.values_list('default', flat=True)[:10]) | set(matches.values_list('value', flat=True)[:10]))
]
})
def item_meta_values(request, organizer, event):
q = request.GET.get('q')
propname = request.GET.get('property')
matches = ItemMetaValue.objects.filter(
value__icontains=q,
property__name=propname
)
defaults = ItemMetaProperty.objects.filter(
name=propname,
default__icontains=q
)
organizer = get_object_or_404(Organizer, slug=organizer)
if not request.user.has_organizer_permission(organizer, request=request):
raise PermissionDenied()
defaults = defaults.filter(event__organizer_id=organizer.pk)
matches = matches.filter(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()
)
if not all_access:
defaults = matches.filter(
event__id__in=request.user.teams.filter(can_change_items=True).values_list(
'limit_events__id', flat=True
)
)
matches = matches.filter(
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]))
]
})