From 76aaf61e1905acd1e868e66539c61d1b390db554 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Wed, 26 Feb 2020 15:06:25 +0100 Subject: [PATCH] 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 --- doc/api/resources/events.rst | 12 ++++ doc/api/resources/items.rst | 10 +++ src/pretix/api/serializers/event.py | 53 ++++++++++++++- src/pretix/api/serializers/item.py | 56 +++++++++++++++- .../migrations/0143_auto_20200210_1038.py | 42 ++++++++++++ src/pretix/base/models/__init__.py | 6 +- src/pretix/base/models/event.py | 16 ++++- src/pretix/base/models/items.py | 67 ++++++++++++++++++- src/pretix/base/pdf.py | 2 + src/pretix/control/forms/event.py | 8 +++ src/pretix/control/forms/item.py | 27 +++++++- .../pretixcontrol/event/settings.html | 60 +++++++++++++++++ .../templates/pretixcontrol/item/index.html | 20 ++++++ .../templates/pretixcontrol/pdf/index.html | 5 ++ src/pretix/control/urls.py | 1 + src/pretix/control/views/event.py | 40 +++++++++-- src/pretix/control/views/item.py | 48 +++++++++++-- src/pretix/control/views/typeahead.py | 46 ++++++++++++- .../static/pretixcontrol/js/ui/editor.js | 4 +- src/tests/api/conftest.py | 1 + src/tests/api/test_events.py | 34 +++++++++- src/tests/api/test_items.py | 40 ++++++++++- 22 files changed, 573 insertions(+), 25 deletions(-) create mode 100644 src/pretix/base/migrations/0143_auto_20200210_1038.py diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index fc5638c9fe..a38b345c12 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -43,6 +43,7 @@ seating_plan integer If reserved sea seat_category_mapping object An object mapping categories of the seating plan (strings) to items in the event (integers or ``null``). timezone string Event timezone name +item_meta_properties object Item-specific meta data parameters and default values. ===================================== ========================== ======================================================= @@ -79,6 +80,10 @@ timezone string Event timezone The attribute ``timezone`` has been added. +.. versionchanged:: 3.6 + + The attribute ``item_meta_properties`` has been added. + Endpoints --------- @@ -133,6 +138,7 @@ Endpoints "seating_plan": null, "seat_category_mapping": {}, "timezone": "Europe/Berlin", + "item_meta_properties": {}, "plugins": [ "pretix.plugins.banktransfer" "pretix.plugins.stripe" @@ -204,6 +210,7 @@ Endpoints "seat_category_mapping": {}, "meta_data": {}, "timezone": "Europe/Berlin", + "item_meta_properties": {}, "plugins": [ "pretix.plugins.banktransfer" "pretix.plugins.stripe" @@ -256,6 +263,7 @@ Endpoints "has_subevents": false, "meta_data": {}, "timezone": "Europe/Berlin", + "item_meta_properties": {}, "plugins": [ "pretix.plugins.stripe", "pretix.plugins.paypal" @@ -290,6 +298,7 @@ Endpoints "has_subevents": false, "meta_data": {}, "timezone": "Europe/Berlin", + "item_meta_properties": {}, "plugins": [ "pretix.plugins.stripe", "pretix.plugins.paypal" @@ -344,6 +353,7 @@ Endpoints "has_subevents": false, "meta_data": {}, "timezone": "Europe/Berlin", + "item_meta_properties": {}, "plugins": [ "pretix.plugins.stripe", "pretix.plugins.paypal" @@ -378,6 +388,7 @@ Endpoints "seat_category_mapping": {}, "meta_data": {}, "timezone": "Europe/Berlin", + "item_meta_properties": {}, "plugins": [ "pretix.plugins.stripe", "pretix.plugins.paypal" @@ -444,6 +455,7 @@ Endpoints "seat_category_mapping": {}, "meta_data": {}, "timezone": "Europe/Berlin", + "item_meta_properties": {}, "plugins": [ "pretix.plugins.banktransfer", "pretix.plugins.stripe", diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 617adc8824..1f8d83eb14 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -114,6 +114,7 @@ bundles list of objects Definition of b └ designated_price money (string) Designated price of the bundled product. This will be used to split the price of the base item e.g. for mixed taxation. This is not added to the price. +meta_data object Values set for event-specific meta data parameters. ===================================== ========================== ======================================================= .. versionchanged:: 2.7 @@ -154,6 +155,10 @@ bundles list of objects Definition of b The ``show_quota_left``, ``allow_waitinglist``, and ``hidden_if_available`` attributes have been added. +.. versionchanged:: 3.6 + + The attribute ``meta_data`` has been added. + Notes ----- @@ -208,6 +213,7 @@ Endpoints "tax_rule": 1, "admission": false, "issue_giftcard": false, + "meta_data": {}, "position": 0, "picture": null, "available_from": null, @@ -303,6 +309,7 @@ Endpoints "tax_rule": 1, "admission": false, "issue_giftcard": false, + "meta_data": {}, "position": 0, "picture": null, "available_from": null, @@ -379,6 +386,7 @@ Endpoints "tax_rule": 1, "admission": false, "issue_giftcard": false, + "meta_data": {}, "position": 0, "picture": null, "available_from": null, @@ -442,6 +450,7 @@ Endpoints "tax_rule": 1, "admission": false, "issue_giftcard": false, + "meta_data": {}, "position": 0, "picture": null, "available_from": null, @@ -537,6 +546,7 @@ Endpoints "tax_rule": 1, "admission": false, "issue_giftcard": false, + "meta_data": {}, "position": 0, "picture": null, "available_from": null, diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 2dc80fa858..21be19d819 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -34,6 +34,19 @@ class MetaDataField(Field): } +class MetaPropertyField(Field): + + def to_representation(self, value): + return { + v.name: v.default for v in value.item_meta_properties.all() + } + + def to_internal_value(self, data): + return { + 'item_meta_properties': data + } + + class SeatCategoryMappingField(Field): def to_representation(self, value): @@ -77,6 +90,7 @@ class TimeZoneField(ChoiceField): class EventSerializer(I18nAwareModelSerializer): meta_data = MetaDataField(required=False, source='*') + item_meta_properties = MetaPropertyField(required=False, source='*') plugins = PluginsField(required=False, source='*') seat_category_mapping = SeatCategoryMappingField(source='*', required=False) timezone = TimeZoneField(required=False, choices=[(a, a) for a in common_timezones]) @@ -86,7 +100,7 @@ class EventSerializer(I18nAwareModelSerializer): fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from', 'date_to', 'date_admission', 'is_public', 'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan', - 'plugins', 'seat_category_mapping', 'timezone') + 'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties') def validate(self, data): data = super().validate(data) @@ -131,6 +145,12 @@ class EventSerializer(I18nAwareModelSerializer): raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key)) return value + @cached_property + def item_meta_props(self): + return { + p.name: p for p in self.context['request'].event.item_meta_properties.all() + } + def validate_seating_plan(self, value): if value and value.organizer != self.context['request'].organizer: raise ValidationError('Invalid seating plan.') @@ -172,6 +192,7 @@ class EventSerializer(I18nAwareModelSerializer): @transaction.atomic def create(self, validated_data): meta_data = validated_data.pop('meta_data', None) + item_meta_properties = validated_data.pop('item_meta_properties', None) validated_data.pop('seat_category_mapping', None) plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(',')) tz = validated_data.pop('timezone', None) @@ -188,6 +209,15 @@ class EventSerializer(I18nAwareModelSerializer): value=value ) + # Item Meta properties + if item_meta_properties is not None: + for key, value in item_meta_properties.items(): + event.item_meta_properties.create( + name=key, + default=value, + event=event + ) + # Seats if event.seating_plan: generate_seats(event, None, event.seating_plan, {}) @@ -202,6 +232,7 @@ class EventSerializer(I18nAwareModelSerializer): @transaction.atomic def update(self, instance, validated_data): meta_data = validated_data.pop('meta_data', None) + item_meta_properties = validated_data.pop('item_meta_properties', None) plugins = validated_data.pop('plugins', None) seat_category_mapping = validated_data.pop('seat_category_mapping', None) tz = validated_data.pop('timezone', None) @@ -228,6 +259,26 @@ class EventSerializer(I18nAwareModelSerializer): if prop.name not in meta_data: current_object.delete() + # Item Meta properties + if item_meta_properties is not None: + current = [imp for imp in event.item_meta_properties.all()] + for key, value in item_meta_properties.items(): + prop = self.item_meta_props.get(key) + if prop in current: + prop.default = value + prop.save() + else: + prop = event.item_meta_properties.create( + name=key, + default=value, + event=event + ) + current.append(prop) + + for prop in current: + if prop.name not in list(item_meta_properties.keys()): + prop.delete() + # Seats if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None): current_mappings = { diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 1b049288f4..2901a07b97 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -2,13 +2,15 @@ from decimal import Decimal from django.core.exceptions import ValidationError from django.db import transaction +from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers +from pretix.api.serializers.event import MetaDataField from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.models import ( - Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question, - QuestionOption, Quota, + Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation, + Question, QuestionOption, Quota, ) @@ -110,6 +112,7 @@ class ItemSerializer(I18nAwareModelSerializer): bundles = InlineItemBundleSerializer(many=True, required=False) variations = InlineItemVariationSerializer(many=True, required=False) tax_rate = ItemTaxRateField(source='*', read_only=True) + meta_data = MetaDataField(required=False, source='*') class Meta: model = Item @@ -119,7 +122,7 @@ class ItemSerializer(I18nAwareModelSerializer): 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', - 'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard') + 'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data') read_only_fields = ('has_variations', 'picture') def validate(self, data): @@ -167,18 +170,65 @@ class ItemSerializer(I18nAwareModelSerializer): ItemAddOn.clean_max_min_count(addon_data['max_count'], addon_data['min_count']) return value + @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 + @transaction.atomic def create(self, validated_data): variations_data = validated_data.pop('variations') if 'variations' in validated_data else {} addons_data = validated_data.pop('addons') if 'addons' in validated_data else {} bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {} + meta_data = validated_data.pop('meta_data', None) item = Item.objects.create(**validated_data) + for variation_data in variations_data: ItemVariation.objects.create(item=item, **variation_data) for addon_data in addons_data: ItemAddOn.objects.create(base_item=item, **addon_data) for bundle_data in bundles_data: ItemBundle.objects.create(base_item=item, **bundle_data) + + # Meta data + if meta_data is not None: + for key, value in meta_data.items(): + ItemMetaValue.objects.create( + property=self.item_meta_properties.get(key), + value=value, + item=item + ) + return item + + def update(self, instance, validated_data): + meta_data = validated_data.pop('meta_data', None) + item = super().update(instance, validated_data) + + # Meta data + if meta_data is not None: + current = {mv.property: mv for mv in item.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: + item.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 item diff --git a/src/pretix/base/migrations/0143_auto_20200210_1038.py b/src/pretix/base/migrations/0143_auto_20200210_1038.py new file mode 100644 index 0000000000..658077b418 --- /dev/null +++ b/src/pretix/base/migrations/0143_auto_20200210_1038.py @@ -0,0 +1,42 @@ +# Generated by Django 2.2.8 on 2020-02-10 10:38 + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0142_auto_20191215_1522'), + ] + + operations = [ + migrations.CreateModel( + name='ItemMetaProperty', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(db_index=True, max_length=50)), + ('default', models.TextField()), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_meta_properties', to='pretixbase.Event')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.CreateModel( + name='ItemMetaValue', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('value', models.TextField()), + ('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meta_values', to='pretixbase.Item')), + ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='item_values', to='pretixbase.ItemMetaProperty')), + ], + options={ + 'unique_together': {('item', '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 2793d22b23..a980d6aefc 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -10,9 +10,9 @@ from .event import ( from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction from .invoices import Invoice, InvoiceLine, invoice_filename from .items import ( - Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Question, - QuestionOption, Quota, SubEventItem, SubEventItemVariation, - itempicture_upload_to, + Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue, + ItemVariation, Question, QuestionOption, Quota, SubEventItem, + SubEventItemVariation, itempicture_upload_to, ) from .log import LogEntry from .notifications import NotificationSetting diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 6405f9a62d..df968fbc79 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -515,7 +515,7 @@ class Event(EventMixin, LoggedModel): ), tz) def copy_data_from(self, other): - from . import ItemAddOn, ItemCategory, Item, Question, Quota + from . import ItemAddOn, ItemCategory, Item, Question, Quota, ItemMetaValue from ..signals import event_copy_data self.plugins = other.plugins @@ -540,6 +540,14 @@ class Event(EventMixin, LoggedModel): c.save() c.log_action('pretix.object.cloned') + item_meta_properties_map = {} + for imp in other.item_meta_properties.all(): + item_meta_properties_map[imp.pk] = imp + imp.pk = None + imp.event = self + imp.save() + imp.log_action('pretix.object.cloned') + item_map = {} variation_map = {} for i in Item.objects.filter(event=other).prefetch_related('variations'): @@ -561,6 +569,12 @@ class Event(EventMixin, LoggedModel): v.item = i v.save() + for imv in ItemMetaValue.objects.filter(item__event=other).prefetch_related('item', 'property'): + imv.pk = None + imv.property = item_meta_properties_map[imv.property.pk] + imv.item = item_map[imv.item.pk] + imv.save() + 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 06eb9aecb1..b72bd97b90 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -1,6 +1,6 @@ import sys import uuid -from collections import Counter +from collections import Counter, OrderedDict from datetime import date, datetime, time from decimal import Decimal, DecimalException from typing import Tuple @@ -9,6 +9,7 @@ import dateutil.parser import pytz from django.conf import settings from django.core.exceptions import ValidationError +from django.core.validators import RegexValidator from django.db import models from django.db.models import F, Func, Q, Sum from django.utils import formats @@ -591,6 +592,16 @@ class Item(LoggedModel): if from_date > until_date: raise ValidationError(_('The item\'s availability cannot end before it starts.')) + @property + def meta_data(self): + data = {p.name: p.default for p in self.event.item_meta_properties.all()} + 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 ItemVariation(models.Model): """ @@ -1541,3 +1552,57 @@ class Quota(LoggedModel): else: if subevent: raise ValidationError(_('The subevent does not belong to this event.')) + + +class ItemMetaProperty(LoggedModel): + """ + An event can have ItemMetaProperty objects attached to define meta information fields + for its items. This information can be re-used for example in ticket layouts. + + :param event: The event this property is defined for. + :type event: Event + :param name: Name + :type name: Name of the property, used in various places + :param default: Default value + :type default: str + """ + event = models.ForeignKey(Event, related_name="item_meta_properties", on_delete=models.CASCADE) + name = models.CharField( + max_length=50, db_index=True, + help_text=_( + "Can not contain spaces or special characters except underscores" + ), + validators=[ + RegexValidator( + regex="^[a-zA-Z0-9_]+$", + message=_("The property name may only contain letters, numbers and underscores."), + ), + ], + verbose_name=_("Name"), + ) + default = models.TextField(blank=True) + + +class ItemMetaValue(LoggedModel): + """ + A meta-data value assigned to an item. + + :param item: The item this metadata is valid for + :type item: Item + :param property: The property this value belongs to + :type property: ItemMetaProperty + :param value: The actual value + :type value: str + """ + item = models.ForeignKey('Item', on_delete=models.CASCADE, related_name='meta_values') + property = models.ForeignKey('ItemMetaProperty', on_delete=models.CASCADE, related_name='item_values') + value = models.TextField() + + class Meta: + unique_together = ('item', 'property') + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index b2100a0f2a..e41f59b33f 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -431,6 +431,8 @@ class Renderer: return '(error)' if o['content'] == 'other': return o['text'] + elif o['content'].startswith('itemmeta:'): + return op.item.meta_data.get(o['content'][9:]) or '' elif o['content'].startswith('meta:'): return ev.meta_data.get(o['content'][5:]) or '' elif o['content'] in self.variables: diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 361de3e505..72927bb05b 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -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() + } diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index f34d8f8af0..bc40589024 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -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() + } diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index f5db495467..f94418cd8c 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -3,6 +3,7 @@ {% load bootstrap3 %} {% load static %} {% load hierarkey_form %} +{% load formset_tags %} {% block custom_header %} {{ block.super }} @@ -139,6 +140,65 @@ {% bootstrap_field sform.waiting_list_auto layout="control" %} {% bootstrap_field sform.waiting_list_hours layout="control" %} +
+ {% trans "Item metadata" %} +

+ {% 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 %} +

+
+ {{ formset.management_form }} + {% bootstrap_formset_errors formset %} +
+ {% for form in formset %} +
+
+ {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} +
+
+ {% bootstrap_form_errors form %} + {% bootstrap_field form.name layout='inline' form_group_class="" %} +
+
+ {% bootstrap_field form.default layout='inline' form_group_class="" %} +
+
+ +
+
+ {% endfor %} +
+ +

+ +

+
+