diff --git a/src/requirements.txt b/src/requirements.txt
index d8316ae4d..1603d8e83 100644
--- a/src/requirements.txt
+++ b/src/requirements.txt
@@ -1,16 +1,24 @@
+# Functional requirements
Django>=1.7
pytz
django-bootstrap3
+-e git+https://github.com/tixl/django-formset-js.git@master#egg=django-formset-js
+
+# Deployment / static file compilation requirements
django-compressor
BeautifulSoup4
html5lib
slimit
lxml
+
+# Debugging requirements
+django-debug-toolbar
+
+# Testing requirements
pyflakes
pep8
pep8-naming
flake8
-django-debug-toolbar
coveralls
coverage
diff --git a/src/tixl/settings.py b/src/tixl/settings.py
index 443f64fec..8f6a6cc50 100644
--- a/src/tixl/settings.py
+++ b/src/tixl/settings.py
@@ -42,6 +42,7 @@ INSTALLED_APPS = (
'compressor',
'bootstrap3',
'debug_toolbar.apps.DebugToolbarConfig',
+ 'djangoformsetjs',
)
MIDDLEWARE_CLASSES = (
diff --git a/src/tixlbase/migrations/0013_propertyvalue_position.py b/src/tixlbase/migrations/0013_propertyvalue_position.py
new file mode 100644
index 000000000..77b5091e9
--- /dev/null
+++ b/src/tixlbase/migrations/0013_propertyvalue_position.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import models, migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('tixlbase', '0012_auto_20140929_1935'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='propertyvalue',
+ name='position',
+ field=models.IntegerField(default=0),
+ preserve_default=True,
+ ),
+ ]
diff --git a/src/tixlbase/models.py b/src/tixlbase/models.py
index ac92270fd..434068051 100644
--- a/src/tixlbase/models.py
+++ b/src/tixlbase/models.py
@@ -393,10 +393,18 @@ class PropertyValue(models.Model):
max_length=250,
verbose_name=_("Value"),
)
+ position = models.IntegerField(
+ default=0
+ )
def __str__(self):
return "%s: %s" % (self.prop.name, self.value)
+ class Meta:
+ verbose_name = _("Property value")
+ verbose_name_plural = _("Property values")
+ ordering = ("position",)
+
class Item(models.Model):
"""
diff --git a/src/tixlcontrol/static/tixlcontrol/js/ui/main.js b/src/tixlcontrol/static/tixlcontrol/js/ui/main.js
new file mode 100644
index 000000000..f771defe2
--- /dev/null
+++ b/src/tixlcontrol/static/tixlcontrol/js/ui/main.js
@@ -0,0 +1,6 @@
+$(function() {
+ $("[data-formset]").formset({
+ animateForms: true,
+ reorderMode: 'animate',
+ });
+});
diff --git a/src/tixlcontrol/static/tixlcontrol/less/forms.less b/src/tixlcontrol/static/tixlcontrol/less/forms.less
index 46581d7a3..03f9f34e0 100644
--- a/src/tixlcontrol/static/tixlcontrol/less/forms.less
+++ b/src/tixlcontrol/static/tixlcontrol/less/forms.less
@@ -14,3 +14,13 @@ td > .form-group > .checkbox {
.has-success .form-control {
border-color: #cccccc;
}
+.form-horizontal [data-formset] .form-group {
+ width: 100%;
+}
+[data-formset] .form-group:not([data-formset-form-deleted]):last-of-type [data-formset-move-down-button],
+[data-formset] .form-group:not([data-formset-form-deleted]):first-of-type [data-formset-move-up-button] {
+ cursor: not-allowed;
+ pointer-events: none; // Future-proof disabling of clicks
+ .opacity(.65);
+ .box-shadow(none);
+}
diff --git a/src/tixlcontrol/templates/tixlcontrol/base.html b/src/tixlcontrol/templates/tixlcontrol/base.html
index b784c28a3..fd6121bed 100644
--- a/src/tixlcontrol/templates/tixlcontrol/base.html
+++ b/src/tixlcontrol/templates/tixlcontrol/base.html
@@ -10,7 +10,9 @@
{% endcompress %}
{% compress js %}
+
+
{% endcompress %}
diff --git a/src/tixlcontrol/templates/tixlcontrol/items/index.html b/src/tixlcontrol/templates/tixlcontrol/items/index.html
index d4994706d..66ab70fff 100644
--- a/src/tixlcontrol/templates/tixlcontrol/items/index.html
+++ b/src/tixlcontrol/templates/tixlcontrol/items/index.html
@@ -7,12 +7,14 @@
| {% trans "Item name" %} |
+ {% trans "Category" %} |
{% for i in items %}
| {{ i.name }} |
+ {{ i.category }} |
{% endfor %}
diff --git a/src/tixlcontrol/templates/tixlcontrol/items/properties.html b/src/tixlcontrol/templates/tixlcontrol/items/properties.html
new file mode 100644
index 000000000..57cc9c1dd
--- /dev/null
+++ b/src/tixlcontrol/templates/tixlcontrol/items/properties.html
@@ -0,0 +1,38 @@
+{% extends "tixlcontrol/items/base.html" %}
+{% load i18n %}
+{% block title %}{% trans "Item properties" %}{% endblock %}
+{% block inside %}
+ {% trans "Item properties" %}
+ {% if "updated" in request.GET %}
+
+ {% trans "Your changes have been saved." %}
+
+ {% elif "created" in request.GET %}
+
+ {% trans "A new property has been created." %}
+
+ {% elif "deleted" in request.GET %}
+
+ {% trans "The property has been deleted." %}
+
+ {% endif %}
+
+ {% trans "Create new property" %}
+
+
+
+
+ | {% trans "Item properties" %} |
+ |
+
+
+
+ {% for p in properties %}
+
+ | {{ p.name }} |
+ |
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/src/tixlcontrol/templates/tixlcontrol/items/property.html b/src/tixlcontrol/templates/tixlcontrol/items/property.html
new file mode 100644
index 000000000..4a17a3420
--- /dev/null
+++ b/src/tixlcontrol/templates/tixlcontrol/items/property.html
@@ -0,0 +1,72 @@
+{% extends "tixlcontrol/items/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% load formset_tags %}
+{% block title %}{% trans "Item property" %}{% endblock %}
+{% block inside %}
+ {% trans "Item property" %}
+
+{% endblock %}
diff --git a/src/tixlcontrol/templates/tixlcontrol/items/property_delete.html b/src/tixlcontrol/templates/tixlcontrol/items/property_delete.html
new file mode 100644
index 000000000..3973942e6
--- /dev/null
+++ b/src/tixlcontrol/templates/tixlcontrol/items/property_delete.html
@@ -0,0 +1,30 @@
+{% extends "tixlcontrol/items/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% block title %}{% trans "Delete item property" %}{% endblock %}
+{% block inside %}
+ {% trans "Delete item property" %}
+ {% if not possible %}
+ {% blocktrans %}You can not delete the property {{ property }} as long as the following items use it:{% endblocktrans %}
+
+ {% else %}
+
+ {% endif %}
+{% endblock %}
diff --git a/src/tixlcontrol/urls.py b/src/tixlcontrol/urls.py
index d029c79ff..cb1945eb2 100644
--- a/src/tixlcontrol/urls.py
+++ b/src/tixlcontrol/urls.py
@@ -29,6 +29,9 @@ urlpatterns += patterns(
url(r'^category/(?P\d+)/$', item.CategoryUpdate.as_view(), name='event.items.categories.edit'),
url(r'^category/add$', item.CategoryCreate.as_view(), name='event.items.categories.add'),
url(r'^properties$', item.PropertyList.as_view(), name='event.items.properties'),
+ url(r'^properties/(?P\d+)/$', item.PropertyUpdate.as_view(), name='event.items.properties.edit'),
+ url(r'^properties/(?P\d+)/delete$', item.PropertyDelete.as_view(), name='event.items.properties.delete'),
+ url(r'^properties/add$', item.PropertyCreate.as_view(), name='event.items.properties.add'),
)
))
)
diff --git a/src/tixlcontrol/views/forms.py b/src/tixlcontrol/views/forms.py
new file mode 100644
index 000000000..176a67c8e
--- /dev/null
+++ b/src/tixlcontrol/views/forms.py
@@ -0,0 +1,32 @@
+from django import forms
+
+
+class TolerantFormsetModelForm(forms.ModelForm):
+
+ def has_changed(self):
+ """
+ Returns True if data differs from initial. Contrary to the default
+ implementation, the ORDER field is being ignored.
+ """
+ for name, field in self.fields.items():
+ if name == 'ORDER':
+ continue
+ prefixed_name = self.add_prefix(name)
+ data_value = field.widget.value_from_datadict(self.data, self.files, prefixed_name)
+ if not field.show_hidden_initial:
+ initial_value = self.initial.get(name, field.initial)
+ if callable(initial_value):
+ initial_value = initial_value()
+ else:
+ initial_prefixed_name = self.add_initial_prefix(name)
+ hidden_widget = field.hidden_widget()
+ try:
+ initial_value = field.to_python(hidden_widget.value_from_datadict(
+ self.data, self.files, initial_prefixed_name))
+ except forms.ValidationError:
+ # Always assume data has changed if validation fails.
+ self._changed_data.append(name)
+ continue
+ if field._has_changed(initial_value, data_value):
+ return True
+ return False
diff --git a/src/tixlcontrol/views/item.py b/src/tixlcontrol/views/item.py
index 75793aed5..5d12e161b 100644
--- a/src/tixlcontrol/views/item.py
+++ b/src/tixlcontrol/views/item.py
@@ -5,13 +5,14 @@ from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.views.generic.base import TemplateView
from django.views.generic.detail import SingleObjectMixin
from django.core.urlresolvers import resolve, reverse
-from django.http import HttpResponseRedirect
+from django.http import HttpResponseRedirect, HttpResponseForbidden
from django import forms
-from django.db.models import Q
from django.shortcuts import redirect
+from django.forms.models import inlineformset_factory
-from tixlbase.models import Item, ItemCategory, Property, ItemVariation
+from tixlbase.models import Item, ItemCategory, Property, ItemVariation, PropertyValue
from tixlcontrol.permissions import EventPermissionRequiredMixin, event_permission_required
+from tixlcontrol.views.forms import TolerantFormsetModelForm
class ItemList(ListView):
@@ -22,7 +23,7 @@ class ItemList(ListView):
def get_queryset(self):
return Item.objects.filter(
event=self.request.event
- )
+ ).prefetch_related("category")
class CategoryForm(forms.ModelForm):
@@ -147,8 +148,8 @@ def category_move_down(request, organizer, event, category):
class PropertyList(ListView):
model = Property
- context_object_name = 'items'
- template_name = 'tixlcontrol/items/index.html'
+ context_object_name = 'properties'
+ template_name = 'tixlcontrol/items/properties.html'
def get_queryset(self):
return Property.objects.filter(
@@ -156,6 +157,164 @@ class PropertyList(ListView):
)
+class PropertyForm(forms.ModelForm):
+ class Meta:
+ model = Property
+ localized_fields = '__all__'
+ fields = [
+ 'name',
+ ]
+
+
+class PropertyValueForm(TolerantFormsetModelForm):
+ class Meta:
+ model = PropertyValue
+ localized_fields = '__all__'
+ fields = [
+ 'value',
+ ]
+
+
+class PropertyUpdate(EventPermissionRequiredMixin, UpdateView):
+ model = Property
+ form_class = PropertyForm
+ template_name = 'tixlcontrol/items/property.html'
+ permission = 'can_change_items'
+ context_object_name = 'property'
+
+ def get_object(self, queryset=None):
+ url = resolve(self.request.path_info)
+ return self.request.event.properties.get(
+ id=url.kwargs['property']
+ )
+
+ def get_success_url(self):
+ url = resolve(self.request.path_info)
+ return reverse('control:event.items.properties.edit', kwargs={
+ 'organizer': self.request.event.organizer.slug,
+ 'event': self.request.event.slug,
+ 'property': url.kwargs['property']
+ }) + '?success=true'
+
+ def get_formset(self):
+ formsetclass = inlineformset_factory(
+ Property, PropertyValue,
+ form=PropertyValueForm,
+ can_order=True,
+ extra=0,
+ )
+ formset = formsetclass(**self.get_form_kwargs())
+ return formset
+
+ def get_context_data(self, *args, **kwargs):
+ context = super().get_context_data(*args, **kwargs)
+ context['formset'] = self.get_formset()
+ return context
+
+ def form_valid(self, form, formset):
+ for i, f in enumerate(formset.ordered_forms):
+ f.instance.position = i
+ formset.save()
+ return super().form_valid(form)
+
+ def post(self, request, *args, **kwargs):
+ self.object = self.get_object()
+ form_class = self.get_form_class()
+ form = self.get_form(form_class)
+ formset = self.get_formset()
+ if form.is_valid() and formset.is_valid():
+ return self.form_valid(form, formset)
+ else:
+ return self.form_invalid(form)
+
+
+class PropertyCreate(EventPermissionRequiredMixin, CreateView):
+ model = Property
+ form_class = PropertyForm
+ template_name = 'tixlcontrol/items/property.html'
+ permission = 'can_change_items'
+ context_object_name = 'property'
+
+ def get_success_url(self):
+ return reverse('control:event.items.properties', kwargs={
+ 'organizer': self.request.event.organizer.slug,
+ 'event': self.request.event.slug,
+ }) + '?created=true'
+
+ def get_formset(self):
+ formsetclass = inlineformset_factory(
+ Property, PropertyValue,
+ form=PropertyValueForm,
+ can_order=True,
+ extra=3,
+ )
+ formset = formsetclass(**self.get_form_kwargs())
+ return formset
+
+ def get_context_data(self, *args, **kwargs):
+ self.object = None
+ context = super().get_context_data(*args, **kwargs)
+ context['formset'] = self.get_formset()
+ return context
+
+ def form_valid(self, form, formset):
+ form.instance.event = self.request.event
+ resp = super().form_valid(form)
+ for i, f in enumerate(formset.ordered_forms):
+ f.instance.position = i
+ f.instance.prop = form.instance
+ f.instance.save()
+ return resp
+
+ def post(self, request, *args, **kwargs):
+ form_class = self.get_form_class()
+ form = self.get_form(form_class)
+ formset = self.get_formset()
+ if form.is_valid() and formset.is_valid():
+ return self.form_valid(form, formset)
+ else:
+ return self.form_invalid(form)
+
+
+class PropertyDelete(EventPermissionRequiredMixin, DeleteView):
+ model = Property
+ form_class = PropertyForm
+ template_name = 'tixlcontrol/items/property_delete.html'
+ permission = 'can_change_items'
+ context_object_name = 'property'
+
+ def get_context_data(self, *args, **kwargs):
+ context = super().get_context_data(*args, **kwargs)
+ context['dependent'] = self.get_object().items.all()
+ context['possible'] = self.is_allowed()
+ return context
+
+ def is_allowed(self):
+ return self.get_object().items.count() == 0
+
+ def get_object(self, queryset=None):
+ if not hasattr(self, 'object') or not self.object:
+ url = resolve(self.request.path_info)
+ self.object = self.request.event.properties.get(
+ id=url.kwargs['property']
+ )
+ return self.object
+
+ def delete(self, request, *args, **kwargs):
+ if self.is_allowed():
+ success_url = self.get_success_url()
+ self.get_object().delete()
+ return HttpResponseRedirect(success_url)
+ else:
+ return HttpResponseForbidden()
+
+ def get_success_url(self):
+ return reverse('control:event.items.properties', kwargs={
+ 'organizer': self.request.event.organizer.slug,
+ 'event': self.request.event.slug,
+ }) + '?deleted=true'
+
+
class ItemUpdateFormGeneral(forms.ModelForm):
def __init__(self, *args, **kwargs):