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.has_changed():
self.instance = self.instance.clone()
super().save(commit)
return super().save(commit)
class VersionedModelForm(six.with_metaclass(ModelFormMetaclass, VersionedBaseModelForm)):

View File

@@ -1,4 +1,6 @@
from itertools import product
import copy
import uuid
from django.db import models
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.template.defaultfilters import date as _date
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
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):
"""
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'):
return self._get_all_variations_cache
all_variations = self.variations.current.all().prefetch_related("values")
all_properties = self.properties.current.all().prefetch_related("values")
all_variations = self.variations.all().prefetch_related("values")
all_properties = self.properties.all().prefetch_related("values")
variations_cache = {}
for var in all_variations:
key = []
for v in var.values.current.all():
for v in var.values.all():
key.append((v.prop_id, v.identity))
key = tuple(sorted(key))
variations_cache[key] = var
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:
result.append(VariationDict())
continue
@@ -772,8 +817,8 @@ class Quota(Versionable):
and no more than 100 of them will be VIP tickets (but 450 normal and 50
VIP tickets will be fine).
As always, a quota can not only be tied to an item, but also to a specific
variation. We follow the general rule here: If there are no variations
As always, a quota can not only be tied to an item, but also to specific
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
specified, the quota applies to those.
@@ -797,10 +842,12 @@ class Quota(Versionable):
items = VersionedManyToManyField(
Item,
verbose_name=_("Item"),
related_name="quotas",
blank=True
)
variations = VariationsField(
ItemVariation,
related_name="quotas",
blank=True,
verbose_name=_("Variations")
)

View File

@@ -5,14 +5,23 @@ $(function () {
reorderMode: 'animate'
});
$(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();
return false;
});
$(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();
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();
});

View File

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

View File

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

View File

@@ -4,10 +4,10 @@
{% block content %}
<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.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.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.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>
{% block inside %}
{% endblock %}

View File

@@ -15,6 +15,35 @@
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name 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>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">

View File

@@ -19,7 +19,7 @@
<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>
</p>
<table class="table table-hover">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Quota name" %}</th>
@@ -33,7 +33,15 @@
{% for q in quotas %}
<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></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></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.html import format_html
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.models import ItemVariation, PropertyValue, Item
@@ -301,7 +301,7 @@ class VariationsField(forms.ModelMultipleChoiceField):
if self.required and not value:
raise ValidationError(self.error_messages['required'], code='required')
elif not self.required and not value:
return self.queryset.none()
return []
if not isinstance(value, (list, tuple)):
raise ValidationError(self.error_messages['list'], code='list')

View File

@@ -1,5 +1,7 @@
from itertools import product
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.edit import CreateView, UpdateView, DeleteView
@@ -9,13 +11,14 @@ from django.core.urlresolvers import resolve, reverse
from django.http import HttpResponseRedirect, HttpResponseForbidden
from django.shortcuts import redirect
from django.forms.models import inlineformset_factory
from django.utils.translation import ugettext_lazy as _
from tixlbase.forms import VersionedModelForm
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.views.forms import TolerantFormsetModelForm
from tixlcontrol.views.forms import TolerantFormsetModelForm, VariationsField
from tixlcontrol.signals import restriction_formset
@@ -431,10 +434,45 @@ class QuotaList(ListView):
def get_queryset(self):
return Quota.objects.current.filter(
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:
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
form_class = QuotaForm
template_name = 'tixlcontrol/items/quota.html'
@@ -463,7 +547,7 @@ class QuotaCreate(EventPermissionRequiredMixin, CreateView):
return super().form_valid(form)
class QuotaUpdate(EventPermissionRequiredMixin, UpdateView):
class QuotaUpdate(EventPermissionRequiredMixin, QuotaEditorMixin, UpdateView):
model = Quota
form_class = QuotaForm
template_name = 'tixlcontrol/items/quota.html'