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

+ + + + + + + + + {% for p in properties %} + + + + + {% endfor %} + +
{% trans "Item properties" %}
{{ p.name }}
+{% 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" %}

+
+ {% csrf_token %} + {% if "success" in request.GET %} +
+ {% trans "Your changes have been saved." %} +
+ {% endif %} +
+ {% trans "General information" %} + {% bootstrap_field form.name layout="horizontal" %} +
+
+ {% trans "Values" %} +
+
+ {{ formset.management_form }} + {% for f in formset %} +
+ {{ 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 %} +
+ + +
+
+
+
+ +
+
+
+{% 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 %} +
+ {% csrf_token %} +

{% blocktrans %}Are you sure you want to the property {{ property }}?{% endblocktrans %}

+
+
+ + + {% trans "Cancel" %} + +
+
+
+ {% 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):