Manage item properties

This commit is contained in:
Raphael Michel
2014-10-04 19:42:36 +02:00
parent 65091b71d4
commit e10267ebd0
14 changed files with 398 additions and 7 deletions

View File

@@ -1,16 +1,24 @@
# Functional requirements
Django>=1.7 Django>=1.7
pytz pytz
django-bootstrap3 django-bootstrap3
-e git+https://github.com/tixl/django-formset-js.git@master#egg=django-formset-js
# Deployment / static file compilation requirements
django-compressor django-compressor
BeautifulSoup4 BeautifulSoup4
html5lib html5lib
slimit slimit
lxml lxml
# Debugging requirements
django-debug-toolbar
# Testing requirements
pyflakes pyflakes
pep8 pep8
pep8-naming pep8-naming
flake8 flake8
django-debug-toolbar
coveralls coveralls
coverage coverage

View File

@@ -42,6 +42,7 @@ INSTALLED_APPS = (
'compressor', 'compressor',
'bootstrap3', 'bootstrap3',
'debug_toolbar.apps.DebugToolbarConfig', 'debug_toolbar.apps.DebugToolbarConfig',
'djangoformsetjs',
) )
MIDDLEWARE_CLASSES = ( MIDDLEWARE_CLASSES = (

View File

@@ -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,
),
]

View File

@@ -393,10 +393,18 @@ class PropertyValue(models.Model):
max_length=250, max_length=250,
verbose_name=_("Value"), verbose_name=_("Value"),
) )
position = models.IntegerField(
default=0
)
def __str__(self): def __str__(self):
return "%s: %s" % (self.prop.name, self.value) 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): class Item(models.Model):
""" """

View File

@@ -0,0 +1,6 @@
$(function() {
$("[data-formset]").formset({
animateForms: true,
reorderMode: 'animate',
});
});

View File

