Merge branch 'master' of github.com:pretix/pretix

This commit is contained in:
Raphael Michel
2015-06-16 19:40:10 +02:00
33 changed files with 1299 additions and 716 deletions

View File

@@ -0,0 +1,83 @@
from django.contrib.auth.forms import AuthenticationForm as BaseAuthenticationForm
from django import forms
from django.utils.translation import ugettext as _
from django.contrib.auth import authenticate
from pretix.base.models import User
class AuthenticationForm(BaseAuthenticationForm):
"""
The login form, providing an email and password field. The form already implements
validation for correct user data.
"""
email = forms.EmailField(label=_("Email address"), max_length=254)
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
username = None
error_messages = {
'invalid_login': _("Please enter a correct e-mail address and password."),
'inactive': _("This account is inactive.")
}
def __init__(self, request=None, *args, **kwargs):
self.request = request
self.user_cache = None
super(forms.Form, self).__init__(*args, **kwargs)
def clean(self):
email = self.cleaned_data.get('email')
password = self.cleaned_data.get('password')
if email and password:
self.user_cache = authenticate(identifier=email.lower(),
password=password)
if self.user_cache is None:
raise forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login'
)
else:
self.confirm_login_allowed(self.user_cache)
return self.cleaned_data
class GlobalRegistrationForm(forms.Form):
error_messages = {
'duplicate_email': _("You already registered with that e-mail address, please use the login form."),
'pw_mismatch': _("Please enter the same password twice")
}
email = forms.EmailField(
label=_('Email address'),
required=True
)
password = forms.CharField(
label=_('Password'),
widget=forms.PasswordInput,
required=True
)
password_repeat = forms.CharField(
label=_('Repeat password'),
widget=forms.PasswordInput
)
def clean(self):
password1 = self.cleaned_data.get('password')
password2 = self.cleaned_data.get('password_repeat')
if password1 and password1 != password2:
raise forms.ValidationError(
self.error_messages['pw_mismatch'],
code='pw_mismatch',
)
return self.cleaned_data
def clean_email(self):
email = self.cleaned_data['email']
if User.objects.filter(identifier=email).exists():
raise forms.ValidationError(
self.error_messages['duplicate_email'],
code='duplicate_email',
)
return email

View File

