Restructure our python module. A lot.

This commit is contained in:
Raphael Michel
2015-02-14 17:55:13 +01:00
parent cf18f3e200
commit 077413f41c
117 changed files with 193 additions and 163 deletions

View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class PretixPresaleConfig(AppConfig):
name = 'pretix.presale'
label = 'pretixpresale'
default_app_config = 'pretix.presale.PretixPresaleConfig'

View File

@@ -0,0 +1,22 @@
from django.core.urlresolvers import resolve
from django.http import HttpResponseNotFound
from pretix.base.models import Event
class EventMiddleware:
def process_request(self, request):
url = resolve(request.path_info)
url_namespace = url.namespace
url_name = url.url_name
if url_namespace != 'presale':
return
if 'event.' in url_name and 'event' in url.kwargs:
try:
request.event = Event.objects.current.filter(
slug=url.kwargs['event'],
organizer__slug=url.kwargs['organizer'],
).select_related('organizer')[0]
except IndexError:
return HttpResponseNotFound() # TODO: Provide error message

View File

@@ -0,0 +1,3 @@
"use strict";
$(function () {
});

View File

@@ -0,0 +1,49 @@
.product-row {
border-top: 1px solid @table-border-color;
&.headline, &.simple {
border-top: 2px solid @table-border-color;
}
&:last-child {
border-bottom: 2px solid @table-border-color;
}
p:last-child {
margin-bottom: 0;
}
.input-item-count {
text-align: center;
}
.availability-box {
text-align: center;
&.gone {
color: @alert-danger-text;
}
&.unavailable {
color: @alert-warning-text;
}
}
}
.cart-row, .product-row {
padding: 10px 0;
.count form {
display: inline;
}
.price, .count {
text-align: right;
}
.price small,
.availability-box small {
display: block;
line-height: 1;
}
&.total {
border-top: 1px solid @table-border-color;
}
}
.checkout-button-row {
padding: 15px 0;
}

View File

@@ -0,0 +1,5 @@
@import "../../../../base/static/bootstrap/less/bootstrap.less";
@import "../../../../base/static/fontawesome/less/font-awesome.less";
@fa-font-path: "../../fontawesome/fonts";
@import "event.less";

View File

@@ -0,0 +1,31 @@
{% load compress %}
{% load staticfiles %}
{% load i18n %}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}{% if url_name != "event.index" %} :: {% endif %}{{ event.name }}</title>
{% compress css %}
<link rel="stylesheet" type="text/less" href="{% static "pretixpresale/less/main.less" %}" />
{% endcompress %}
{% compress js %}
<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>
{% endcompress %}
</head>
<body>
<div class="container event">
<h1>{{ event.name }} <small>{{ event.date_from|date }}{% if event.show_date_to %} {{ event.date_to|date }}{% endif %}</small></h1>
{% if messages %}
{% for message in messages %}
<div class="alert {{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% block content %}
{% endblock %}
</div>
</body>
</html>

View File

@@ -0,0 +1,13 @@
{% load i18n %}
{% if avail == 0 %}
<div class="col-md-2 col-xs-6 availability-box gone">
<strong>{% trans "SOLD OUT" %}</strong>
</div>
{% elif avail < 100 %}
<div class="col-md-2 col-xs-6 availability-box unavailable">
<strong>{% trans "Unavailable" %}</strong><br />
<small>
{% trans "This item is currently unavailable but might become available again." %}
</small>
</div>
{% endif %}

View File

