diff --git a/src/pretix/base/migrations/0018_auto_20150314_1232.py b/src/pretix/base/migrations/0018_auto_20150314_1232.py new file mode 100644 index 0000000000..e9311f609a --- /dev/null +++ b/src/pretix/base/migrations/0018_auto_20150314_1232.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +import versions.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0017_auto_20150308_1507'), + ] + + operations = [ + migrations.AlterField( + model_name='order', + name='status', + field=models.CharField(verbose_name='Status', choices=[('p', 'pending'), ('n', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3), + ), + migrations.AlterField( + model_name='orderposition', + name='order', + field=versions.models.VersionedForeignKey(verbose_name='Order', to='pretixbase.Order', related_name='positions'), + ), + ] diff --git a/src/pretix/base/models.py b/src/pretix/base/models.py index 79bc6035e6..3b5d9f737b 100644 --- a/src/pretix/base/models.py +++ b/src/pretix/base/models.py @@ -1,3 +1,4 @@ +from datetime import datetime from itertools import product import copy import uuid @@ -1327,6 +1328,19 @@ class Order(Versionable): self.code = code return + @property + def can_modify_answers(self): + if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED): + return False + modify_deadline = self.event.settings.get('last_order_modification_date', as_type=datetime) + if modify_deadline is not None and now() > modify_deadline: + return False + ask_names = self.event.settings.get('attendee_names_asked', as_type=bool) + for cp in self.positions.all().prefetch_related('item__questions'): + if (cp.item.admission and ask_names) or cp.item.questions.all(): + return True + return False # nothing there to modify + class QuestionAnswer(Versionable): """ @@ -1376,7 +1390,8 @@ class OrderPosition(ObjectWithAnswers, Versionable): """ order = VersionedForeignKey( Order, - verbose_name=_("Order") + verbose_name=_("Order"), + related_name='positions' ) item = VersionedForeignKey( Item, diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index ab600997f4..6f77802e91 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1,6 +1,8 @@ +from datetime import datetime, date, time import json import decimal +import dateutil.parser from django.db.models import Model from versions.models import Versionable @@ -11,6 +13,7 @@ DEFAULTS = { 'attendee_names_asked': 'True', 'attendee_names_required': 'False', 'reservation_time': '30', + 'last_order_modification_date': None, } @@ -51,6 +54,12 @@ class SettingsProxy: return json.loads(value) elif as_type == bool: return value == 'True' + elif as_type == datetime: + return dateutil.parser.parse(value) + elif as_type == date: + return dateutil.parser.parse(value).date() + elif as_type == time: + return dateutil.parser.parse(value).time() elif as_type == decimal.Decimal: return decimal.Decimal(value) elif issubclass(as_type, Versionable): @@ -67,6 +76,8 @@ class SettingsProxy: return str(value) elif isinstance(value, list) or isinstance(value, dict): return json.dumps(value) + elif isinstance(value, datetime) or isinstance(value, date) or isinstance(value, time): + return value.isoformat() elif isinstance(value, Versionable): return value.identity elif isinstance(value, Model): diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html index 206f7fe5fb..b9485f7a2e 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html @@ -12,16 +12,16 @@

- - {{ form.cartpos.item }} - {% if form.cartpos.variation %} - – {{ form.cartpos.variation }} + {{ form.pos.item }} + {% if form.pos.variation %} + – {{ form.pos.variation }} {% endif %}