@@ -142,6 +142,15 @@ class EventSettingsForm(SettingsForm):
label=_("Reservation period"),
help_text=_("The number of minutes the items in a user's card are reserved for this user."),
)
imprint_url = forms.URLField(
label=_("Imprint URL"),
required=False,
)
contact_mail = forms.EmailField(
label=_("Contact address"),
required=False,
help_text=_("Public email address for contacting the organizer")
)
mail_from = forms.EmailField(
label=_("Sender address"),
help_text=_("Sender address for outgoing e-mails")

View File

@@ -19,7 +19,8 @@ class PermissionMiddleware:
"""
EXCEPTIONS = (
"auth.login"
"auth.login",
"auth.register"
)
def process_request(self, request):

View File

@@ -15,7 +15,7 @@ def event_permission_required(permission):
# just a double check, should not ever happen
return HttpResponseForbidden()
try:
perm = EventPermission.objects.get(
perm = EventPermission.objects.current.get(
event=request.event,
user=request.user
)
@@ -59,7 +59,7 @@ def organizer_permission_required(permission):
# just a double check, should not ever happen
return HttpResponseForbidden()
try:
perm = OrganizerPermission.objects.get(
perm = OrganizerPermission.objects.current.get(
organizer=request.organizer,
user=request.user
)

View File

@@ -25,4 +25,8 @@ footer {
.buttons {
text-align: right;
}
h3 {
margin-top: 0;
}
}

View File

@@ -8,6 +8,10 @@
{% bootstrap_field form.email %}
{% bootstrap_field form.password %}
<div class="form-group buttons">
<a href="{% url "control:auth.register" %}" class="btn btn-link">
{% trans "Register" %}
</a>
<button type="submit" class="btn btn-primary">
{% trans "Log in" %}
</button>

View File

@@ -0,0 +1,22 @@
{% extends "pretixcontrol/auth/base.html" %}
{% load bootstrap3 %}
{% load i18n %}
{% block content %}
<form class="form-signin" action="" method="post">
<h3>{% trans "Create a new account" %}</h3>
{% bootstrap_form_errors form type='all' layout='inline' %}
{% csrf_token %}
{% bootstrap_field form.email %}
{% bootstrap_field form.password %}
{% bootstrap_field form.password_repeat %}
<div class="form-group buttons">
<a href="{% url "control:auth.login" %}" class="btn btn-link">
&laquo; {% trans "Login" %}
</a>
<button type="submit" class="btn btn-primary">
{% trans "Register" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% block title %}{% trans "Dashboard" %}{% endblock %}
{% block content %}
<h1>{% trans "Dashboard" %}</h1>
<p>
There is nothing yet to see on this dashboard. If you have any ideas what to put here, just <a
href="https://github.com/pretix/pretix/issues">tell us</a>!
</p>
<p>
Probably, you are looking for your <a href="{% url "control:events" %}">events</a>.
</p>
{% endblock %}

View File

@@ -22,6 +22,12 @@
{% trans "General" %}
</a>
</li>
<li>
<a href="{% url 'control:event.settings.permissions' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if "event.settings.permissions" == url_name %}class="active"{% endif %}>
{% trans "Permissions" %}
</a>
</li>
<li>
<a href="{% url 'control:event.settings.payment' organizer=request.event.organizer.slug event=request.event.slug %}"
{% if "event.settings.payment" == url_name %}class="active"{% endif %}>

View File

@@ -3,4 +3,94 @@
{% block title %}{{ request.event.name }}{% endblock %}
{% block content %}
<h1>{{ request.event.name }}</h1>
<div class="row">
<div class="col-lg-3 col-md-6">
<div class="panel panel-green">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<i class="fa fa-users fa-5x"></i>
</div>
<div class="col-xs-9 text-right">
<div class="huge">{{ tickets_sold }}</div>
<div>{% trans "Tickets sold" %}</div>
</div>
</div>
</div>
<a href="{% url "control:event.orders.overview" organizer=request.organizer.slug event=request.event.slug %}">
<div class="panel-footer">
<span class="pull-left">{% trans "Orders overview" %}</span>
<span class="pull-right"><i class="fa fa-arrow-circle-right"></i></span>
<div class="clearfix"></div>
</div>
</a>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="panel panel-primary">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<i class="fa fa-shopping-cart fa-5x"></i>
</div>
<div class="col-xs-9 text-right">
<div class="huge">{{ tickets_total }}</div>
<div>{% trans "Total items ordered" %}</div>
</div>
</div>
</div>
<a href="{% url "control:event.orders" organizer=request.organizer.slug event=request.event.slug %}">
<div class="panel-footer">
<span class="pull-left">{% trans "View all orders" %}</span>
<span class="pull-right"><i class="fa fa-arrow-circle-right"></i></span>
<div class="clearfix"></div>
</div>
</a>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="panel panel-green">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<i class="fa fa-money fa-5x"></i>
</div>
<div class="col-xs-9 text-right">
<div class="huge">{{ tickets_revenue }}</div>
<div>{% trans "Total Revenue" %}</div>
</div>
</div>
</div>
<a href="{% url "control:event.orders.overview" organizer=request.organizer.slug event=request.event.slug %}">
<div class="panel-footer">
<span class="pull-left">{% trans "Orders overview" %}</span>
<span class="pull-right"><i class="fa fa-arrow-circle-right"></i></span>
<div class="clearfix"></div>
</div>
</a>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="panel panel-primary">
<div class="panel-heading">
<div class="row">
<div class="col-xs-3">
<i class="fa fa-folder fa-5x"></i>
</div>
<div class="col-xs-9 text-right">
<div class="huge">{{ products_active }}</div>
<div>{% trans "Active Products" %}</div>
</div>
</div>
</div>
<a href="{% url "control:event.items" organizer=request.organizer.slug event=request.event.slug %}">
<div class="panel-footer">
<span class="pull-left">{% trans "View details" %}</span>
<span class="pull-right"><i class="fa fa-arrow-circle-right"></i></span>
<div class="clearfix"></div>
</div>
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -6,11 +6,6 @@
{% csrf_token %}
<fieldset>
<legend>{% trans "Payment settings" %}</legend>
{% if "success" in request.GET %}
<div class="alert alert-success">
{% trans "Your changes have been saved." %}
</div>
{% endif %}
{% for provider in providers %}
<div class="panel panel-default">
<div class="panel-heading">

View File

@@ -0,0 +1,57 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inside %}
<form action="" method="post" class="form-horizontal form-permissions">
{% csrf_token %}
<fieldset>
<legend>{% trans "Permissions" %}</legend>
{{ formset.management_form }}
<table class="table table-striped table-condensed">
<thead>
<tr>
<th>{% trans "User" %}</th>
<th>{% trans "Change settings" %}</th>
<th>{% trans "Change products" %}</th>
<th>{% trans "View orders" %}</th>
<th>{% trans "Change orders" %}</th>
<th>{% trans "Change permissions" %}</th>
<th>{% trans "Delete" %}</th>
</tr>
</thead>
<tbody>
{% for form in formset %}
<tr>
<td>{{ form.id }}{{ form.instance.user }}</td>
<td>{{ form.can_change_settings }}</td>
<td>{{ form.can_change_items }}</td>
<td>{{ form.can_view_orders }}</td>
<td>{{ form.can_change_orders }}</td>
<td>{{ form.can_change_permissions }}</td>
<td>{{ form.DELETE }}</td>
</tr>
{% endfor %}
<tr>
<td>
<div class="row-fluid">
<div class="col-sm-12">
{% bootstrap_field add_form.user layout='inline' %}
</div>
</div>
</td>
<td>{{ add_form.can_change_settings }}</td>
<td>{{ add_form.can_change_items }}</td>
<td>{{ add_form.can_view_orders }}</td>
<td>{{ add_form.can_change_orders }}</td>
<td>{{ add_form.can_change_permissions }}</td>
</tr>
</tbody>
</table>
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -19,6 +19,8 @@
{% bootstrap_field sform.timezone layout="horizontal" %}
{% bootstrap_field sform.show_date_to layout="horizontal" %}
{% bootstrap_field sform.show_times layout="horizontal" %}
{% bootstrap_field sform.contact_mail layout="horizontal" %}
{% bootstrap_field sform.imprint_url layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Timeline" %}</legend>

View File

@@ -3,11 +3,6 @@
{% load bootstrap3 %}
{% block inside %}
<form action="" method="post" class="form-horizontal">
{% if "success" in request.GET %}
<div class="alert alert-success">
{% trans "Your changes have been saved." %}
</div>
{% endif %}
{% csrf_token %}
<fieldset>
<legend>{% trans "Ticket download" %}</legend>

View File

@@ -8,25 +8,31 @@
<span class="fa fa-plus"></span>
{% trans "Create a new event" %}
</a>
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Event name" %}</th>
<th>{% trans "Organizer" %}</th>
<th>{% trans "Start date" %}</th>
<th>{% trans "End date" %}</th>
</tr>
</thead>
<tbody>
{% for e in events %}
<tr>
<td><strong><a href="{% url "control:event.index" organizer=e.organizer.slug event=e.slug %}">{{ e.name }}</a></strong></td>
<td>{{ e.organizer }}</td>
<td>{{ e.get_date_from_display }}</td>
<td>{{ e.get_date_to_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if events|length == 0 %}
<p>
<em>{% trans "You currently do not have access to any events." %}</em>
</p>
{% else %}
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Event name" %}</th>
<th>{% trans "Organizer" %}</th>
<th>{% trans "Start date" %}</th>
<th>{% trans "End date" %}</th>
</tr>
</thead>
<tbody>
{% for e in events %}
<tr>
<td><strong><a href="{% url "control:event.index" organizer=e.organizer.slug event=e.slug %}">{{ e.name }}</a></strong></td>
<td>{{ e.organizer }}</td>
<td>{{ e.get_date_from_display }}</td>
<td>{{ e.get_date_to_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

View File

@@ -6,6 +6,7 @@ from pretix.control.views import main, event, item, auth, orders, user, organize
urlpatterns = [
url(r'^logout$', auth.logout, name='auth.logout'),
url(r'^login$', auth.login, name='auth.login'),
url(r'^register$', auth.register, name='auth.register'),
url(r'^$', main.index, name='index'),
url(r'^settings$', user.UserSettings.as_view(), name='user.settings'),
url(r'^organizers/$', organizer.OrganizerList.as_view(), name='organizers'),
@@ -18,6 +19,7 @@ urlpatterns = [
url(r'^$', event.index, name='event.index'),
url(r'^settings/$', event.EventUpdate.as_view(), name='event.settings'),
url(r'^settings/plugins$', event.EventPlugins.as_view(), name='event.settings.plugins'),
url(r'^settings/permissions$', event.EventPermissions.as_view(), name='event.settings.permissions'),
url(r'^settings/payment$', event.PaymentSettings.as_view(), name='event.settings.payment'),
url(r'^settings/tickets$', event.TicketSettings.as_view(), name='event.settings.tickets'),
url(r'^items/$', item.ItemList.as_view(), name='event.items'),

View File

@@ -1,47 +1,9 @@
from django.conf import settings
from django.shortcuts import render, redirect
from django.contrib.auth.forms import AuthenticationForm as BaseAuthenticationForm
from django import forms
from django.utils.translation import ugettext as _
from django.contrib.auth import authenticate
from django.contrib.auth import login as auth_login
from django.contrib.auth import login as auth_login, authenticate
from django.contrib.auth import logout as auth_logout
class AuthenticationForm(BaseAuthenticationForm):
"""
The login form, providing an email and password field. The form already implements
validation for correct user data.
"""
email = forms.EmailField(label=_("E-mail address"), max_length=254)
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
username = None
error_messages = {
'invalid_login': _("Please enter a correct e-mail address and password."),
'inactive': _("This account is inactive."),
}
def __init__(self, request=None, *args, **kwargs):
self.request = request
self.user_cache = None
super(forms.Form, self).__init__(*args, **kwargs)
def clean(self):
email = self.cleaned_data.get('email')
password = self.cleaned_data.get('password')
if email and password:
self.user_cache = authenticate(identifier=email.lower(),
password=password)
if self.user_cache is None:
raise forms.ValidationError(
self.error_messages['invalid_login'],
code='invalid_login',
)
else:
self.confirm_login_allowed(self.user_cache)
return self.cleaned_data
from pretix.base.models import User
from pretix.control.forms.auth import AuthenticationForm, GlobalRegistrationForm
def login(request):
@@ -73,3 +35,29 @@ def logout(request):
"""
auth_logout(request)
return redirect('control:auth.login')
def register(request):
"""
Render and process a basic registration form.
"""
ctx = {}
if request.user.is_authenticated():
if "next" in request.GET:
return redirect(request.GET.get("next", 'control:index'))
return redirect('control:index')
if request.method == 'POST':
form = GlobalRegistrationForm(data=request.POST)
if form.is_valid():
user = User.objects.create_global_user(
form.cleaned_data['email'], form.cleaned_data['password'],
locale=request.LANGUAGE_CODE,
timezone=request.timezone if hasattr(request, 'timezone') else settings.TIME_ZONE
)
user = authenticate(identifier=user.identifier, password=form.cleaned_data['password'])
auth_login(request, user)
return redirect('control:index')
else:
form = GlobalRegistrationForm()
ctx['form'] = form
return render(request, 'pretixcontrol/auth/register.html', ctx)

View File

@@ -1,6 +1,9 @@
from collections import OrderedDict
from django import forms
from django.contrib import messages
from django.db.models import Sum
from django.forms import inlineformset_factory, formset_factory, modelformset_factory, BaseInlineFormSet
from django.shortcuts import render, redirect
from django.utils.functional import cached_property
from django.views.generic import FormView
@@ -8,8 +11,9 @@ from django.views.generic.base import TemplateView
from django.views.generic.detail import SingleObjectMixin
from django.utils.translation import ugettext_lazy as _
from django.core.urlresolvers import reverse
from pretix.base.forms import VersionedModelForm
from pretix.control.forms.event import ProviderForm, TicketSettingsForm, EventSettingsForm, EventUpdateForm
from pretix.base.models import Event
from pretix.base.models import Event, OrderPosition, Order, Item, EventPermission, User
from pretix.base.signals import register_payment_providers, register_ticket_outputs
from pretix.control.permissions import EventPermissionRequiredMixin
from . import UpdateView
@@ -94,13 +98,14 @@ class EventPlugins(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin
plugins_active.remove(module)
self.object.plugins = ",".join(plugins_active)
self.object.save()
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
def get_success_url(self) -> str:
return reverse('control:event.settings.plugins', kwargs={
'organizer': self.get_object().organizer.slug,
'event': self.get_object().slug,
}) + '?success=true'
})
class PaymentSettings(EventPermissionRequiredMixin, TemplateView, SingleObjectMixin):
@@ -153,6 +158,7 @@ class PaymentSettings(EventPermissionRequiredMixin, TemplateView, SingleObjectMi
else:
success = False
if success:
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
else:
return self.get(request)
@@ -161,7 +167,7 @@ class PaymentSettings(EventPermissionRequiredMixin, TemplateView, SingleObjectMi
return reverse('control:event.settings.payment', kwargs={
'organizer': self.get_object().organizer.slug,
'event': self.get_object().slug,
}) + '?success=true'
})
class TicketSettings(EventPermissionRequiredMixin, FormView):
@@ -170,10 +176,6 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
template_name = 'pretixcontrol/event/tickets.html'
permission = 'can_change_settings'
def form_valid(self, form):
form.save()
return super().form_valid(form)
def get_context_data(self, *args, **kwargs) -> dict:
context = super().get_context_data(*args, **kwargs)
context['providers'] = self.provider_forms
@@ -183,7 +185,7 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
return reverse('control:event.settings.tickets', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug
}) + '?success=true'
})
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
@@ -204,6 +206,8 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
success = False
form = self.get_form(self.get_form_class())
if success and form.is_valid():
form.save()
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
else:
return self.get(request)
@@ -232,4 +236,101 @@ class TicketSettings(EventPermissionRequiredMixin, FormView):
def index(request, organizer, event):
return render(request, 'pretixcontrol/event/index.html', {})
ctx = {
'products_active': Item.objects.current.filter(
event=request.event,
active=True,
).count(),
'tickets_total': OrderPosition.objects.current.filter(
order__event=request.event,
item__admission=True
).count(),
'tickets_revenue': Order.objects.current.filter(
event=request.event,
status=Order.STATUS_PAID,
).aggregate(sum=Sum('total'))['sum'],
'tickets_sold': OrderPosition.objects.current.filter(
order__event=request.event,
order__status=Order.STATUS_PAID,
item__admission=True
).count()
}
return render(request, 'pretixcontrol/event/index.html', ctx)
class EventPermissionForm(VersionedModelForm):
class Meta:
model = EventPermission
fields = (
'can_change_settings', 'can_change_items', 'can_change_permissions', 'can_view_orders',
'can_change_orders'
)
class EventPermissionCreateForm(EventPermissionForm):
user = forms.EmailField(required=False, label=_('User'))
class EventPermissions(EventPermissionRequiredMixin, TemplateView):
model = Event
form_class = TicketSettingsForm
template_name = 'pretixcontrol/event/permissions.html'
permission = 'can_change_permissions'
@cached_property
def formset(self):
fs = modelformset_factory(
EventPermission,
form=EventPermissionForm,
can_delete=True, can_order=False, extra=0
)
return fs(data=self.request.POST if self.request.method == "POST" else None,
prefix="formset",
queryset=EventPermission.objects.current.filter(event=self.request.event))
@cached_property
def add_form(self):
return EventPermissionCreateForm(data=self.request.POST if self.request.method == "POST" else None,
prefix="add")
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['formset'] = self.formset
ctx['add_form'] = self.add_form
return ctx
def post(self, *args, **kwargs):
if self.formset.is_valid() and self.add_form.is_valid():
if self.add_form.has_changed():
try:
self.add_form.instance.user = User.objects.get(identifier=self.add_form.cleaned_data['user'])
self.add_form.instance.user_id = self.add_form.instance.user.id
self.add_form.instance.event = self.request.event
self.add_form.instance.event_id = self.request.event.identity
except User.DoesNotExist:
messages.error(self.request, _('There is no user with the email address you entered.'))
return self.get(*args, **kwargs)
else:
if EventPermission.objects.current.filter(user=self.add_form.instance.user,
event=self.request.event).exists():
messages.error(self.request, _('This user already has permissions for this event.'))
return self.get(*args, **kwargs)
self.add_form.save()
for form in self.formset.forms:
if form.instance.user_id == self.request.user.pk:
if not form.cleaned_data['can_change_permissions'] or form in self.formset.deleted_forms:
messages.error(self.request, _('You cannot remove your own permission to view this page.'))
return self.get(*args, **kwargs)
self.formset.save()
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
else:
messages.error(self.request, _('Your changes could not be saved.'))
return self.get(*args, **kwargs)
def get_success_url(self) -> str:
return reverse('control:event.settings.permissions', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug
})

