tixlcontrol: Quota UI

This commit is contained in:
Raphael Michel
2015-01-08 00:13:20 +01:00
parent 6b5027e412
commit 88df78d4cf
11 changed files with 219 additions and 23 deletions

View File

@@ -11,7 +11,7 @@ class VersionedBaseModelForm(BaseModelForm):
if self.instance.pk is not None and isinstance(self.instance, Versionable): if self.instance.pk is not None and isinstance(self.instance, Versionable):
if self.has_changed(): if self.has_changed():
self.instance = self.instance.clone() self.instance = self.instance.clone()
super().save(commit) return super().save(commit)
class VersionedModelForm(six.with_metaclass(ModelFormMetaclass, VersionedBaseModelForm)): class VersionedModelForm(six.with_metaclass(ModelFormMetaclass, VersionedBaseModelForm)):

View File

@@ -1,4 +1,6 @@
from itertools import product from itertools import product
import copy
import uuid
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
@@ -6,11 +8,54 @@ from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, Permis
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.template.defaultfilters import date as _date from django.template.defaultfilters import date as _date
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from versions.models import Versionable, VersionedForeignKey, VersionedManyToManyField import six
from versions.models import Versionable as BaseVersionable
from versions.models import VersionedForeignKey, VersionedManyToManyField, get_utc_now
from tixlbase.types import VariationDict from tixlbase.types import VariationDict
class Versionable(BaseVersionable):
class Meta:
abstract = True
def clone_shallow(self, forced_version_date=None):
"""
This behaves like clone(), but misses all the Many2Many-relation-handling. This is
a performance optimization for cases in which we have to handle the Many2Many relations
by handy anyways.
"""
if not self.pk:
raise ValueError('Instance must be saved before it can be cloned')
if self.version_end_date:
raise ValueError('This is a historical item and can not be cloned.')
if forced_version_date:
if not self.version_start_date <= forced_version_date <= get_utc_now():
raise ValueError('The clone date must be between the version start date and now.')
else:
forced_version_date = get_utc_now()
earlier_version = self
later_version = copy.copy(earlier_version)
later_version.version_end_date = None
later_version.version_start_date = forced_version_date
# set earlier_version's ID to a new UUID so the clone (later_version) can
# get the old one -- this allows 'head' to always have the original
# id allowing us to get at all historic foreign key relationships
earlier_version.id = six.u(str(uuid.uuid4()))
earlier_version.version_end_date = forced_version_date
earlier_version.save()
later_version.save()
return later_version
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
""" """
This is the user manager for our custom user model. See the User This is the user manager for our custom user model. See the User
@@ -614,18 +659,18 @@ class Item(Versionable):
if use_cache and hasattr(self, '_get_all_variations_cache'): if use_cache and hasattr(self, '_get_all_variations_cache'):
return self._get_all_variations_cache return self._get_all_variations_cache
all_variations = self.variations.current.all().prefetch_related("values") all_variations = self.variations.all().prefetch_related("values")
all_properties = self.properties.current.all().prefetch_related("values") all_properties = self.properties.all().prefetch_related("values")
variations_cache = {} variations_cache = {}
for var in all_variations: for var in all_variations:
key = [] key = []
for v in var.values.current.all(): for v in var.values.all():
key.append((v.prop_id, v.identity)) key.append((v.prop_id, v.identity))
key = tuple(sorted(key)) key = tuple(sorted(key))
variations_cache[key] = var variations_cache[key] = var
result = [] result = []
for comb in product(*[prop.values.current.all() for prop in all_properties]): for comb in product(*[prop.values.all() for prop in all_properties]):
if len(comb) == 0: if len(comb) == 0:
result.append(VariationDict()) result.append(VariationDict())
continue continue
@@ -772,8 +817,8 @@ class Quota(Versionable):
and no more than 100 of them will be VIP tickets (but 450 normal and 50 and no more than 100 of them will be VIP tickets (but 450 normal and 50
VIP tickets will be fine). VIP tickets will be fine).
As always, a quota can not only be tied to an item, but also to a specific As always, a quota can not only be tied to an item, but also to specific
variation. We follow the general rule here: If there are no variations variations. We follow the general rule here: If there are no variations
speficied, the quota applies to all of them, and if there are variations speficied, the quota applies to all of them, and if there are variations
specified, the quota applies to those. specified, the quota applies to those.
@@ -797,10 +842,12 @@ class Quota(Versionable):
items = VersionedManyToManyField( items = VersionedManyToManyField(
Item, Item,
verbose_name=_("Item"), verbose_name=_("Item"),
related_name="quotas",
blank=True blank=True
) )
variations = VariationsField( variations = VariationsField(
ItemVariation, ItemVariation,
related_name="quotas",
blank=True, blank=True,
verbose_name=_("Variations") verbose_name=_("Variations")
) )

