Add support for reserved seating (#1228)

* Initial work on seating

* Add seat guids

* Add product_list_top

* CartAdd: Ignore item when a seat is passed

* Cart display

* product_list_top → render_seating_plan

* Render seating plan in voucher redemption

* Fix failing tests

* Add tests for extending cart positions with seats

* Add subevent_forms to docs

* Update schema, migrations

* Dealing with expired orders

* steps to order change

* Change order positions

* Allow to add seats

* tests for ocm

* Fix things after rebase

* Seating plans API

* Add more tests for cart behaviour

* Widget support

* Adjust widget tests

* Re-enable CSP

* Update schema

* Api: position.seat

* Add guid to word list

* API: (sub)event.seating_plan

* Vali fixes

* Fix api

* Fix reference in test

* Fix test for real
This commit is contained in:
Raphael Michel
2019-06-25 11:00:03 +02:00
committed by GitHub
parent f79d17cb6a
commit 93089d87e3
77 changed files with 3689 additions and 164 deletions

View File

@@ -222,6 +222,18 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
receivers are expected to return HTML.
"""
render_seating_plan = EventPluginSignal(
providing_args=["request", "subevent", "voucher"]
)
"""
This signal is sent out to render a seating plan, if one is configured for the specific event.
You will be passed the ``request`` as a keyword argument. If applicable, a ``subevent`` or
``voucher`` argument might be given.
As with all plugin signals, the ``sender`` keyword argument will contain the event. The
receivers are expected to return HTML.
"""
front_page_bottom = EventPluginSignal(
providing_args=[]
)

View File

@@ -19,20 +19,7 @@
{% endcompress %}
{% endif %}
{% compress js %}
<script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script>
<script type="text/javascript" src="{% static "moment/moment-with-locales.js" %}"></script>
<script type="text/javascript" src="{% static "js/jquery.formset.js" %}"></script>
<script type="text/javascript" src="{% static "bootstrap/js/bootstrap.js" %}"></script>
<script type="text/javascript" src="{% static "datetimepicker/bootstrap-datetimepicker.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/jquery.qrcode.min.js" %}"></script>
<script type="text/javascript" src="{% static "pretixpresale/js/ui/questions.js" %}"></script>
<script type="text/javascript" src="{% static "pretixpresale/js/ui/main.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script>
<script type="text/javascript" src="{% static "pretixpresale/js/ui/cart.js" %}"></script>
<script type="text/javascript" src="{% static "lightbox/js/lightbox.min.js" %}"></script>
{% endcompress %}
{% include "pretixpresale/fragment_js.html" %}
<meta name="referrer" content="origin">
{{ html_head|safe }}
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0">
@@ -79,20 +66,7 @@
{% include "pretixpresale/base_footer.html" %}
</footer>
</div>
<div id="ajaxerr">
</div>
<div id="loadingmodal">
<div class="modal-card">
<div class="modal-card-icon">
<i class="fa fa-cog big-rotating-icon"></i>
</div>
<div class="modal-card-content">
<h3></h3>
<p class="text"></p>
<p class="status">{% trans "If this takes longer than a few minutes, please contact us." %}</p>
</div>
</div>
</div>
{% include "pretixpresale/fragment_modals.html" %}
{% if DEBUG %}
<script type="text/javascript" src="{% url 'javascript-catalog' lang=request.LANGUAGE_CODE %}" async></script>
{% else %}

View File

@@ -13,11 +13,20 @@
{% if line.variation %}
{{ line.variation }}
{% endif %}
{% if line.seat %}
<br />
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="14" viewBox="0 0 4.7624999 3.7041668" class="svg-icon">
<path
style="fill:black"
d="m 1.9592032,1.8522629e-4 c -0.21468,0 -0.38861,0.17394000371 -0.38861,0.38861000371 0,0.21466 0.17393,0.38861 0.38861,0.38861 0.21468,0 0.3886001,-0.17395 0.3886001,-0.38861 0,-0.21467 -0.1739201,-0.38861000371 -0.3886001,-0.38861000371 z m 0.1049,0.84543000371 c -0.20823,-0.0326 -0.44367,0.12499 -0.39998,0.40462997 l 0.20361,1.01854 c 0.0306,0.15316 0.15301,0.28732 0.3483,0.28732 h 0.8376701 v 0.92708 c 0,0.29313 0.41187,0.29447 0.41187,0.005 v -1.19115 c 0,-0.14168 -0.0995,-0.29507 -0.29094,-0.29507 l -0.65578,-10e-4 -0.1757,-0.87644 C 2.3042533,0.95300523 2.1890432,0.86500523 2.0641032,0.84547523 Z m -0.58549,0.44906997 c -0.0946,-0.0134 -0.20202,0.0625 -0.17829,0.19172 l 0.18759,0.91054 c 0.0763,0.33956 0.36802,0.55914 0.66042,0.55914 h 0.6015201 c 0.21356,0 0.21448,-0.32143 -0.003,-0.32143 H 2.1954632 c -0.19911,0 -0.36364,-0.11898 -0.41341,-0.34107 l -0.17777,-0.87126 c -0.0165,-0.0794 -0.0688,-0.11963 -0.12557,-0.12764 z"/>
</svg>
{{ line.seat }}
{% endif %}
{% if line.voucher %}
<br /><span class="fa fa-tags"></span> {% trans "Voucher code used:" %} {{ line.voucher.code }}
<br /><span class="fa fa-tags fa-fw"></span> {% trans "Voucher code used:" %} {{ line.voucher.code }}
{% endif %}
{% if line.subevent %}
<br /><span class="fa fa-calendar"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display }}
<br /><span class="fa fa-calendar fa-fw"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display }}
{% if event.settings.show_times %}
<span class="fa fa-clock-o"></span>
{{ line.subevent.date_from|date:"TIME_FORMAT" }}
@@ -116,7 +125,7 @@
<input type="hidden" name="price_{{ line.item.id }}"
value="{% if event.settings.display_net_prices %}{{ line.net_price }}{% else %}{{ line.price }}{% endif %}" />
{% endif %}
<button class="btn btn-mini btn-link" title="{% trans "Add one more" %}">
<button class="btn btn-mini btn-link" title="{% trans "Add one more" %}" {% if line.seat %}disabled{% endif %}>
<i class="fa fa-plus"></i>
</button>
</form>

View File

@@ -141,7 +141,7 @@
</div>
{% elif var.cached_availability.0 == 100 %}
<div class="col-md-2 col-xs-6 availability-box available">
{% if item.max_per_order == 1 %}
{% if item.max_per_order == 1 %}
<label class="item-checkbox-label">
<input type="checkbox" value="1"
{% if not ev.presale_is_running %}disabled{% endif %}
@@ -255,7 +255,7 @@
</div>
{% elif item.cached_availability.0 == 100 %}
<div class="col-md-2 col-xs-6 availability-box available">
{% if item.max_per_order == 1 %}
{% if item.max_per_order == 1 %}
<label class="item-checkbox-label">
<input type="checkbox" value="1" {% if itemnum == 1 %}checked{% endif %}
{% if not ev.presale_is_running %}disabled{% endif %}

View File

@@ -219,6 +219,13 @@
action="{% eventurl request.event "presale:event.cart.add" cart_namespace=cart_namespace %}?next={{ cart_redirect|urlencode }}">
{% csrf_token %}
<input type="hidden" name="subevent" value="{{ subevent.id|default_if_none:"" }}" />
{% if ev.seating_plan_id %}
{% if event.has_subevents %}
{% eventsignal event "pretix.presale.signals.render_seating_plan" request=request subevent=subevent %}
{% else %}
{% eventsignal event "pretix.presale.signals.render_seating_plan" request=request %}
{% endif %}
{% endif %}
{% include "pretixpresale/event/fragment_product_list.html" %}
{% if ev.presale_is_running and display_add_to_cart %}
<section class="front-page">

View File

@@ -0,0 +1,47 @@
{% load i18n %}
{% load static %}
{% load eventurl %}
{% load compress %}
{% load eventsignal %}
{% load statici18n %}
<!DOCTYPE html>
<html>
<head>
{% if css_file %}
<link rel="stylesheet" type="text/css" href="{{ css_file }}"/>
{% else %}
{% compress css %}
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixpresale/scss/main.scss" %}"/>
{% endcompress %}
{% endif %}
{% include "pretixpresale/fragment_js.html" %}
<meta name="referrer" content="origin">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0">
</head>
<body class="full-screen-seating" data-locale="{{ request.LANGUAGE_CODE }}">
<form method="post" data-asynctask
data-asynctask-headline="{% trans "We're now trying to reserve this for you!" %}"
data-asynctask-text="{% blocktrans with time=event.settings.reservation_time %}Once the items are in your cart, you will have {{ time }} minutes to complete your purchase.{% endblocktrans %}"
action="{% eventurl request.event "presale:event.cart.add" cart_namespace=cart_namespace %}?next={{ cart_redirect|urlencode }}">
{% csrf_token %}
<input type="hidden" name="subevent" value="{{ subevent.id|default_if_none:"" }}"/>
{% if event.has_subevents %}
{% eventsignal event "pretix.presale.signals.render_seating_plan" request=request subevent=subevent %}
{% else %}
{% eventsignal event "pretix.presale.signals.render_seating_plan" request=request %}
{% endif %}
</form>
{% include "pretixpresale/fragment_modals.html" %}
{% if DEBUG %}
<script type="text/javascript" src="{% url 'javascript-catalog' lang=request.LANGUAGE_CODE %}" async></script>
{% else %}
<script src="{% statici18n LANGUAGE_CODE %}" async></script>
{% endif %}
{% if request.session.iframe_session %}
{% compress js %}
<script type="text/javascript" src="{% static "iframeresizer/iframeResizer.contentWindow.js" %}"></script>
{% endcompress %}
{% endif %}
{{ html_foot|safe }}
</body>
</html>

View File

@@ -29,6 +29,14 @@
<input type="hidden" name="subevent" value="{{ subevent.id|default_if_none:"" }}" />
<input type="hidden" name="_voucher_code" value="{{ voucher.code }}">
{% if voucher.seating_available %}
{% if event.has_subevents %}
{% eventsignal event "pretix.presale.signals.render_seating_plan" request=request subevent=subevent voucher=voucher %}
{% else %}
{% eventsignal event "pretix.presale.signals.render_seating_plan" request=request voucher=voucher %}
{% endif %}
{% endif %}
{% for tup in items_by_category %}
<section>
{% if tup.0 %}

View File

@@ -0,0 +1,16 @@
{% load static %}
{% load compress %}
{% compress js %}
<script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script>
<script type="text/javascript" src="{% static "moment/moment-with-locales.js" %}"></script>
<script type="text/javascript" src="{% static "js/jquery.formset.js" %}"></script>
<script type="text/javascript" src="{% static "bootstrap/js/bootstrap.js" %}"></script>
<script type="text/javascript" src="{% static "datetimepicker/bootstrap-datetimepicker.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/jquery.qrcode.min.js" %}"></script>
<script type="text/javascript" src="{% static "pretixpresale/js/ui/questions.js" %}"></script>
<script type="text/javascript" src="{% static "pretixpresale/js/ui/main.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/asynctask.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/details.js" %}"></script>
<script type="text/javascript" src="{% static "pretixpresale/js/ui/cart.js" %}"></script>
<script type="text/javascript" src="{% static "lightbox/js/lightbox.min.js" %}"></script>
{% endcompress %}

View File

@@ -0,0 +1,15 @@
{% load i18n %}
<div id="ajaxerr">
</div>
<div id="loadingmodal">
<div class="modal-card">
<div class="modal-card-icon">
<i class="fa fa-cog big-rotating-icon"></i>
</div>
<div class="modal-card-content">
<h3></h3>
<p class="text"></p>
<p class="status">{% trans "If this takes longer than a few minutes, please contact us." %}</p>
</div>
</div>
</div>

View File

@@ -27,6 +27,10 @@ frame_wrapped_urls = [
name='event.checkout'),
url(r'^redeem/?$', pretix.presale.views.cart.RedeemView.as_view(),
name='event.redeem'),
url(r'^seatingframe/$', pretix.presale.views.event.SeatingPlanView.as_view(),
name='event.seatingplan'),
url(r'^(?P<subevent>[0-9]+)/seatingframe/$', pretix.presale.views.event.SeatingPlanView.as_view(),
name='event.seatingplan'),
url(r'^(?P<subevent>[0-9]+)/$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'),
url(r'^waitinglist', pretix.presale.views.waiting.WaitingView.as_view(), name='event.waitinglist'),
url(r'^$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'),

View File

@@ -59,7 +59,7 @@ class CartMixin:
cartpos = queryset.order_by(
'item__category__position', 'item__category_id', 'item__position', 'item__name', 'variation__value'
).select_related(
'item', 'variation', 'addon_to', 'subevent', 'subevent__event', 'subevent__event__organizer'
'item', 'variation', 'addon_to', 'subevent', 'subevent__event', 'subevent__event__organizer', 'seat'
).prefetch_related(
*prefetch
)
@@ -103,13 +103,13 @@ class CartMixin:
)
addon_penalty = 1 if pos.addon_to else 0
if downloads or pos.pk in has_addons or pos.addon_to:
return i, addon_penalty, pos.pk, 0, 0, 0, 0, (pos.subevent_id or 0)
return i, addon_penalty, pos.pk, 0, 0, 0, 0, (pos.subevent_id or 0), pos.seat_id
if answers and (has_attendee_data or pos.item.questions.all()):
return i, addon_penalty, pos.pk, 0, 0, 0, 0, (pos.subevent_id or 0)
return i, addon_penalty, pos.pk, 0, 0, 0, 0, (pos.subevent_id or 0), pos.seat_id
return (
0, addon_penalty, 0, pos.item_id, pos.variation_id, pos.price, (pos.voucher_id or 0),
(pos.subevent_id or 0)
(pos.subevent_id or 0), pos.seat_id
)
positions = []

View File

@@ -90,10 +90,26 @@ class CartActionMixin:
if value.strip() == '' or '_' not in key:
return
if not key.startswith('item_') and not key.startswith('variation_'):
if not key.startswith('item_') and not key.startswith('variation_') and not key.startswith('seat_'):
return
parts = key.split("_")
price = self.request.POST.get('price_' + "_".join(parts[1:]), "")
if key.startswith('seat_'):
try:
return {
'item': int(parts[1]),
'variation': int(parts[2]) if len(parts) > 2 else None,
'count': 1,
'seat': value,
'price': price,
'voucher': voucher,
'subevent': self.request.POST.get("subevent")
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
try:
amount = int(value)
except ValueError:
@@ -103,7 +119,6 @@ class CartActionMixin:
elif amount == 0:
return
price = self.request.POST.get('price_' + "_".join(parts[1:]), "")
if key.startswith('item_'):
try:
return {
@@ -131,8 +146,7 @@ class CartActionMixin:
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)
Parses the POST data and returns a list of dictionaries
"""
# Compatibility patch that makes the frontend code a lot easier

View File

@@ -7,7 +7,7 @@ from importlib import import_module
import pytz
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db.models import Count, Prefetch
from django.db.models import Count, Exists, OuterRef, Prefetch
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator
@@ -17,7 +17,7 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView
from pretix.base.models import ItemVariation, Quota
from pretix.base.models import ItemVariation, Quota, SeatCategoryMapping
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemBundle
from pretix.multidomain.urlreverse import eventreverse
@@ -49,7 +49,7 @@ def item_group_by_category(items):
)
def get_grouped_items(event, subevent=None, voucher=None, channel='web'):
def get_grouped_items(event, subevent=None, voucher=None, channel='web', require_seat=0):
items = event.items.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher).select_related(
'category', 'tax_rule', # for re-grouping
).prefetch_related(
@@ -81,10 +81,20 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web'):
).distinct()),
).annotate(
quotac=Count('quotas'),
has_variations=Count('variations')
has_variations=Count('variations'),
requires_seat=Exists(
SeatCategoryMapping.objects.filter(
product_id=OuterRef('pk'),
subevent=subevent
)
)
).filter(
quotac__gt=0
quotac__gt=0,
).order_by('category__position', 'category_id', 'position', 'name')
if require_seat:
items = items.filter(requires_seat__gt=0)
else:
items = items.filter(requires_seat=0)
display_add_to_cart = False
external_quota_cache = event.cache.get('item_quota_cache')
quota_cache = external_quota_cache or {}
@@ -342,6 +352,52 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
return context
@method_decorator(allow_frame_if_namespaced, 'dispatch')
@method_decorator(iframe_entry_view_wrapper, 'dispatch')
class SeatingPlanView(EventViewMixin, TemplateView):
template_name = "pretixpresale/event/seatingplan.html"
def get(self, request, *args, **kwargs):
from pretix.presale.views.cart import get_or_create_cart_id
self.subevent = None
if request.GET.get('src', '') == 'widget' and 'take_cart_id' in request.GET:
# User has clicked "Open in a new tab" link in widget
get_or_create_cart_id(request)
return redirect(eventreverse(request.event, 'presale:event.seatingplan', kwargs=kwargs))
elif request.GET.get('iframe', '') == '1' and 'take_cart_id' in request.GET:
# Widget just opened, a cart already exists. Let's to a stupid redirect to check if cookies are disabled
get_or_create_cart_id(request)
return redirect(eventreverse(request.event, 'presale:event.seatingplan', kwargs=kwargs) + '?require_cookie=true&cart_id={}'.format(
request.GET.get('take_cart_id')
))
elif request.GET.get('iframe', '') == '1' and len(self.request.GET.get('widget_data', '{}')) > 3:
# We've been passed data from a widget, we need to create a cart session to store it.
get_or_create_cart_id(request)
if request.event.has_subevents:
if 'subevent' in kwargs:
self.subevent = request.event.subevents.using(settings.DATABASE_REPLICA).filter(pk=kwargs['subevent'], active=True).first()
if not self.subevent or not self.subevent.seating_plan:
raise Http404()
return super().get(request, *args, **kwargs)
else:
raise Http404()
else:
if 'subevent' in kwargs or not request.event.seating_plan:
raise Http404()
else:
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['cart_redirect'] = eventreverse(self.request.event, 'presale:event.checkout.start',
kwargs={'cart_namespace': kwargs.get('cart_namespace') or ''})
if context['cart_redirect'].startswith('https:'):
context['cart_redirect'] = '/' + context['cart_redirect'].split('/', 3)[3]
return context
class EventIcalDownload(EventViewMixin, View):
def get(self, request, *args, **kwargs):
if not self.request.event:

View File

@@ -515,6 +515,8 @@ class WidgetAPIProductList(EventListMixin, View):
data['display_add_to_cart'] = False
data['itemnum'] = 0
data['has_seating_plan'] = ev.seating_plan is not None
vouchers_exist = self.request.event.get_cache().get('vouchers_exist')
if vouchers_exist is None:
vouchers_exist = self.request.event.vouchers.exists()