-
{% bootstrap_form form layout="horizontal" %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index cfe0f4ef0a..a6527b83e3 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -1,7 +1,7 @@ {% load i18n %} {% for line in cart.positions %}
-
+
{{ line.item }} {% if line.variation %} – {{ line.variation }} @@ -17,41 +17,40 @@
{% if q.answer %}{{ q.answer }}{% else %}{% trans "not answered" %}{% endif %}
{% endfor %} - {% else %} -
-
- {{ event.currency }} {{ line.price|floatformat:2 }} -
-
- {% if editable %} -
- {% csrf_token %} - {% if line.variation %} - - {% else %} - - {% endif %} - -
- {% endif %} - {{ line.count }} - {% if editable %} -
+
+ {{ event.currency }} {{ line.price|floatformat:2 }} +
+
+ {% if editable %} + - {% csrf_token %} - {% if line.variation %} - + {% else %} + - {% else %} - - {% endif %} - - {% endif %} + + + {% endif %} + {{ line.count }} + {% if editable %} +
+ {% csrf_token %} + {% if line.variation %} + + {% else %} + + {% endif %} + +
{% endif %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html index c0dba2b6e1..100e011fc7 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order.html +++ b/src/pretix/presale/templates/pretixpresale/event/order.html @@ -36,6 +36,13 @@ {% endif %}
+ {% if order.can_modify_answers %} + + {% endif %}

{% trans "Ordered items" %}

diff --git a/src/pretix/presale/templates/pretixpresale/event/order_modify.html b/src/pretix/presale/templates/pretixpresale/event/order_modify.html new file mode 100644 index 0000000000..e140301c81 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/order_modify.html @@ -0,0 +1,51 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Modify order" %}{% endblock %} +{% block content %} +

+ {% blocktrans trimmed with code=order.code %} + Modify order: {{ code }} + {% endblocktrans %} +

+
+ {% csrf_token %} +
+ {% for form in forms %} +
+ +
+
+ {% bootstrap_form form layout="horizontal" %} +
+
+
+ {% endfor %} +
+
+ +
+ +
+
+
+
+{% endblock %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 228344c40b..11853e7831 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -20,6 +20,8 @@ urlpatterns = [ name='event.order'), url(r'^order/(?P[^/]+)/cancel$', pretix.presale.views.order.OrderCancel.as_view(), name='event.order.cancel'), + url(r'^order/(?P[^/]+)/modify$', pretix.presale.views.order.OrderModify.as_view(), + name='event.order.modify'), url(r'^login$', pretix.presale.views.event.EventLogin.as_view(), name='event.checkout.login'), ])), ] diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 8060c73d46..62761c46de 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -36,7 +36,7 @@ class EventLoginRequiredMixin: class CartDisplayMixin: @cached_property - def cartpos(self): + def positions(self): """ A list of this users cart position """ diff --git a/src/pretix/presale/views/checkout.py b/src/pretix/presale/views/checkout.py index 15a285d18f..4ad6204822 100644 --- a/src/pretix/presale/views/checkout.py +++ b/src/pretix/presale/views/checkout.py @@ -29,8 +29,9 @@ class QuestionsForm(forms.Form): :param cartpos: The cart position the form should be for :param event: The event this belongs to """ - cartpos = kwargs.pop('cartpos') - item = cartpos.item + cartpos = kwargs.pop('cartpos', None) + orderpos = kwargs.pop('orderpos', None) + item = cartpos.item if cartpos else orderpos.item questions = list(item.questions.all()) event = kwargs.pop('event') @@ -40,12 +41,16 @@ class QuestionsForm(forms.Form): self.fields['attendee_name'] = forms.CharField( max_length=255, required=(event.settings.attendee_names_required == 'True'), label=_('Attendee name'), - initial=cartpos.attendee_name + initial=(cartpos.attendee_name if cartpos else orderpos.attendee_name) ) for q in questions: # Do we already have an answer? Provide it as the initial value - answers = [a for a in cartpos.answers.all() if a.question_id == q.identity] + answers = [ + a for a + in (cartpos.answers.all() if cartpos else orderpos.answers.all()) + if a.question_id == q.identity + ] if answers: initial = answers[0].answer else: @@ -112,8 +117,7 @@ class CheckoutView(TemplateView): }) -class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, CheckoutView): - template_name = "pretixpresale/event/checkout_questions.html" +class QuestionsViewMixin: @cached_property def forms(self): @@ -123,20 +127,23 @@ class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, C submitted at once. """ formlist = [] - for cr in self.cartpos: + for cr in self.positions: + cartpos = cr if isinstance(cr, CartPosition) else None + orderpos = cr if isinstance(cr, OrderPosition) else None form = QuestionsForm(event=self.request.event, prefix=cr.identity, - cartpos=cr, + cartpos=cartpos, + orderpos=orderpos, data=(self.request.POST if self.request.method == 'POST' else None)) - form.cartpos = cr + form.pos = cartpos or orderpos if len(form.fields) > 0: formlist.append(form) return formlist - def post(self, *args, **kwargs): + def save(self): failed = False for form in self.forms: - # Every form represents a CartPosition with questions attached + # Every form represents a CartPosition or OrderPosition with questions attached if not form.is_valid(): failed = True else: @@ -144,9 +151,9 @@ class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, C # answers to the questions / in the CartPosition object for k, v in form.cleaned_data.items(): if k == 'attendee_name': - form.cartpos = form.cartpos.clone() - form.cartpos.attendee_name = v if v != '' else None - form.cartpos.save() + form.pos = form.pos.clone() + form.pos.attendee_name = v if v != '' else None + form.pos.save() elif k.startswith('question_') and v is not None: field = form.fields[k] if hasattr(field, 'answer'): @@ -160,10 +167,20 @@ class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, C field.answer.save() elif v != '': QuestionAnswer.objects.create( - cartposition=form.cartpos, + cartposition=(form.pos if isinstance(form.pos, CartPosition) else None), + orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None), question=field.question, answer=v ) + return not failed + + +class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, + QuestionsViewMixin, CheckoutView): + template_name = "pretixpresale/event/checkout_questions.html" + + def post(self, *args, **kwargs): + failed = not self.save() if failed: messages.error(self.request, _("We had difficulties processing your input. Please review the errors below.")) @@ -171,7 +188,7 @@ class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, C return redirect(self.get_payment_url()) def get(self, *args, **kwargs): - if not self.cartpos: + if not self.positions: messages.error(self.request, _("Your cart is empty")) return redirect(self.get_index_url()) @@ -267,7 +284,7 @@ class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, Ch return provider def check_process(self, request): - if len(self.cartpos) == 0: + if len(self.positions) == 0: messages.warning(request, _('Your cart is empty.')) return redirect(self.get_index_url()) if 'payment' not in request.session or not self.payment_provider: @@ -276,7 +293,7 @@ class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, Ch if not self.payment_provider.checkout_is_valid_session(request): messages.error(request, _('The payment information you entered was incomplete.')) return redirect(self.get_payment_url()) - for cp in self.cartpos: + for cp in self.positions: answ = { aw.question_id: aw.answer for aw in cp.answers.all() } @@ -307,7 +324,7 @@ class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, Ch quotas_locked = set() try: - cartpos = self.cartpos + cartpos = self.positions for i, cp in enumerate(cartpos): quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all()) if cp.expires < dt: diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 96d362a9dc..29ad10e404 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -1,6 +1,9 @@ +from datetime import datetime from itertools import groupby +from django.contrib import messages from django.core.urlresolvers import reverse from django.shortcuts import redirect +from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from django.utils.functional import cached_property from django.views.generic import TemplateView @@ -8,6 +11,7 @@ from django.http import HttpResponseNotFound, HttpResponseForbidden from pretix.base.models import Order, OrderPosition from pretix.base.signals import register_payment_providers from pretix.presale.views import EventViewMixin, EventLoginRequiredMixin, CartDisplayMixin +from pretix.presale.views.checkout import QuestionsViewMixin class OrderDetailMixin: @@ -34,46 +38,6 @@ class OrderDetails(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin, return HttpResponseNotFound(_('Unknown order code or order does belong to another user.')) return super().get(request, *args, **kwargs) - def itemlist_cartlike(self): - """ - Returns the list of ordered items a format compatible to the - CardDisplayMixin, so we can reuse template code - """ - cartpos = OrderPosition.objects.current.filter( - order=self.order, - ).order_by( - 'item', 'variation' - ).select_related( - 'item', 'variation' - ).prefetch_related( - 'variation__values', 'variation__values__prop', - 'item__questions' - ) - - # 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): - if (pos.item.admission and self.request.event.settings.attendee_names_asked == 'True') \ - or pos.item.questions.all(): - return pos.id, "", "", "" - return "", pos.item_id, pos.variation_id, pos.price - - positions = [] - for k, g in groupby(sorted(list(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, - 'raw': cartpos, - 'total': self.order.total, - 'payment_fee': self.order.payment_fee, - } - @cached_property def payment_provider(self): responses = register_payment_providers.send(self.request.event) @@ -96,6 +60,55 @@ class OrderDetails(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin, return ctx +class OrderModify(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin, + QuestionsViewMixin, TemplateView): + template_name = "pretixpresale/event/order_modify.html" + + @cached_property + def positions(self): + return list(self.order.positions.order_by( + 'item', 'variation' + ).select_related( + 'item', 'variation' + ).prefetch_related( + 'variation__values', 'variation__values__prop', + 'item__questions', 'answers' + )) + + def post(self, request, *args, **kwargs): + self.request = request + self.kwargs = kwargs + if not self.order: + return HttpResponseNotFound(_('Unknown order code or order does belong to another user.')) + if not self.order.can_modify_answers: + return HttpResponseForbidden(_('You cannot modify this order')) + failed = not self.save() + if failed: + messages.error(self.request, + _("We had difficulties processing your input. Please review the errors below.")) + return self.get(*args, **kwargs) + return redirect(reverse('presale:event.order', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + 'order': self.order.code, + })) + + def get(self, request, *args, **kwargs): + self.request = request + self.kwargs = kwargs + if not self.order: + return HttpResponseNotFound(_('Unknown order code or order does belong to another user.')) + if not self.order.can_modify_answers: + return HttpResponseForbidden(_('You cannot modify this order')) + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['order'] = self.order + ctx['forms'] = self.forms + return ctx + + class OrderCancel(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin, TemplateView): template_name = "pretixpresale/event/order_cancel.html" @@ -104,7 +117,7 @@ class OrderCancel(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin, self.kwargs = kwargs if not self.order: return HttpResponseNotFound(_('Unknown order code or order does belong to another user.')) - if self.order.status != Order.STATUS_PENDING: + if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_EXPIRED): return HttpResponseForbidden(_('You cannot cancel this order')) order = self.order.clone() order.status = Order.STATUS_CANCELLED @@ -119,7 +132,7 @@ class OrderCancel(EventViewMixin, EventLoginRequiredMixin, OrderDetailMixin, self.kwargs = kwargs if not self.order: return HttpResponseNotFound(_('Unknown order code or order does belong to another user.')) - if self.order.status != Order.STATUS_PENDING: + if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_EXPIRED): return HttpResponseForbidden(_('You cannot cancel this order')) return super().get(request, *args, **kwargs) diff --git a/src/requirements/production.txt b/src/requirements/production.txt index b1133f9d1a..629a7d5e52 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -1,5 +1,6 @@ # Functional requirements Django==1.8b2 +python-dateutil pytz django-bootstrap3 -e git+https://github.com/pretix/django-formset-js.git@master#egg=django-formset-js diff --git a/src/tests/base/test_settings.py b/src/tests/base/test_settings.py index 0632518f04..8763d4ca59 100644 --- a/src/tests/base/test_settings.py +++ b/src/tests/base/test_settings.py @@ -1,3 +1,4 @@ +from datetime import datetime, time, date from decimal import Decimal from django.test import TestCase from django.utils.timezone import now @@ -95,6 +96,15 @@ class SettingsTestCase(TestCase): def test_serialize_int(self): self._test_serialization(2, int) + def test_serialize_datetime(self): + self._test_serialization(now(), datetime) + + def test_serialize_time(self): + self._test_serialization(now().time(), time) + + def test_serialize_date(self): + self._test_serialization(now().date(), date) + def test_serialize_decimal(self): self._test_serialization(Decimal('2.3'), Decimal)