View File

@@ -5,14 +5,23 @@ $(function () {
reorderMode: 'animate' reorderMode: 'animate'
}); });
$(document).on("click", ".variations .variations-select-all", function (e) { $(document).on("click", ".variations .variations-select-all", function (e) {
$(this).parent().parent().find("input[type=checkbox]").prop("checked", true); $(this).parent().parent().find("input[type=checkbox]").prop("checked", true).change();
e.stopPropagation(); e.stopPropagation();
return false; return false;
}); });
$(document).on("click", ".variations .variations-select-none", function (e) { $(document).on("click", ".variations .variations-select-none", function (e) {
$(this).parent().parent().find("input[type=checkbox]").prop("checked", false); $(this).parent().parent().find("input[type=checkbox]").prop("checked", false).change();
e.stopPropagation(); e.stopPropagation();
return false; return false;
}); });
if ($(".items-on-quota").length) {
$(".items-on-quota .panel").each(function () {
var $panel = $(this);
$panel.toggleClass("panel-success", $panel.find("input:checked").length > 0);
$(this).find("input").change(function () {
$panel.toggleClass("panel-success", $panel.find("input:checked").length > 0);
});
});
}
$('.collapse').collapse(); $('.collapse').collapse();
}); });

View File

@@ -47,6 +47,10 @@ td > .form-group > .checkbox {
} }
} }
.panel .form-group:last-child {
margin-bottom: 0;
}
.container ul.nav-pills { .container ul.nav-pills {
margin: 20px 0; margin: 20px 0;
} }
@@ -56,6 +60,13 @@ td > .form-group > .checkbox {
margin: 0; margin: 0;
} }
} }
.table-quotas td ul {
list-style: none;
margin-left: 0;
padding-left: 0;
}
@media (min-width: @screen-sm-min) { @media (min-width: @screen-sm-min) {
.variation-matrix > tbody > tr > td { .variation-matrix > tbody > tr > td {
line-height: 34px; line-height: 34px;

View File

@@ -8,6 +8,14 @@
<li {% if "event.item.variations" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item.variations' organizer=request.event.organizer.slug event=request.event.slug item=item.identity %}">{% trans "Variations" %}</a></li> <li {% if "event.item.variations" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item.variations' organizer=request.event.organizer.slug event=request.event.slug item=item.identity %}">{% trans "Variations" %}</a></li>
<li {% if "event.item.restrictions" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item.restrictions' organizer=request.event.organizer.slug event=request.event.slug item=item.identity %}">{% trans "Restrictions" %}</a></li> <li {% if "event.item.restrictions" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.item.restrictions' organizer=request.event.organizer.slug event=request.event.slug item=item.identity %}">{% trans "Restrictions" %}</a></li>
</ul> </ul>
{% if item.identity and not item.quotas.exists %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
Please note, that your item will <strong>not</strong> be available for sale until you added your item
to an existing or newly created quota.
{% endblocktrans %}
</div>
{% endif %}
{% block inside %} {% block inside %}
{% endblock %} {% endblock %}
{% endblock %} {% endblock %}

View File

@@ -22,7 +22,7 @@
<h4 class="panel-title"> <h4 class="panel-title">
<a data-toggle="collapse" data-parent="#accordion" <a data-toggle="collapse" data-parent="#accordion"
href="#collapse{{ f.prefix }}"> href="#collapse{{ f.prefix }}">
Test {{ set.title }}
</a> </a>
</h4> </h4>
</div> </div>

View File

@@ -4,10 +4,10 @@
{% block content %} {% block content %}
<ul class="nav nav-pills"> <ul class="nav nav-pills">
<li {% if "event.items" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.items' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Items" %}</a></li> <li {% if "event.items" == url_name %}class="active"{% endif %}><a href="{% url 'control:event.items' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Items" %}</a></li>
<li {% if "event.items.quotas" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.quotas' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Quotas" %}</a></li>
<li {% if "event.items.categories" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.categories' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Categories" %}</a></li> <li {% if "event.items.categories" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.categories' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Categories" %}</a></li>
<li {% if "event.items.properties" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.properties' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Properties" %}</a></li> <li {% if "event.items.properties" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.properties' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Properties" %}</a></li>
<li {% if "event.items.questions" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.questions' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Questions" %}</a></li> <li {% if "event.items.questions" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.questions' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Questions" %}</a></li>
<li {% if "event.items.quotas" in url_name %}class="active"{% endif %}><a href="{% url 'control:event.items.quotas' organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Quotas" %}</a></li>
</ul> </ul>
{% block inside %} {% block inside %}
{% endblock %} {% endblock %}

View File

@@ -15,6 +15,35 @@
<legend>{% trans "General information" %}</legend> <legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %} {% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.size layout="horizontal" %} {% bootstrap_field form.size layout="horizontal" %}
<legend>{% trans "Items" %}</legend>
<p>
{% blocktrans trimmed %}
Please select the items or item variations this quota should be applied to. If you apply two
quotas to the same items, it will only be available if <strong>both</strong> quotas have capacity
left.
{% endblocktrans %}
</p>
<div class="panel-group items-on-quota">
{% for item in items %}
<div class="panel panel-default" data-formset-form>
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" data-parent="#accordion"
href="#collapse{{ item.identity }}">
{{ item.name }}
</a>
</h4>
</div>
<div id="collapse{{ item.identity }}" class="panel-collapse collapse in">
<div class="panel-body">
<div class="form-horizontal">
{% bootstrap_field item.field layout="horizontal" %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</fieldset> </fieldset>
<div class="form-group submit-group"> <div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save"> <button type="submit" class="btn btn-primary btn-save">

View File

@@ -19,7 +19,7 @@
<p> <p>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new quota" %}</a> <a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new quota" %}</a>
</p> </p>
<table class="table table-hover"> <table class="table table-hover table-quotas">
<thead> <thead>
<tr> <tr>
<th>{% trans "Quota name" %}</th> <th>{% trans "Quota name" %}</th>
@@ -33,7 +33,15 @@
{% for q in quotas %} {% for q in quotas %}
<tr> <tr>
<td><strong><a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.identity %}">{{ q.name }}</a></strong></td> <td><strong><a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.identity %}">{{ q.name }}</a></strong></td>
<td></td> <td>
<ul>
{% for item in q.items.all %}
<li><a href="
{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.identity %}"
>{{ item.name }}</a></li>
{% endfor %}
</ul>
</td>
<td>{{ q.size }}</td> <td>{{ q.size }}</td>
<td></td> <td></td>
<td class="text-right"><a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.identity %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a></td> <td class="text-right"><a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.identity %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a></td>

View File

@@ -6,7 +6,7 @@ from django.forms.widgets import flatatt
from django.utils.encoding import force_text from django.utils.encoding import force_text
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy as _
from tixlbase.forms import VersionedModelForm from tixlbase.forms import VersionedModelForm
from tixlbase.models import ItemVariation, PropertyValue, Item from tixlbase.models import ItemVariation, PropertyValue, Item
@@ -301,7 +301,7 @@ class VariationsField(forms.ModelMultipleChoiceField):
if self.required and not value: if self.required and not value:
raise ValidationError(self.error_messages['required'], code='required') raise ValidationError(self.error_messages['required'], code='required')
elif not self.required and not value: elif not self.required and not value:
return self.queryset.none() return []
if not isinstance(value, (list, tuple)): if not isinstance(value, (list, tuple)):
raise ValidationError(self.error_messages['list'], code='list') raise ValidationError(self.error_messages['list'], code='list')

View File

@@ -1,5 +1,7 @@
from itertools import product from itertools import product
from django.db import transaction from django.db import transaction
from django.forms import BooleanField, ModelForm
from django.utils.functional import cached_property
from django.views.generic import ListView from django.views.generic import ListView
from django.views.generic.edit import CreateView, UpdateView, DeleteView from django.views.generic.edit import CreateView, UpdateView, DeleteView
@@ -9,13 +11,14 @@ from django.core.urlresolvers import resolve, reverse
from django.http import HttpResponseRedirect, HttpResponseForbidden from django.http import HttpResponseRedirect, HttpResponseForbidden
from django.shortcuts import redirect from django.shortcuts import redirect
from django.forms.models import inlineformset_factory from django.forms.models import inlineformset_factory
from django.utils.translation import ugettext_lazy as _
from tixlbase.forms import VersionedModelForm from tixlbase.forms import VersionedModelForm
from tixlbase.models import ( from tixlbase.models import (
Item, ItemCategory, Property, ItemVariation, PropertyValue, Question, Quota Item, ItemCategory, Property, ItemVariation, PropertyValue, Question, Quota,
) Versionable)
from tixlcontrol.permissions import EventPermissionRequiredMixin, event_permission_required from tixlcontrol.permissions import EventPermissionRequiredMixin, event_permission_required
from tixlcontrol.views.forms import TolerantFormsetModelForm from tixlcontrol.views.forms import TolerantFormsetModelForm, VariationsField
from tixlcontrol.signals import restriction_formset from tixlcontrol.signals import restriction_formset
@@ -431,10 +434,45 @@ class QuotaList(ListView):
def get_queryset(self): def get_queryset(self):
return Quota.objects.current.filter( return Quota.objects.current.filter(
event=self.request.event event=self.request.event
) ).prefetch_related("items")
class QuotaForm(VersionedModelForm): class QuotaForm(ModelForm):
def __init__(self, **kwargs):
items = kwargs['items']
del kwargs['items']
super().__init__(**kwargs)
if hasattr(self, 'instance'):
active_items = set(self.instance.items.all())
active_variations = set(self.instance.variations.all())
else:
active_items = set()
active_variations = set()
for item in items:
if len(item.properties.all()) > 0:
self.fields['item_%s' % item.identity] = VariationsField(
item, label=_("Activate for"),
required=False,
initial=active_variations
)
self.fields['item_%s' % item.identity].set_item(item)
else:
self.fields['item_%s' % item.identity] = BooleanField(
label=_("Activate"),
required=False,
initial=(item in active_items)
)
def save(self, commit=True):
if self.instance.pk is not None and isinstance(self.instance, Versionable):
if self.has_changed():
self.instance = self.instance.clone_shallow()
# TODO: order_cache, lock_cache are emptied by that but you'll have
# to rebuild them anyway
return super().save(commit)
class Meta: class Meta:
model = Quota model = Quota
@@ -445,7 +483,53 @@ class QuotaForm(VersionedModelForm):
] ]
class QuotaCreate(EventPermissionRequiredMixin, CreateView): class QuotaEditorMixin:
@cached_property
def items(self) -> "List[Item]":
return list(self.request.event.items.all().prefetch_related("properties", "variations"))
def get_form(self, form_class):
if not hasattr(self, '_form'):
kwargs = self.get_form_kwargs()
kwargs['items'] = self.items
self._form = form_class(**kwargs)
return self._form
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
context['items'] = self.items
for item in context['items']:
item.field = self.get_form(QuotaForm)['item_%s' % item.identity]
return context
@transaction.atomic()
def form_valid(self, form):
res = super().form_valid(form)
# The following commented-out checks are not necessary as both self.object.items
# and self.object.variations can be expected empty due to the performance
# optimization of tixlbase.models.Versionable.clone_shallow()
# items = self.object.items.all()
# variations = self.object.variations.all()
for item in self.items:
field = form.fields['item_%s' % item.identity]
data = form.cleaned_data['item_%s' % item.identity]
if isinstance(field, VariationsField):
self.object.variations.add(data)
# for v in data:
# if v not in variations:
# self.object.variations.add(v)
# for v in variations:
# if v not in data:
# self.object.variations.remove(v)
if data: # and item not in items:
self.object.items.add(item)
# elif not data and item in items:
# self.object.items.remove(item)
return res
class QuotaCreate(EventPermissionRequiredMixin, QuotaEditorMixin, CreateView):
model = Quota model = Quota
form_class = QuotaForm form_class = QuotaForm
template_name = 'tixlcontrol/items/quota.html' template_name = 'tixlcontrol/items/quota.html'
@@ -463,7 +547,7 @@ class QuotaCreate(EventPermissionRequiredMixin, CreateView):
return super().form_valid(form) return super().form_valid(form)
class QuotaUpdate(EventPermissionRequiredMixin, UpdateView): class QuotaUpdate(EventPermissionRequiredMixin, QuotaEditorMixin, UpdateView):
model = Quota model = Quota
form_class = QuotaForm form_class = QuotaForm
template_name = 'tixlcontrol/items/quota.html' template_name = 'tixlcontrol/items/quota.html'