@@ -0,0 +1,63 @@
{% load i18n %}
{% for line in cart.positions %}
<div class="row-fluid cart-row">
<div class="col-md-4 col-xs-6">
<strong>{{ line.item }}</strong>
{% if line.variation %}
{{ line.variation }}
{% endif %}
</div>
<div class="col-md-3 col-xs-6 price">
{{ event.currency }} {{ line.price|floatformat:2 }}
</div>
<div class="col-md-2 col-xs-6 count">
{% if editable %}
<form action="{% url "presale:event.cart.remove" event=event.slug organizer=event.organizer.slug %}"
method="post">
{% csrf_token %}
{% if line.variation %}
<input type="hidden" name="variation_{{ line.item.identity }}_{{ line.variation.identity }}"
value="1" />
{% else %}
<input type="hidden" name="item_{{ line.item.identity }}"
value="1" />
{% endif %}
<button class="btn btn-mini btn-link"><i class="fa fa-minus"></i></button>
</form>
{% endif %}
{{ line.count }}
{% if editable %}
<form action="{% url "presale:event.cart.add" event=event.slug organizer=event.organizer.slug %}"
method="post">
{% csrf_token %}
{% if line.variation %}
<input type="hidden" name="variation_{{ line.item.identity }}_{{ line.variation.identity }}"
value="1" />
{% else %}
<input type="hidden" name="item_{{ line.item.identity }}"
value="1" />
{% endif %}
<button class="btn btn-mini btn-link"><i class="fa fa-plus"></i></button>
</form>
{% endif %}
</div>
<div class="col-md-3 col-xs-6 price">
<strong>{{ event.currency }} {{ line.total|floatformat:2 }}</strong>
{% if line.item.tax_rate %}
<br /><small>{% blocktrans trimmed with rate=line.item.tax_rate %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
{% endif %}
</div>
<div class="clearfix"></div>
</div>
{% endfor %}
<div class="row-fluid cart-row total">
<div class="col-md-4 col-xs-6">
<strong>{% trans "Total" %}</strong>
</div>
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
<strong>{{ event.currency }} {{ cart.total|floatformat:2 }}</strong>
</div>
<div class="clearfix"></div>
</div>

View File

@@ -0,0 +1,92 @@
{% extends "pretixpresale/event/base.html" %}
{% load i18n %}
{% block content %}
{% if cart.positions %}
<div class="panel panel-primary">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Your cart" %}</h3>
</div>
<div class="panel-body">
{% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event editable=True %}
</div>
</div>
{% endif %}
<form method="post"
action="{% url "presale:event.cart.add" organizer=request.event.organizer.slug event=request.event.slug %}?next={{ request.path_info|urlencode }}">
{% csrf_token %}
{% for tup in items_by_category %}
<section>
<h3>{{ tup.0.name }}</h3>
{% for item in tup.1 %}
{% if item.has_variations %}
<div class="row-fluid product-row headline">
<div class="col-md-8 col-xs-12">
<strong>{{ item.name }}</strong>
{% if item.short_description %}<p>{{ item.short_description }}</p>{% endif %}
</div>
<div class="clearfix"></div>
</div>
{% for var in item.available_variations %}
<div class="row-fluid product-row variation">
<div class="col-md-8 col-xs-12">
{{ var }}
</div>
<div class="col-md-2 col-xs-6 price">
{{ event.currency }} {{ var.price|floatformat:2 }}
{% if item.tax_rate %}
<br /><small>{% blocktrans trimmed with rate=item.tax_rate %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
{% endif %}
</div>
{% if var.cached_availability.0 == 100 %}
<div class="col-md-2 col-xs-6 availability-box available">
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
max="{{ var.cached_availability.1 }}"
name="variation_{{ item.identity }}_{{ var.variation.identity }}">
</div>
{% else %}
{% include "pretixpresale/event/fragment_availability.html" with avail=var.cached_availability.0 %}
{% endif %}
<div class="clearfix"></div>
</div>
{% endfor %}
{% else %}
<div class="row-fluid product-row simple">
<div class="col-md-8 col-xs-12">
<strong>{{ item.name }}</strong>
{% if item.short_description %}<p>{{ item.short_description }}</p>{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
{{ event.currency }} {{ item.price|floatformat:2 }}
{% if item.tax_rate %}
<br /><small>{% blocktrans trimmed with rate=item.tax_rate %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
{% endif %}
</div>
{% if item.cached_availability.0 == 100 %}
<div class="col-md-2 col-xs-6 availability-box available">
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
max="{{ item.cached_availability.1 }}" name="item_{{ item.identity }}">
</div>
{% else %}
{% include "pretixpresale/event/fragment_availability.html" with avail=item.cached_availability.0 %}
{% endif %}
<div class="clearfix"></div>
</div>
{% endif %}
{% endfor %}
</section>
{% endfor %}
<div class="row-fluid checkout-button-row">
<div class="col-md-4 col-md-offset-8">
<button class="btn btn-block btn-primary btn-lg">
<i class="fa fa-shopping-cart"></i> {% trans "Add to cart" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,16 @@
from django.conf.urls import patterns, url, include
import pretix.presale.views.event
import pretix.presale.views.cart
urlpatterns = patterns(
'',
url(r'^(?P<organizer>[^/]+)/(?P<event>[^/]+)/', include(
patterns(
'pretix.presale.views.event',
url(r'^$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'),
url(r'^cart/add$', pretix.presale.views.cart.CartAdd.as_view(), name='event.cart.add'),
url(r'^cart/remove$', pretix.presale.views.cart.CartRemove.as_view(), name='event.cart.remove'),
)
)),
)

View File

@@ -0,0 +1,60 @@
import uuid
from itertools import groupby
from django.db.models import Q
from pretix.base.models import CartPosition
class CartMixin:
def get_session_key(self):
if 'cart_key' in self.request.session:
return self.request.session.get('cart_key')
key = str(uuid.uuid4())
self.request.session['cart_key'] = key
return key
class CartDisplayMixin(CartMixin):
def get_cart(self):
qw = Q(session=self.get_session_key())
if self.request.user.is_authenticated():
qw |= Q(user=self.request.user)
cartpos = list(CartPosition.objects.current.filter(
qw & Q(event=self.request.event)
).order_by(
'item', 'variation'
).select_related(
'item', 'variation'
).prefetch_related(
'variation__values', 'variation__values__prop'
))
# Group items of the same variation
# We do this by list manipulations instead of a GROUP BY query, as
# Django is unable to join related models in a .values() query
def keyfunc(pos):
return pos.item_id, pos.variation_id, pos.price
positions = []
for k, g in groupby(sorted(cartpos, key=keyfunc), key=keyfunc):
g = list(g)
group = g[0]
group.count = len(g)
group.total = group.count * group.price
positions.append(group)
return {
'positions': positions,
'total': sum(p.total for p in positions),
}
class EventViewMixin:
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['event'] = self.request.event
return context

View File

@@ -0,0 +1,210 @@
from datetime import timedelta
from django.contrib import messages
from django.core.urlresolvers import reverse
from django.db.models import Q
from django.shortcuts import redirect
from django.utils.timezone import now
from django.views.generic import View
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Item, ItemVariation, Quota, CartPosition
from pretix.presale.views import CartMixin, EventViewMixin
class CartActionMixin(CartMixin):
def get_next_url(self):
if "next" in self.request.GET and '://' not in self.request.GET:
return self.request.GET.get('next')
elif "HTTP_REFERER" in self.request.META:
return self.request.META.get('HTTP_REFERER')
else:
return reverse('presale:event.index', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
})
def get_success_url(self):
return self.get_next_url()
def get_failure_url(self):
return self.get_next_url()
def _items_from_post_data(self):
"""
Parses the POST data and returns a list of tuples in the
form (item id, variation id or None, number)
"""
items = []
for key, value in self.request.POST.items():
if value.strip() == '':
continue
if key.startswith('item_'):
try:
items.append((key.split("_")[1], None, int(value)))
except ValueError:
messages.error(self.request, _('Please enter numbers only.'))
return False
elif key.startswith('variation_'):
try:
items.append((key.split("_")[1], key.split("_")[2], int(value)))
except ValueError:
messages.error(self.request, _('Please enter numbers only.'))
return False
if len(items) == 0:
messages.warning(self.request, _('You did not select any items.'))
return False
return items
class CartRemove(EventViewMixin, CartActionMixin, View):
def post(self, *args, **kwargs):
items = self._items_from_post_data()
if not items:
return redirect(self.get_failure_url())
qw = Q(session=self.get_session_key())
if self.request.user.is_authenticated():
qw |= Q(user=self.request.user)
for item, variation, cnt in items:
cw = qw & Q(item_id=item)
if variation:
cw &= Q(variation_id=variation)
else:
cw &= Q(variation__isnull=True)
for cp in CartPosition.objects.current.filter(cw).order_by("-price")[:cnt]:
cp.delete()
messages.success(self.request, _('Your cart has been updated.'))
return redirect(self.get_success_url())
class CartAdd(EventViewMixin, CartActionMixin, View):
def post(self, *args, **kwargs):
items = self._items_from_post_data()
if not items:
return redirect(self.get_failure_url())
if sum(i[2] for i in items) > self.request.event.max_items_per_order:
# TODO: i18n plurals
messages.error(self.request,
_("You cannot select more than %d items per order") % self.event.max_items_per_order)
return redirect(self.get_failure_url())
# Fetch items from the database
items_cache = {
i.identity: i for i
in Item.objects.current.filter(
event=self.request.event,
identity__in=[i[0] for i in items]
).prefetch_related("quotas")
}
variations_cache = {
v.identity: v for v
in ItemVariation.objects.current.filter(
item__event=self.request.event,
identity__in=[i[1] for i in items if i[1] is not None]
).select_related("item", "item__event").prefetch_related("quotas", "values", "values__prop")
}
# Extend this user's cart session to 30 minutes from now to ensure all items in the
# cart expire at the same time
qw = Q(session=self.get_session_key())
if self.request.user.is_authenticated():
qw |= Q(user=self.request.user)
CartPosition.objects.current.filter(
qw & Q(event=self.request.event)).update(expires=now() + timedelta(minutes=30))
# Process the request itself
msg_some_unavailable = False
for i in items:
# Check whether the specified items are part of what we just fetched from the database
# If they are not, the user supplied item IDs which either do not exist or belong to
# a different event
if i[0] not in items_cache or (i[1] is not None and i[1] not in variations_cache):
messages.error(self.request, _('You selected an item which is not available for sale.'))
return redirect(self.get_failure_url())
item = items_cache[i[0]]
variation = variations_cache[i[1]] if i[1] is not None else None
# Execute restriction plugins to check whether they (a) change the price or
# (b) make the item/variation unavailable. If neither is the case, check_restriction
# will correctly return the default price
price = item.check_restrictions() if variation is None else variation.check_restrictions()
if price is False:
if not msg_some_unavailable:
msg_some_unavailable = True
messages.error(self.request,
_('Some of the items you selected were no longer available. '
'Please see below for details.'))
continue
# Fetch all quotas. If there are no quotas, this item is not allowed to be sold.
quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all())
if len(quotas) == 0:
if not msg_some_unavailable:
msg_some_unavailable = True
messages.error(self.request,
_('Some of the items you selected were no longer available. '
'Please see below for details.'))
continue
# Assume that all quotas allow us to buy i[2] instances of the object
quota_ok = i[2]
try:
for quota in quotas:
# Lock the quota, so no other thread is allowed to perform sales covered by this
# quota while we're doing so.
quota.lock()
avail = quota.availability()
if avail[0] != Quota.AVAILABILITY_OK:
# This quota is sold out/currently unavailable, so do not sell this at all
if not msg_some_unavailable:
msg_some_unavailable = True
messages.error(self.request,
_('Some of the items you selected were no longer available. '
'Please see below for details.'))
quota_ok = 0
break
elif avail[1] < i[2]:
# This quota is available, but with less than i[2] items left, so we have to
# reduce the number of bought items
if not msg_some_unavailable:
msg_some_unavailable = True
messages.error(self.request,
_('Some of the items you selected were no longer available in '
'the quantity you selected. Please see below for details.'))
quota_ok = min(quota_ok, avail[1])
# Create a CartPosition for as much items as we can
for k in range(quota_ok):
CartPosition.objects.create(
event=self.request.event,
session=self.get_session_key(),
user=(self.request.user if self.request.user.is_authenticated() else None),
item=item,
variation=variation,
price=price,
expires=now() + timedelta(minutes=30)
)
except Quota.LockTimeoutException:
# Is raised when there are too many threads asking for quota locks and we were
# unaible to get one
if not msg_some_unavailable:
msg_some_unavailable = True
messages.error(self.request,
_('We were not able to process your request completely as the '
'server was too busy. Please try again.'))
finally:
# Release the locks. This is important ;)
for quota in quotas:
quota.release()
if not msg_some_unavailable:
messages.success(self.request, _('The items have been successfully added to your cart.'))
return redirect(self.get_success_url())

View File

@@ -0,0 +1,45 @@
from django.db.models import Count
from django.views.generic import TemplateView
from pretix.presale.views import EventViewMixin, CartDisplayMixin
class EventIndex(EventViewMixin, CartDisplayMixin, TemplateView):
template_name = "pretixpresale/event/index.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Fetch all items
items = self.request.event.items.all().select_related(
'category', # for re-grouping
).prefetch_related(
'properties', # for .get_all_available_variations()
'quotas', 'variations__quotas' # for .availability()
).annotate(quotac=Count('quotas')).filter(
quotac__gt=0
).order_by('category__position', 'category_id', 'name')
for item in items:
item.available_variations = sorted(item.get_all_available_variations(),
key=lambda vd: vd.ordered_values())
item.has_variations = (len(item.available_variations) != 1
or not item.available_variations[0].empty())
if not item.has_variations:
item.cached_availability = list(item.check_quotas())
item.cached_availability[1] = min(item.cached_availability[1],
self.request.event.max_items_per_order)
item.price = item.available_variations[0]['price']
else:
for var in item.available_variations:
var.cached_availability = list(var['variation'].check_quotas())
var.cached_availability[1] = min(var.cached_availability[1],
self.request.event.max_items_per_order)
# Regroup those by category
context['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_id == cat.identity])
for cat in set([i.category for i in items]) # insert categories into a set for uniqueness
], key=lambda group: (group[0].position, group[0].pk)) # a set is unsorted, so sort again by category
context['cart'] = self.get_cart()
return context