View File

@@ -24,7 +24,7 @@ class EventList(ListView):
def index(request):
return render(request, 'pretixcontrol/base.html', {})
return render(request, 'pretixcontrol/dashboard.html', {})
class EventCreateStart(TemplateView):

View File

@@ -8,7 +8,7 @@ from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.utils.functional import cached_property
from django.views.generic import ListView, DetailView, TemplateView
from pretix.base.models import Order, Quota, OrderPosition
from pretix.base.models import Order, Quota, OrderPosition, ItemCategory
from pretix.base.services.orders import mark_order_paid
from pretix.base.signals import register_payment_providers
from pretix.control.forms.orders import ExtendForm
@@ -258,11 +258,12 @@ class OverView(EventPermissionRequiredMixin, TemplateView):
item.num_refunded = sum(var.num_refunded for var in item.all_variations)
item.num_paid = sum(var.num_paid for var in item.all_variations)
nonecat = ItemCategory(name=_('Uncategorized'))
# Regroup those by category
ctx['items_by_category'] = sorted(
[
# a group is a tuple of a category and a list of items
(cat, [i for i in items if i.category == cat])
(cat if cat is not None else nonecat, [i for i in items if i.category == cat])
for cat in set([i.category for i in items])
# insert categories into a set for uniqueness
# a set is unsorted, so sort again by category