@@ -14,3 +14,13 @@ td > .form-group > .checkbox {
.has-success .form-control { .has-success .form-control {
border-color: #cccccc; 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);
}

View File

@@ -10,7 +10,9 @@
{% endcompress %} {% endcompress %}
{% compress js %} {% compress js %}
<script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script> <script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script>
<script type="text/javascript" src="{% static "js/jquery.formset.js" %}"></script>
<script type="text/javascript" src="{% static "bootstrap/dist/js/bootstrap.js" %}"></script> <script type="text/javascript" src="{% static "bootstrap/dist/js/bootstrap.js" %}"></script>
<script type="text/javascript" src="{% static "tixlcontrol/js/ui/main.js" %}"></script>
{% endcompress %} {% endcompress %}
</head> </head>
<body> <body>

View File

@@ -7,12 +7,14 @@
<thead> <thead>
<tr> <tr>
<th>{% trans "Item name" %}</th> <th>{% trans "Item name" %}</th>
<th>{% trans "Category" %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for i in items %} {% for i in items %}
<tr> <tr>
<td><strong><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.pk %}">{{ i.name }}</a></strong></td> <td><strong><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.pk %}">{{ i.name }}</a></strong></td>
<td>{{ i.category }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -0,0 +1,38 @@
{% extends "tixlcontrol/items/base.html" %}
{% load i18n %}
{% block title %}{% trans "Item properties" %}{% endblock %}
{% block inside %}
<h1>{% trans "Item properties" %}</h1>
{% if "updated" in request.GET %}
<div class="alert alert-success">
{% trans "Your changes have been saved." %}
</div>
{% elif "created" in request.GET %}
<div class="alert alert-success">
{% trans "A new property has been created." %}
</div>
{% elif "deleted" in request.GET %}
<div class="alert alert-success">
{% trans "The property has been deleted." %}
</div>
{% endif %}
<p>
<a href="{% url "control:event.items.properties.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create new property" %}</a>
</p>
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Item properties" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for p in properties %}
<tr>
<td><strong><a href="{% url "control:event.items.properties.edit" organizer=request.event.organizer.slug event=request.event.slug property=p.pk %}">{{ p.name }}</a></strong></td>
<td class="text-right"><a href="{% url "control:event.items.properties.delete" organizer=request.event.organizer.slug event=request.event.slug property=p.pk %}"" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "tixlcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
{% block title %}{% trans "Item property" %}{% endblock %}
{% block inside %}
<h1>{% trans "Item property" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if "success" in request.GET %}
<div class="alert alert-success">
{% trans "Your changes have been saved." %}
</div>
{% endif %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Values" %}</legend>
<div data-formset data-formset-prefix="{{ formset.prefix }}">
<div data-formset-body>
{{ formset.management_form }}
{% for f in formset %}
<div class="form-group" data-formset-form>
{{ f.id }}
<div class="col-sm-10">
{% bootstrap_field f.value form_group_class="" layout="inline" %}
</div>
<div class="sr-only">
{% bootstrap_field f.ORDER form_group_class="" layout="inline" %}
{% bootstrap_field f.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-sm-2 text-right">
<button type="button" class="btn btn-default" data-formset-move-up-button><i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button><i class="fa fa-arrow-down"></i></button>
<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="form-group" data-formset-form>
{{ formset.empty_form.id }}
<div class="col-sm-10">
{% bootstrap_field formset.empty_form.value form_group_class="" layout="inline" %}
</div>
<div class="sr-only">
{% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %}
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-sm-2 text-right">
<button type="button" class="btn btn-default" data-formset-move-up-button><i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button><i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button><i class="fa fa-trash"></i></button>
</div>
</div>
{% endescapescript %}
</script>
<button type="button" class="btn btn-default" data-formset-add><i class="fa fa-plus"></i> {% trans "Add a new value" %}</button>
</div>
</fieldset>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary">
{% trans "Save" %}
</button>
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "tixlcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete item property" %}{% endblock %}
{% block inside %}
<h1>{% trans "Delete item property" %}</h1>
{% if not possible %}
<p>{% blocktrans %}You can not delete the property <strong>{{ property }}</strong> as long as the following items use it:{% endblocktrans %}</p>
<ul>
{% for item in dependent %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.pk %}">{{ item.name }}</a></li>
{% endfor %}
</ul>
{% else %}
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans %}Are you sure you want to the property <strong>{{ property }}</strong>?{% endblocktrans %}</p>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<button type="submit" class="btn btn-primary">
{% trans "Confirm" %}
</button>
<a href="{% url "control:event.items.properties" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default">
{% trans "Cancel" %}
</a>
</div>
</div>
</form>
{% endif %}
{% endblock %}

View File

@@ -29,6 +29,9 @@ urlpatterns += patterns(
url(r'^category/(?P<category>\d+)/$', item.CategoryUpdate.as_view(), name='event.items.categories.edit'), url(r'^category/(?P<category>\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'^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$', item.PropertyList.as_view(), name='event.items.properties'),
url(r'^properties/(?P<property>\d+)/$', item.PropertyUpdate.as_view(), name='event.items.properties.edit'),
url(r'^properties/(?P<property>\d+)/delete$', item.PropertyDelete.as_view(), name='event.items.properties.delete'),
url(r'^properties/add$', item.PropertyCreate.as_view(), name='event.items.properties.add'),
) )
)) ))
) )

View File

@@ -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

View File

@@ -5,13 +5,14 @@ from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from django.views.generic.detail import SingleObjectMixin from django.views.generic.detail import SingleObjectMixin
from django.core.urlresolvers import resolve, reverse from django.core.urlresolvers import resolve, reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect, HttpResponseForbidden
from django import forms from django import forms
from django.db.models import Q
from django.shortcuts import redirect 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.permissions import EventPermissionRequiredMixin, event_permission_required
from tixlcontrol.views.forms import TolerantFormsetModelForm
class ItemList(ListView): class ItemList(ListView):
@@ -22,7 +23,7 @@ class ItemList(ListView):
def get_queryset(self): def get_queryset(self):
return Item.objects.filter( return Item.objects.filter(
event=self.request.event event=self.request.event
) ).prefetch_related("category")
class CategoryForm(forms.ModelForm): class CategoryForm(forms.ModelForm):
@@ -147,8 +148,8 @@ def category_move_down(request, organizer, event, category):
class PropertyList(ListView): class PropertyList(ListView):
model = Property model = Property
context_object_name = 'items' context_object_name = 'properties'
template_name = 'tixlcontrol/items/index.html' template_name = 'tixlcontrol/items/properties.html'
def get_queryset(self): def get_queryset(self):
return Property.objects.filter( 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): class ItemUpdateFormGeneral(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):