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
+11
View File
@@ -36,12 +36,20 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
seat objects The assigned seat. Can be ``null``.
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
└ seat_guid string Identifier of the seat within the seating plan
===================================== ========================== =======================================================
.. versionchanged:: 1.17
This resource has been added.
.. versionchanged:: 3.0
This ``seat`` attribute has been added.
Cart position endpoints
-----------------------
@@ -87,6 +95,7 @@ Cart position endpoints
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
"includes_tax": true,
"seat": null,
"answers": []
}
]
@@ -132,6 +141,7 @@ Cart position endpoints
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
"includes_tax": true,
"seat": null,
"answers": []
}
@@ -178,6 +188,7 @@ Cart position endpoints
* ``item``
* ``variation`` (optional)
* ``price``
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
* ``attendee_name`` **or** ``attendee_name_parts`` (optional)
* ``attendee_email`` (optional)
* ``subevent`` (optional)
+2
View File
@@ -396,6 +396,7 @@ Order position endpoints
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 1,
@@ -505,6 +506,7 @@ Order position endpoints
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 1,
+23 -1
View File
@@ -27,9 +27,13 @@ presale_end datetime The date at whi
location multi-lingual string The event location (or ``null``)
has_subevents boolean ``true`` if the event series feature is active for this
event. Cannot change after event is created.
meta_data dict Values set for organizer-specific meta data parameters.
meta_data object Values set for organizer-specific meta data parameters.
plugins list A list of package names of the enabled plugins for this
event.
seating_plan integer If reserved seating is in use, the ID of a seating
plan. Otherwise ``null``.
seat_category_mapping object An object mapping categories of the seating plan
(strings) to items in the event (integers or ``null``).
===================================== ========================== =======================================================
@@ -54,6 +58,10 @@ plugins list A list of packa
When cloning events, the ``testmode`` attribute will now be cloned, too.
.. versionchanged:: 3.0
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
Endpoints
---------
@@ -99,6 +107,8 @@ Endpoints
"location": null,
"has_subevents": false,
"meta_data": {},
"seating_plan": null,
"seat_category_mapping": {},
"plugins": [
"pretix.plugins.banktransfer"
"pretix.plugins.stripe"
@@ -160,6 +170,8 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {},
"plugins": [
"pretix.plugins.banktransfer"
@@ -205,6 +217,8 @@ Endpoints
"is_public": false,
"presale_start": null,
"presale_end": null,
"seating_plan": null,
"seat_category_mapping": {},
"location": null,
"has_subevents": false,
"meta_data": {},
@@ -235,6 +249,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"has_subevents": false,
"meta_data": {},
"plugins": [
@@ -284,6 +300,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"has_subevents": false,
"meta_data": {},
"plugins": [
@@ -314,6 +332,8 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {},
"plugins": [
"pretix.plugins.stripe",
@@ -375,6 +395,8 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {},
"plugins": [
"pretix.plugins.banktransfer",
+1
View File
@@ -23,4 +23,5 @@ Resources and endpoints
waitinglist
carts
webhooks
seatingplans
billing_invoices
+14 -1
View File
@@ -176,6 +176,10 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
seat objects The assigned seat. Can be ``null``.
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
└ seat_guid string Identifier of the seat within the seating plan
pdf_data object Data object required for ticket PDF generation. By default,
this field is missing. It will be added only if you add the
``pdf_data=true`` query parameter to your request.
@@ -197,6 +201,10 @@ pdf_data object Data object req
The attributes ``pseudonymization_id`` and ``pdf_data`` have been added.
.. versionchanged:: 3.0
The attribute ``seat`` has been added.
.. _order-payment-resource:
Order payment resource
@@ -328,6 +336,7 @@ List of all orders
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 44,
@@ -470,6 +479,7 @@ Fetching individual orders
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 44,
@@ -737,7 +747,7 @@ Creating orders
then call the ``mark_paid`` API method.
* ``testmode`` (optional) Defaults to ``false``
* ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the
order creation is successful. Any quotas that become free by this operation will be credited to your order
order creation is successful. Any quotas or seats that become free by this operation will be credited to your order
creation.
* ``email``
* ``locale``
@@ -771,6 +781,7 @@ Creating orders
* ``item``
* ``variation``
* ``price``
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
* ``attendee_name`` **or** ``attendee_name_parts``
* ``attendee_email``
* ``secret`` (optional)
@@ -1287,6 +1298,7 @@ List of all order positions
"tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"addon_to": null,
"subevent": null,
"checkins": [
@@ -1389,6 +1401,7 @@ Fetching individual positions
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 44,
+209
View File
@@ -0,0 +1,209 @@
.. _`rest-seatingplans`:
Seating plans
=============
Resource description
--------------------
The seating plan resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the plan
name string Human-readable name of the plan
layout object JSON representation of the seating plan. These
representations follow a JSON schema that currently
still evolves. The version in use can be found `here`_.
===================================== ========================== =======================================================
.. versionchanged:: 3.0
This endpoint has been added.
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/seatingplans/
Returns a list of all seating plans within a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/seatingplans/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 2,
"name": "Main plan",
"layout": { … }
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/seatingplans/(id)/
Returns information on one plan, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/seatingplans/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 2,
"name": "Main plan",
"layout": { … }
}
:param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the seating plan to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/seatingplans/
Creates a new seating plan
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/seatingplans/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"name": "Main plan",
"layout": { … }
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 3,
"name": "Main plan",
"layout": { … }
}
:param organizer: The ``slug`` field of the organizer to create a seating plan for
:statuscode 201: no error
:statuscode 400: The seating plan could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/seatingplans/(id)/
Update a plan. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``id`` field. **You can not change a plan while it is in use for
any events.**
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/seatingplans/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"name": "Old plan"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Old plan",
"layout": { … }
}
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the plan to modify
:statuscode 200: no error
:statuscode 400: The plan could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource **or** the plan is currently in use.
.. http:delete:: /api/v1/organizers/(organizer)/seatingplans/(id)/
Delete a plan. You can not delete plans which are currently in use by any events.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/seatingplans/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the plan to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource **or** the plan is currently in use.
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/seating/seating-plan.schema.json
+21 -1
View File
@@ -36,7 +36,11 @@ variation_price_overrides list of objects List of variati
the default price
├ variation integer The internal variation ID
└ price money (string) The price or ``null`` for the default price
meta_data dict Values set for organizer-specific meta data parameters.
meta_data object Values set for organizer-specific meta data parameters.
seating_plan integer If reserved seating is in use, the ID of a seating
plan. Otherwise ``null``.
seat_category_mapping object An object mapping categories of the seating plan
(strings) to items in the event (integers or ``null``).
===================================== ========================== =======================================================
.. versionchanged:: 1.7
@@ -54,6 +58,10 @@ meta_data dict Values set for
The attribute ``is_public`` has been added.
.. versionchanged:: 3.0
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
Endpoints
---------
@@ -93,6 +101,8 @@ Endpoints
"date_admission": null,
"presale_start": null,
"presale_end": null,
"seating_plan": null,
"seat_category_mapping": {},
"location": null,
"item_price_overrides": [
{
@@ -142,6 +152,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
@@ -172,6 +184,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
@@ -223,6 +237,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
@@ -287,6 +303,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
@@ -371,6 +389,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
+6 -2
View File
@@ -26,7 +26,11 @@ Frontend
--------
.. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, checkout_flow_steps, order_info, order_meta_from_request, position_info
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info
.. automodule:: pretix.presale.signals
:members: order_info, order_meta_from_request
Request flow
""""""""""""
@@ -45,7 +49,7 @@ Backend
.. automodule:: pretix.control.signals
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, nav_item
order_info, event_settings_widget, oauth_application_registered, order_position_buttons, nav_item, subevent_forms
.. automodule:: pretix.base.signals
+1
View File
@@ -40,6 +40,7 @@ frontend
frontpage
gettext
gunicorn
guid
hardcoded
hostname
idempotency
+22 -4
View File
@@ -8,31 +8,33 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer,
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
)
from pretix.base.models import Quota
from pretix.base.models import Quota, Seat
from pretix.base.models.orders import CartPosition
class CartPositionSerializer(I18nAwareModelSerializer):
answers = AnswerSerializer(many=True)
seat = InlineSeatSerializer()
class Meta:
model = CartPosition
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
'answers',)
'answers', 'seat')
class CartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
expires = serializers.DateTimeField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
class Meta:
model = CartPosition
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'subevent', 'expires', 'includes_tax', 'answers',)
'subevent', 'expires', 'includes_tax', 'answers', 'seat')
def create(self, validated_data):
answers_data = validated_data.pop('answers')
@@ -71,6 +73,22 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
validated_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
if validated_data.get('seat'):
if not seated:
raise ValidationError('The specified product does not allow to choose a seat.')
try:
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
except Seat.DoesNotExist:
raise ValidationError('The specified seat does not exist.')
else:
validated_data['seat'] = seat
if not seat.is_available():
raise ValidationError(ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
elif seated:
raise ValidationError('The specified product requires to choose a seat.')
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:
+136 -3
View File
@@ -11,6 +11,9 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Event, TaxRule
from pretix.base.models.event import SubEvent
from pretix.base.models.items import SubEventItem, SubEventItemVariation
from pretix.base.services.seating import (
SeatProtected, generate_seats, validate_plan_change,
)
class MetaDataField(Field):
@@ -26,6 +29,22 @@ class MetaDataField(Field):
}
class SeatCategoryMappingField(Field):
def to_representation(self, value):
qs = value.seat_category_mappings.all()
if isinstance(value, Event):
qs = qs.filter(subevent=None)
return {
v.layout_category: v.product_id for v in qs
}
def to_internal_value(self, data):
return {
'seat_category_mapping': data or {}
}
class PluginsField(Field):
def to_representation(self, obj):
@@ -45,12 +64,14 @@ class PluginsField(Field):
class EventSerializer(I18nAwareModelSerializer):
meta_data = MetaDataField(required=False, source='*')
plugins = PluginsField(required=False, source='*')
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
class Meta:
model = Event
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins')
'presale_end', 'location', 'has_subevents', 'meta_data', 'seating_plan',
'plugins', 'seat_category_mapping')
def validate(self, data):
data = super().validate(data)
@@ -61,6 +82,9 @@ class EventSerializer(I18nAwareModelSerializer):
Event.clean_dates(data.get('date_from'), data.get('date_to'))
Event.clean_presale(data.get('presale_start'), data.get('presale_end'))
if full_data.get('has_subevents') and full_data.get('seating_plan'):
raise ValidationError('Event series should not directly be assigned a seating plan.')
return data
def validate_has_subevents(self, value):
@@ -92,6 +116,27 @@ class EventSerializer(I18nAwareModelSerializer):
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
return value
def validate_seating_plan(self, value):
if value and value.organizer != self.context['request'].organizer:
raise ValidationError('Invalid seating plan.')
if self.instance and self.instance.pk:
try:
validate_plan_change(self.instance, None, value)
except SeatProtected as e:
raise ValidationError(str(e))
return value
def validate_seat_category_mapping(self, value):
if value and (not self.instance or not self.instance.pk):
raise ValidationError('You cannot specify seat category mappings on event creation.')
item_cache = {i.pk: i for i in self.instance.items.all()}
result = {}
for k, item in value['seat_category_mapping'].items():
if item not in item_cache:
raise ValidationError('Item \'{id}\' does not exist.'.format(id=item))
result[k] = item_cache[item]
return {'seat_category_mapping': result}
def validate_plugins(self, value):
from pretix.base.plugins import get_all_plugins
@@ -109,6 +154,7 @@ class EventSerializer(I18nAwareModelSerializer):
@transaction.atomic
def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None)
validated_data.pop('seat_category_mapping', None)
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
event = super().create(validated_data)
@@ -120,6 +166,10 @@ class EventSerializer(I18nAwareModelSerializer):
value=value
)
# Seats
if event.seating_plan:
generate_seats(event, None, event.seating_plan, {})
# Plugins
if plugins is not None:
event.set_active_plugins(plugins)
@@ -131,6 +181,7 @@ class EventSerializer(I18nAwareModelSerializer):
def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None)
plugins = validated_data.pop('plugins', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
event = super().update(instance, validated_data)
# Meta data
@@ -151,6 +202,29 @@ class EventSerializer(I18nAwareModelSerializer):
if prop.name not in meta_data:
current_object.delete()
# Seats
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
current_mappings = {
m.layout_category: m
for m in event.seat_category_mappings.filter(subevent=None)
}
if not event.seating_plan:
seat_category_mapping = {}
for key, value in seat_category_mapping.items():
if key in current_mappings:
m = current_mappings.pop(key)
m.product = value
m.save()
else:
event.seat_category_mappings.create(product=value, layout_category=key)
for m in current_mappings.values():
m.delete()
if 'seating_plan' in validated_data or seat_category_mapping is not None:
generate_seats(event, None, event.seating_plan, {
m.layout_category: m.product
for m in event.seat_category_mappings.select_related('product').filter(subevent=None)
})
# Plugins
if plugins is not None:
event.set_active_plugins(plugins)
@@ -196,14 +270,15 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer):
class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True, required=False)
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True, required=False)
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
event = SlugRelatedField(slug_field='slug', read_only=True)
meta_data = MetaDataField(source='*')
class Meta:
model = SubEvent
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
'presale_start', 'presale_end', 'location', 'event', 'is_public',
'item_price_overrides', 'variation_price_overrides', 'meta_data')
'presale_start', 'presale_end', 'location', 'event', 'is_public', 'seating_plan',
'item_price_overrides', 'variation_price_overrides', 'meta_data', 'seat_category_mapping')
def validate(self, data):
data = super().validate(data)
@@ -225,6 +300,25 @@ class SubEventSerializer(I18nAwareModelSerializer):
def validate_variation_price_overrides(self, data):
return list(filter(lambda i: 'variation' in i, data))
def validate_seating_plan(self, value):
if value and value.organizer != self.context['request'].organizer:
raise ValidationError('Invalid seating plan.')
if self.instance and self.instance.pk:
try:
validate_plan_change(self.context['request'].event, self.instance, value)
except SeatProtected as e:
raise ValidationError(str(e))
return value
def validate_seat_category_mapping(self, value):
item_cache = {i.pk: i for i in self.context['request'].event.items.all()}
result = {}
for k, item in value['seat_category_mapping'].items():
if item not in item_cache:
raise ValidationError('Item \'{id}\' does not exist.'.format(id=item))
result[k] = item_cache[item]
return {'seat_category_mapping': result}
@cached_property
def meta_properties(self):
return {
@@ -242,6 +336,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
meta_data = validated_data.pop('meta_data', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
subevent = super().create(validated_data)
for item_price_override_data in item_price_overrides_data:
@@ -257,6 +352,18 @@ class SubEventSerializer(I18nAwareModelSerializer):
value=value
)
# Seats
if subevent.seating_plan:
if seat_category_mapping is not None:
for key, value in seat_category_mapping.items():
self.context['request'].event.seat_category_mappings.create(
product=value, layout_category=key, subevent=subevent
)
generate_seats(self.context['request'].event, subevent, subevent.seating_plan, {
m.layout_category: m.product
for m in self.context['request'].event.seat_category_mappings.select_related('product').filter(subevent=subevent)
})
return subevent
@transaction.atomic
@@ -264,6 +371,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
meta_data = validated_data.pop('meta_data', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
subevent = super().update(instance, validated_data)
existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)}
@@ -300,6 +408,31 @@ class SubEventSerializer(I18nAwareModelSerializer):
if prop.name not in meta_data:
current_object.delete()
# Seats
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
current_mappings = {
m.layout_category: m
for m in self.context['request'].event.seat_category_mappings.filter(subevent=subevent)
}
if not subevent.seating_plan:
seat_category_mapping = {}
for key, value in seat_category_mapping.items():
if key in current_mappings:
m = current_mappings.pop(key)
m.product = value
m.save()
else:
self.context['request'].event.seat_category_mappings.create(
product=value, layout_category=key, subevent=subevent
)
for m in current_mappings.values():
m.delete()
if 'seating_plan' in validated_data or seat_category_mapping is not None:
generate_seats(self.context['request'].event, subevent, subevent.seating_plan, {
m.layout_category: m.product
for m in self.context['request'].event.seat_category_mappings.select_related('product').filter(subevent=subevent)
})
return subevent
+32 -8
View File
@@ -1,5 +1,4 @@
import json
from collections import Counter
from decimal import Decimal
from django.utils.timezone import now
@@ -15,7 +14,7 @@ from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language
from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, SubEvent,
OrderPosition, Question, QuestionAnswer, Seat, SubEvent,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -71,6 +70,13 @@ class AnswerQuestionOptionsIdentifierField(serializers.Field):
return [o.identifier for o in instance.options.all()]
class InlineSeatSerializer(I18nAwareModelSerializer):
class Meta:
model = Seat
fields = ('id', 'name', 'seat_guid')
class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
@@ -166,12 +172,13 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
downloads = PositionDownloadsField(source='*')
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*')
seat = InlineSeatSerializer(read_only=True)
class Meta:
model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data')
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -430,11 +437,12 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
class Meta:
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'secret', 'addon_to', 'subevent', 'answers')
'secret', 'addon_to', 'subevent', 'answers', 'seat')
def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -615,8 +623,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
ia = None
with self.context['event'].lock() as now_dt:
quotadiff = Counter()
free_seats = set()
seats_seen = set()
consume_carts = validated_data.pop('consume_carts', [])
delete_cps = []
quota_avail_cache = {}
@@ -630,7 +638,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] += 1
if cp.expires > now_dt:
quotadiff.subtract(quotas)
if cp.seat:
free_seats.add(cp.seat)
delete_cps.append(cp)
errs = [{} for p in positions_data]
@@ -658,7 +667,22 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
)
]
quotadiff.update(new_quotas)
for i, pos_data in enumerate(positions_data):
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('seat'):
if not seated:
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
try:
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
except Seat.DoesNotExist:
errs[i]['seat'] = ['The specified seat does not exist.']
else:
pos_data['seat'] = seat
if (seat not in free_seats and not seat.is_available()) or seat in seats_seen:
errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
seats_seen.add(seat)
elif seated:
errs[i]['seat'] = ['The specified product requires to choose a seat.']
if any(errs):
raise ValidationError({'positions': errs})
+13 -1
View File
@@ -1,8 +1,20 @@
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Organizer
from pretix.api.serializers.order import CompatibleJSONField
from pretix.base.models import Organizer, SeatingPlan
from pretix.base.models.seating import SeatingPlanLayoutValidator
class OrganizerSerializer(I18nAwareModelSerializer):
class Meta:
model = Organizer
fields = ('name', 'slug')
class SeatingPlanSerializer(I18nAwareModelSerializer):
layout = CompatibleJSONField(
validators=[SeatingPlanLayoutValidator()]
)
class Meta:
model = SeatingPlan
fields = ('id', 'name', 'layout')
+1
View File
@@ -18,6 +18,7 @@ orga_router = routers.DefaultRouter()
orga_router.register(r'events', event.EventViewSet)
orga_router.register(r'subevents', event.SubEventViewSet)
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
event_router = routers.DefaultRouter()
event_router.register(r'subevents', event.SubEventViewSet)
+1 -1
View File
@@ -24,7 +24,7 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
return CartPosition.objects.filter(
event=self.request.event,
cart_id__endswith="@api"
)
).select_related('seat').prefetch_related('answers')
def get_serializer_context(self):
ctx = super().get_serializer_context()
+2 -2
View File
@@ -231,7 +231,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
)
))
).select_related(
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address'
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
)
else:
qs = qs.prefetch_related(
@@ -241,7 +241,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
),
'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order')
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
if not self.checkinlist.all_products:
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
+2 -2
View File
@@ -86,7 +86,7 @@ class EventViewSet(viewsets.ModelViewSet):
)
return qs.prefetch_related(
'meta_values', 'meta_values__property'
'meta_values', 'meta_values__property', 'seat_category_mappings'
)
def perform_update(self, serializer):
@@ -242,7 +242,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
event__in=self.request.user.get_events_with_any_permission()
)
return qs.prefetch_related(
'subeventitem_set', 'subeventitemvariation_set'
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings'
)
def perform_update(self, serializer):
+5 -5
View File
@@ -93,8 +93,8 @@ class OrderViewSet(viewsets.ModelViewSet):
'positions',
OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
'item__category', 'addon_to',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
'item__category', 'addon_to', 'seat',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation', 'seat'))
)
)
)
@@ -103,7 +103,7 @@ class OrderViewSet(viewsets.ModelViewSet):
Prefetch(
'positions',
OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
)
)
)
@@ -611,13 +611,13 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
)
))
).select_related(
'item', 'variation', 'item__category', 'addon_to'
'item', 'variation', 'item__category', 'addon_to', 'seat'
)
else:
qs = qs.prefetch_related(
'checkins', 'answers', 'answers__options', 'answers__question'
).select_related(
'item', 'order', 'order__event', 'order__event__organizer'
'item', 'order', 'order__event', 'order__event__organizer', 'seat'
)
return qs
+53 -2
View File
@@ -1,8 +1,12 @@
from rest_framework import filters, viewsets
from rest_framework.exceptions import PermissionDenied
from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.organizer import OrganizerSerializer
from pretix.base.models import Organizer
from pretix.api.serializers.organizer import (
OrganizerSerializer, SeatingPlanSerializer,
)
from pretix.base.models import Organizer, SeatingPlan
from pretix.helpers.dicts import merge_dicts
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
@@ -30,3 +34,50 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
return Organizer.objects.filter(pk=self.request.auth.organizer_id)
else:
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)
class SeatingPlanViewSet(viewsets.ModelViewSet):
serializer_class = SeatingPlanSerializer
queryset = SeatingPlan.objects.none()
permission = 'can_change_organizer_settings'
write_permission = 'can_change_organizer_settings'
def get_queryset(self):
return self.request.organizer.seating_plans.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
'pretix.seatingplan.added',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk})
)
def perform_update(self, serializer):
if serializer.instance.events.exists() or serializer.instance.subevents.exists():
raise PermissionDenied('This plan can not be changed while it is in use for an event.')
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
'pretix.seatingplan.changed',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
return inst
def perform_destroy(self, instance):
if instance.events.exists() or instance.subevents.exists():
raise PermissionDenied('This plan can not be deleted while it is in use for an event.')
instance.log_action(
'pretix.seatingplan.deleted',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk}
)
instance.delete()
@@ -0,0 +1,70 @@
# Generated by Django 2.2.1 on 2019-05-30 10:35
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0122_orderposition_web_secret'),
]
operations = [
migrations.CreateModel(
name='SeatingPlan',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=190)),
('layout', models.TextField()),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seating_plans', to='pretixbase.Organizer')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='SeatCategoryMapping',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('layout_category', models.CharField(max_length=190)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.Event')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.Item')),
('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.SubEvent')),
],
),
migrations.CreateModel(
name='Seat',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=190)),
('blocked', models.BooleanField(default=False)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.Event')),
('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.Item')),
('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.SubEvent')),
],
),
migrations.AddField(
model_name='cartposition',
name='seat',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Seat'),
),
migrations.AddField(
model_name='event',
name='seating_plan',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='events', to='pretixbase.SeatingPlan'),
),
migrations.AddField(
model_name='orderposition',
name='seat',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Seat'),
),
migrations.AddField(
model_name='subevent',
name='seating_plan',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='subevents', to='pretixbase.SeatingPlan'),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 2.2.1 on 2019-05-30 11:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0123_auto_20190530_1035'),
]
operations = [
migrations.AddField(
model_name='seat',
name='seat_guid',
field=models.CharField(db_index=True, default=None, max_length=190),
preserve_default=False,
),
]
+1
View File
@@ -24,6 +24,7 @@ from .orders import (
from .organizer import (
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
)
from .seating import Seat, SeatCategoryMapping, SeatingPlan
from .tax import TaxRule
from .vouchers import Voucher
from .waitinglist import WaitingListEntry
+64
View File
@@ -336,6 +336,8 @@ class Event(EventMixin, LoggedModel):
verbose_name=_('Event series'),
default=False
)
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='events')
objects = ScopedManager(organizer='organizer')
@@ -348,6 +350,26 @@ class Event(EventMixin, LoggedModel):
def __str__(self):
return str(self.name)
@property
def free_seats(self):
from .orders import CartPosition, Order, OrderPosition
return self.seats.annotate(
has_order=Exists(
OrderPosition.objects.filter(
order__event=self,
seat_id=OuterRef('pk'),
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
)
),
has_cart=Exists(
CartPosition.objects.filter(
event=self,
seat_id=OuterRef('pk'),
expires__gte=now()
)
)
).filter(has_order=False, has_cart=False, blocked=False)
@property
def presale_has_ended(self):
if self.has_subevents:
@@ -531,6 +553,24 @@ class Event(EventMixin, LoggedModel):
for i in items:
cl.limit_products.add(item_map[i.pk])
if other.seating_plan:
if other.seating_plan.organizer_id == self.organizer_id:
self.seating_plan = other.seating_plan
else:
self.organizer.seating_plans.create(name=other.seating_plan.name, layout=other.seating_plan.layout)
self.save()
for m in other.seat_category_mappings.filter(subevent__isnull=True):
m.pk = None
m.event = self
m.product = item_map[m.product_id]
m.save()
for s in other.seats.filter(subevent__isnull=True):
s.pk = None
s.event = self
s.save()
for s in other.settings._objects.all():
s.object = self
s.pk = None
@@ -874,6 +914,8 @@ class SubEvent(EventMixin, LoggedModel):
null=True, blank=True,
verbose_name=_("Frontpage text")
)
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='subevents')
items = models.ManyToManyField('Item', through='SubEventItem')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
@@ -888,6 +930,28 @@ class SubEvent(EventMixin, LoggedModel):
def __str__(self):
return '{} - {}'.format(self.name, self.get_date_range_display())
@property
def free_seats(self):
from .orders import CartPosition, Order, OrderPosition
return self.seats.annotate(
has_order=Exists(
OrderPosition.objects.filter(
order__event_id=self.event_id,
subevent=self,
seat_id=OuterRef('pk'),
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
)
),
has_cart=Exists(
CartPosition.objects.filter(
event_id=self.event_id,
subevent=self,
seat_id=OuterRef('pk'),
expires__gte=now()
)
)
).filter(has_order=False, has_cart=False, blocked=False)
@cached_property
def settings(self):
return self.event.settings
+26 -13
View File
@@ -630,7 +630,7 @@ class Order(LockModel, LoggedModel):
), tz)
return term_last
def _can_be_paid(self, count_waitinglist=True, ignore_date=False) -> Union[bool, str]:
def _can_be_paid(self, count_waitinglist=True, ignore_date=False, force=False) -> Union[bool, str]:
error_messages = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
"payment settings is over."),
@@ -638,29 +638,37 @@ class Order(LockModel, LoggedModel):
"payments should be accepted in the payment settings."),
'require_approval': _('This order is not yet approved by the event organizer.')
}
if self.require_approval:
return error_messages['require_approval']
term_last = self.payment_term_last
if term_last and not ignore_date:
if now() > term_last:
return error_messages['late_lastdate']
if not force:
if self.require_approval:
return error_messages['require_approval']
term_last = self.payment_term_last
if term_last and not ignore_date:
if now() > term_last:
return error_messages['late_lastdate']
if self.status == self.STATUS_PENDING:
return True
if not self.event.settings.get('payment_term_accept_late') and not ignore_date:
if not self.event.settings.get('payment_term_accept_late') and not ignore_date and not force:
return error_messages['late']
return self._is_still_available(count_waitinglist=count_waitinglist)
return self._is_still_available(count_waitinglist=count_waitinglist, force=force)
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True) -> Union[bool, str]:
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False) -> Union[bool, str]:
error_messages = {
'unavailable': _('The ordered product "{item}" is no longer available.'),
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
}
now_dt = now_dt or now()
positions = self.positions.all().select_related('item', 'variation')
positions = self.positions.all().select_related('item', 'variation', 'seat')
quota_cache = {}
try:
for i, op in enumerate(positions):
if op.seat:
if not op.seat.is_available(ignore_orderpos=op):
raise Quota.QuotaExceededException(error_messages['seat_unavailable'].format(seat=op.seat))
if force:
continue
quotas = list(op.quotas)
if len(quotas) == 0:
raise Quota.QuotaExceededException(error_messages['unavailable'].format(
@@ -938,6 +946,8 @@ class AbstractPosition(models.Model):
:type voucher: Voucher
:param meta_info: Additional meta information on the position, JSON-encoded.
:type meta_info: str
:param seat: Seat, if reserved seating is used.
:type seat: Seat
"""
subevent = models.ForeignKey(
SubEvent,
@@ -984,6 +994,9 @@ class AbstractPosition(models.Model):
verbose_name=_("Meta information"),
null=True, blank=True
)
seat = models.ForeignKey(
'Seat', null=True, blank=True, on_delete=models.PROTECT
)
class Meta:
abstract = True
@@ -1183,8 +1196,8 @@ class OrderPayment(models.Model):
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date)
if not force and can_be_paid is not True:
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force)
if can_be_paid is not True:
self.order.log_action('pretix.event.order.quotaexceeded', {
'message': can_be_paid
}, user=user, auth=auth)
+111
View File
@@ -0,0 +1,111 @@
import json
from collections import namedtuple
import jsonschema
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.deconstruct import deconstructible
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent
@deconstructible
class SeatingPlanLayoutValidator:
def __call__(self, value):
if not isinstance(value, dict):
try:
val = json.loads(value)
except ValueError:
raise ValidationError(_('Your layout file is not a valid JSON file.'))
else:
val = value
with open(finders.find('seating/seating-plan.schema.json'), 'r') as f:
schema = json.loads(f.read())
try:
jsonschema.validate(val, schema)
except jsonschema.ValidationError as e:
raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(str(e)))
class SeatingPlan(LoggedModel):
"""
Represents an abstract seating plan, without relation to any event.
"""
name = models.CharField(max_length=190, verbose_name=_('Name'))
organizer = models.ForeignKey(Organizer, related_name='seating_plans', on_delete=models.CASCADE)
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
Category = namedtuple('Categrory', 'name')
RawSeat = namedtuple('Seat', 'name guid number row category')
def __str__(self):
return self.name
@property
def layout_data(self):
return json.loads(self.layout)
@layout_data.setter
def layout_data(self, v):
self.layout = json.dumps(v)
def get_categories(self):
return [
self.Category(name=c['name'])
for c in self.layout_data['categories']
]
def iter_all_seats(self):
for z in self.layout_data['zones']:
for r in z['rows']:
for s in r['seats']:
yield self.RawSeat(
number=s['seat_number'],
guid=s['seat_guid'],
name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme?
row=r['row_number'],
category=s['category']
)
class SeatCategoryMapping(models.Model):
"""
Input seating plans have abstract "categories", such as "Balcony seat", etc. This model maps them to actual
pretix product on a per-(sub)event level.
"""
event = models.ForeignKey(Event, related_name='seat_category_mappings', on_delete=models.CASCADE)
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seat_category_mappings', on_delete=models.CASCADE)
layout_category = models.CharField(max_length=190)
product = models.ForeignKey(Item, related_name='seat_category_mappings', on_delete=models.CASCADE)
class Seat(models.Model):
"""
This model is used to represent every single specific seat within an (sub)event that can be selected. It's mainly
used for internal bookkeeping and not to be modified by users directly.
"""
event = models.ForeignKey(Event, related_name='seats', on_delete=models.CASCADE)
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
name = models.CharField(max_length=190)
seat_guid = models.CharField(max_length=190, db_index=True)
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
blocked = models.BooleanField(default=False)
def __str__(self):
return self.name
def is_available(self, ignore_cart=None, ignore_orderpos=None):
from .orders import Order
if self.blocked:
return False
opqs = self.orderposition_set.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
cpqs = self.cartposition_set.filter(expires__gte=now())
if ignore_cart:
cpqs = cpqs.exclude(pk=ignore_cart.pk)
if ignore_orderpos:
opqs = opqs.exclude(pk=ignore_orderpos.pk)
return not opqs.exists() and not cpqs.exists()
+10
View File
@@ -10,6 +10,8 @@ from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from pretix.base.models import SeatCategoryMapping
from ..decimal import round_decimal
from .base import LoggedModel
from .event import Event, SubEvent
@@ -395,3 +397,11 @@ class Voucher(LoggedModel):
"""
return Order.objects.filter(all_positions__voucher__in=[self]).distinct()
def seating_available(self):
kwargs = {}
if self.subevent:
kwargs['subevent'] = self.subevent
if self.quota_id:
return SeatCategoryMapping.objects.filter(product__quotas__pk=self.quota_id, **kwargs).exists()
return self.item.seat_category_mappings.filter(**kwargs).exists()
+5
View File
@@ -238,6 +238,11 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT"
) if ev.date_admission else ""
}),
("seat", {
"label": _("Seat name"),
"editor_sample": _("3, 4-5"),
"evaluate": lambda op, order, ev: str(op.seat if op.seat else _('General admission'))
}),
))
+85 -18
View File
@@ -6,7 +6,7 @@ from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError
from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction
from django.db.models import Q
from django.db.models import Count, Exists, OuterRef, Q
from django.dispatch import receiver
from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext as _
@@ -14,8 +14,8 @@ from django_scopes import scopes_disabled
from pretix.base.i18n import language
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation,
Voucher,
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, Seat,
SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
@@ -91,15 +91,20 @@ error_messages = {
'product %(base)s.'),
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
'seat_required': _('You need to select a specific seat.'),
'seat_invalid': _('Please select a valid seat.'),
'seat_forbidden': _('You can not select a seat for this position.'),
'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'),
'seat_multiple': _('You can not select the same seat multiple times.'),
}
class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to', 'subevent', 'includes_tax', 'bundled'))
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent'))
'quotas', 'subevent', 'seat'))
order = {
RemoveOperation: 10,
ExtendOperation: 20,
@@ -117,6 +122,7 @@ class CartManager:
self._items_cache = {}
self._subevents_cache = {}
self._variations_cache = {}
self._seated_cache = {}
self._expiry = None
self.invoice_address = invoice_address
self._widget_data = widget_data or {}
@@ -128,6 +134,11 @@ class CartManager:
Q(cart_id=self.cart_id) & Q(event=self.event)
).select_related('item', 'subevent')
def _is_seated(self, item, subevent):
if (item, subevent) not in self._seated_cache:
self._seated_cache[item, subevent] = item.seat_category_mappings.filter(subevent=subevent).exists()
return self._seated_cache[item, subevent]
def _calculate_expiry(self):
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
@@ -188,6 +199,8 @@ class CartManager:
i.pk: i
for i in self.event.items.select_related('category').prefetch_related(
'addons', 'bundles', 'addons__addon_category', 'quotas'
).annotate(
has_variations=Count('variations'),
).filter(
id__in=[i for i in item_ids if i and i not in self._items_cache]
)
@@ -224,6 +237,12 @@ class CartManager:
if self._sales_channel not in op.item.sales_channels:
raise CartError(error_messages['unavailable'])
if op.item.has_variations and not op.variation:
raise CartError(error_messages['not_for_sale'])
if op.variation and op.variation.item_id != op.item.pk:
raise CartError(error_messages['not_for_sale'])
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
raise CartError(error_messages['voucher_invalid_item'])
@@ -239,6 +258,16 @@ class CartManager:
if op.subevent and op.subevent.presale_has_ended:
raise CartError(error_messages['ended'])
seated = self._is_seated(op.item, op.subevent)
if seated and (not op.seat or op.seat.blocked):
raise CartError(error_messages['seat_invalid'])
elif op.seat and not seated:
raise CartError(error_messages['seat_forbidden'])
elif op.seat and op.seat.product != op.item:
raise CartError(error_messages['seat_invalid'])
elif op.seat and op.count > 1:
raise CartError('Invalid request: A seat can only be bought once.')
if op.subevent:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
@@ -301,6 +330,13 @@ class CartManager:
def extend_expired_positions(self):
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
).annotate(
requires_seat=Exists(
SeatCategoryMapping.objects.filter(
Q(product=OuterRef('item'))
& (Q(subevent=OuterRef('subevent')) if self.event.has_subevents else Q(subevent__isnull=True))
)
)
).prefetch_related(
'item__quotas',
'variation__quotas',
@@ -313,6 +349,8 @@ class CartManager:
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
continue
cp.item.requires_seat = cp.requires_seat
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
@@ -359,7 +397,7 @@ class CartManager:
op = self.ExtendOperation(
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
price=price, quotas=quotas, subevent=cp.subevent
price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat
)
self._check_item_constraints(op)
@@ -378,12 +416,6 @@ class CartManager:
operations = []
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['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache):
raise CartError(error_messages['not_for_sale'])
if self.event.has_subevents:
if not i.get('subevent'):
raise CartError(error_messages['subevent_required'])
@@ -391,6 +423,24 @@ class CartManager:
else:
subevent = None
# When a seat is given, we ignore the item that was given, since we can infer it from the
# seat. The variation is still relevant, though!
seat = None
if i.get('seat'):
try:
seat = (subevent or self.event).seats.get(seat_guid=i.get('seat'))
except Seat.DoesNotExist:
raise CartError(error_messages['seat_invalid'])
i['item'] = seat.product_id
if i['item'] not in self._items_cache:
self._update_items_cache([i['item']], [i['variation']])
# 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['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache):
raise CartError(error_messages['not_for_sale'])
item = self._items_cache[i['item']]
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None
voucher = None
@@ -446,7 +496,7 @@ class CartManager:
bop = self.AddOperation(
count=bundle.count, item=bitem, variation=bvar, price=bprice,
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
includes_tax=bool(bprice.rate), bundled=[]
includes_tax=bool(bprice.rate), bundled=[], seat=None
)
self._check_item_constraints(bop)
bundled.append(bop)
@@ -455,7 +505,7 @@ class CartManager:
op = self.AddOperation(
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat
)
self._check_item_constraints(op)
operations.append(op)
@@ -561,7 +611,7 @@ class CartManager:
op = self.AddOperation(
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[]
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=cp.seat
)
self._check_item_constraints(op)
operations.append(op)
@@ -687,6 +737,7 @@ class CartManager:
err = err or self._check_min_per_product()
self._operations.sort(key=lambda a: self.order[type(a)])
seats_seen = set()
for op in self._operations:
if isinstance(op, self.RemoveOperation):
@@ -700,6 +751,11 @@ class CartManager:
# Create a CartPosition for as much items as we can
requested_count = quota_available_count = voucher_available_count = op.count
if op.seat:
if op.seat in seats_seen:
err = err or error_messages['seat_multiple']
seats_seen.add(op.seat)
if op.quotas:
quota_available_count = min(requested_count, min(quotas_ok[q] for q in op.quotas))
@@ -745,12 +801,16 @@ class CartManager:
available_count = 0
if isinstance(op, self.AddOperation):
if op.seat and not op.seat.is_available():
available_count = 0
err = err or error_messages['seat_unavailable']
for k in range(available_count):
cp = CartPosition(
event=self.event, item=op.item, variation=op.variation,
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent, includes_tax=op.includes_tax
subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat
)
if self.event.settings.attendee_names_asked:
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
@@ -789,7 +849,11 @@ class CartManager:
new_cart_positions.append(cp)
elif isinstance(op, self.ExtendOperation):
if available_count == 1:
if op.seat and not op.seat.is_available(ignore_cart=op.position):
err = err or error_messages['seat_unavailable']
op.position.addons.all().delete()
op.position.delete()
elif available_count == 1:
op.position.expires = self._expiry
op.position.price = op.price.gross
try:
@@ -820,6 +884,9 @@ class CartManager:
# If any quotas are affected that are not unlimited, we lock
return True
if any(getattr(o, 'seat', False) for o in self._operations):
return True
return False
def commit(self):
@@ -909,7 +976,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
:param items: A list of dicts with the keys item, variation, count, custom_price, voucher
:param items: A list of dicts with the keys item, variation, count, custom_price, voucher, seat ID
:param cart_id: Session ID of a guest
:raises CartError: On any error that occured
"""
+93 -8
View File
@@ -24,7 +24,7 @@ from pretix.base.i18n import (
)
from pretix.base.models import (
CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment,
OrderPosition, Quota, User, Voucher,
OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemBundle
@@ -82,6 +82,8 @@ error_messages = {
'affected positions have been removed from your cart.'),
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
'positions have been removed from your cart.'),
'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'),
'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
}
logger = logging.getLogger(__name__)
@@ -428,6 +430,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
products_seen = Counter()
changed_prices = {}
deleted_positions = set()
seats_seen = set()
def delete(cp):
# Delete a cart position, including parents and children, if applicable
@@ -490,6 +493,13 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
break
if (cp.requires_seat and not cp.seat) or (cp.seat and not cp.requires_seat) or (cp.seat and cp.seat.product != cp.item) or cp.seat in seats_seen:
err = err or error_messages['seat_invalid']
delete(cp)
break
if cp.seat:
seats_seen.add(cp.seat)
if cp.item.require_voucher and cp.voucher is None:
delete(cp)
err = err or error_messages['voucher_required']
@@ -501,6 +511,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
err = error_messages['voucher_required']
break
if cp.seat:
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every time, since we absolutely
# can not overbook a seat.
if not cp.seat.is_available(ignore_cart=cp) or cp.seat.blocked:
err = err or error_messages['seat_unavailable']
cp.delete()
continue
if cp.expires >= now_dt and not cp.voucher:
# Other checks are not necessary
continue
@@ -736,21 +754,30 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
except InvoiceAddress.DoesNotExist:
pass
positions = CartPosition.objects.filter(id__in=position_ids, event=event)
positions = CartPosition.objects.annotate(
requires_seat=Exists(
SeatCategoryMapping.objects.filter(
Q(product=OuterRef('item'))
& (Q(subevent=OuterRef('subevent')) if event.has_subevents else Q(subevent__isnull=True))
)
)
).filter(
id__in=position_ids, event=event
)
validate_order.send(event, payment_provider=pprov, email=email, positions=positions,
locale=locale, invoice_address=addr, meta_info=meta_info)
lockfn = NoLockManager
locked = False
if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2))).exists():
if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2)) | Q(seat__isnull=False)).exists():
# Performance optimization: If no voucher is used and no cart position is dangerously close to its expiry date,
# creating this order shouldn't be prone to any race conditions and we don't need to lock the event.
locked = True
lockfn = event.lock
with lockfn() as now_dt:
positions = list(positions.select_related('item', 'variation', 'subevent', 'addon_to').prefetch_related('addons'))
positions = list(positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons'))
if len(positions) == 0:
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
@@ -961,12 +988,17 @@ class OrderChangeManager:
'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'),
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
'subevent_required': _('You need to choose a subevent for the new position.'),
'seat_unavailable': _('The selected seat "{seat}" is not available.'),
'seat_subevent_mismatch': _('You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'),
'seat_required': _('The selected product requires you to select a seat.'),
'seat_forbidden': _('The selected product does not allow to select a seat.'),
}
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
CancelOperation = namedtuple('CancelOperation', ('position',))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat'))
SplitOperation = namedtuple('SplitOperation', ('position',))
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
@@ -979,6 +1011,7 @@ class OrderChangeManager:
self._committed = False
self._totaldiff = 0
self._quotadiff = Counter()
self._seatdiff = Counter()
self._operations = []
self.notify = notify
self._invoice_dirty = False
@@ -996,6 +1029,13 @@ class OrderChangeManager:
self._quotadiff.subtract(position.quotas)
self._operations.append(self.ItemOperation(position, item, variation))
def change_seat(self, position: OrderPosition, seat: Seat):
if position.seat:
self._seatdiff.subtract([position.seat])
if seat:
self._seatdiff.update([seat])
self._operations.append(self.SeatOperation(position, seat))
def change_subevent(self, position: OrderPosition, subevent: SubEvent):
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
invoice_address=self._invoice_address)
@@ -1051,12 +1091,14 @@ class OrderChangeManager:
self._totaldiff += -position.price
self._quotadiff.subtract(position.quotas)
self._operations.append(self.CancelOperation(position))
if position.seat:
self._seatdiff.subtract([position.seat])
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
self._invoice_dirty = True
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
subevent: SubEvent = None):
subevent: SubEvent = None, seat: Seat = None):
if price is None:
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
else:
@@ -1075,6 +1117,14 @@ class OrderChangeManager:
if self.order.event.has_subevents and not subevent:
raise OrderError(self.error_messages['subevent_required'])
seated = item.seat_category_mappings.filter(subevent=subevent).exists()
if seated and not seat:
raise OrderError(self.error_messages['seat_required'])
elif not seated and seat:
raise OrderError(self.error_messages['seat_forbidden'])
if seat and subevent and seat.subevent_id != subevent:
raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=seat.name))
new_quotas = (variation.quotas.filter(subevent=subevent)
if variation else item.quotas.filter(subevent=subevent))
if not new_quotas:
@@ -1085,7 +1135,9 @@ class OrderChangeManager:
self._totaldiff += price.gross
self._quotadiff.update(new_quotas)
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent))
if seat:
self._seatdiff.update([seat])
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat))
def split(self, position: OrderPosition):
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
@@ -1093,6 +1145,26 @@ class OrderChangeManager:
self._operations.append(self.SplitOperation(position))
def _check_seats(self):
for seat, diff in self._seatdiff.items():
if diff <= 0:
continue
if not seat.is_available() or diff > 1:
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
if self.event.has_subevents:
state = {}
for p in self.order.positions.all():
state[p] = {'seat': p.seat, 'subevent': p.subevent}
for op in self._operations:
if isinstance(op, self.SeatOperation):
state[op.position]['seat'] = op.seat
elif isinstance(op, self.SubeventOperation):
state[op.position]['subevent'] = op.subevent
for v in state.values():
if v['seat'] and v['seat'].subevent_id != v['subevent'].pk:
raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=v['seat'].name))
def _check_quotas(self):
for quota, diff in self._quotadiff.items():
if diff <= 0:
@@ -1179,6 +1251,17 @@ class OrderChangeManager:
op.position.variation = op.variation
op.position._calculate_tax()
op.position.save()
elif isinstance(op, self.SeatOperation):
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_seat': op.position.seat.name if op.position.seat else "-",
'new_seat': op.seat.name if op.seat else "-",
'old_seat_id': op.position.seat.pk if op.position.seat else None,
'new_seat_id': op.seat.pk if op.seat else None,
})
op.position.seat = op.seat
op.position.save()
elif isinstance(op, self.SubeventOperation):
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
'position': op.position.pk,
@@ -1232,7 +1315,7 @@ class OrderChangeManager:
item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate,
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent
positionid=nextposid, subevent=op.subevent, seat=op.seat
)
nextposid += 1
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
@@ -1243,6 +1326,7 @@ class OrderChangeManager:
'price': op.price.gross,
'positionid': pos.positionid,
'subevent': op.subevent.pk if op.subevent else None,
'seat': op.seat.pk if op.seat else None,
})
elif isinstance(op, self.SplitOperation):
split_positions.append(op.position)
@@ -1467,6 +1551,7 @@ class OrderChangeManager:
raise OrderError(self.error_messages['not_pending_or_paid'])
if check_quotas:
self._check_quotas()
self._check_seats()
self._check_complete_cancel()
self._perform_operations()
self._recalculate_total_and_payment_fee()
+57
View File
@@ -0,0 +1,57 @@
from django.db.models import Count
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import CartPosition, Seat
class SeatProtected(Exception):
pass
def validate_plan_change(event, subevent, plan):
current_taken_seats = set(
event.seats.select_related('product')
.annotate(has_op=Count('orderposition'))
.filter(subevent=subevent, has_op=True)
.values_list('seat_guid', flat=True)
)
new_seats = {
ss.guid for ss in plan.iter_all_seats()
} if plan else set()
leftovers = list(current_taken_seats - new_seats)
if leftovers:
raise SeatProtected(_('You can not change the plan since seat "{}" is not present in the new plan and is '
'already sold.').format(leftovers[0]))
def generate_seats(event, subevent, plan, mapping):
current_seats = {
s.seat_guid: s for s in
event.seats.select_related('product').annotate(has_op=Count('orderposition')).filter(subevent=subevent)
}
create_seats = []
if plan:
for ss in plan.iter_all_seats():
p = mapping.get(ss.category)
if ss.guid in current_seats:
seat = current_seats.pop(ss.guid)
if seat.product != p:
seat.product = p
seat.save()
else:
create_seats.append(Seat(
event=event,
subevent=subevent,
seat_guid=ss.guid,
name=ss.name,
product=p,
))
for s in current_seats.values():
if s.has_op:
raise SeatProtected(_('You can not change the plan since seat "{}" is not present in the new plan and is '
'already sold.').format(s.name))
Seat.objects.bulk_create(create_seats)
CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete()
Seat.objects.filter(pk__in=[s.pk for s in current_seats.values()]).delete()
+43 -1
View File
@@ -10,7 +10,9 @@ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.forms import I18nModelForm, PlaceholderValidator
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import InvoiceAddress, ItemAddOn, Order, OrderPosition
from pretix.base.models import (
InvoiceAddress, ItemAddOn, Order, OrderPosition, Seat,
)
from pretix.base.models.event import SubEvent
from pretix.base.services.pricing import get_price
from pretix.control.forms.widgets import Select2
@@ -196,6 +198,12 @@ class OrderPositionAddForm(forms.Form):
required=False,
label=_('Add-on to'),
)
seat = forms.ModelChoiceField(
Seat.objects.none(),
required=False,
label=_('Seat'),
empty_label=_('General admission')
)
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
@@ -241,6 +249,19 @@ class OrderPositionAddForm(forms.Form):
else:
del self.fields['addon_to']
self.fields['seat'].queryset = order.event.seats.all()
self.fields['seat'].widget = Select2(
attrs={
'data-model-select2': 'seat',
'data-select2-url': reverse('control:event.seats.select2', kwargs={
'event': order.event.slug,
'organizer': order.event.organizer.slug,
}),
'data-placeholder': _('General admission')
}
)
self.fields['seat'].widget.choices = self.fields['seat'].choices
if order.event.has_subevents:
self.fields['subevent'].queryset = order.event.subevents.all()
self.fields['subevent'].widget = Select2(
@@ -269,6 +290,11 @@ class OrderPositionChangeForm(forms.Form):
required=False,
empty_label=_('(Unchanged)')
)
seat = forms.ModelChoiceField(
Seat.objects.none(),
required=False,
empty_label=_('(Unchanged)')
)
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
@@ -312,6 +338,22 @@ class OrderPositionChangeForm(forms.Form):
else:
del self.fields['subevent']
if instance.seat:
self.fields['seat'].queryset = instance.order.event.seats.all()
self.fields['seat'].widget = Select2(
attrs={
'data-model-select2': 'seat',
'data-select2-url': reverse('control:event.seats.select2', kwargs={
'event': instance.order.event.slug,
'organizer': instance.order.event.organizer.slug,
}),
'data-placeholder': _('(Unchanged)')
}
)
self.fields['seat'].widget.choices = self.fields['seat'].choices
else:
del self.fields['seat']
choices = [
('', _('(Unchanged)'))
]
+1 -1
View File
@@ -36,7 +36,7 @@ class SubEventForm(I18nModelForm):
'presale_start',
'presale_end',
'location',
'frontpage_text'
'frontpage_text',
]
field_classes = {
'date_from': SplitDateTimeField,
+6
View File
@@ -42,6 +42,12 @@ def _display_order_changed(event: Event, logentry: LogEntry):
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.seat':
return text + ' ' + _('Position #{posid}: Seat "{old_seat}" changed '
'to "{new_seat}".').format(
posid=data.get('positionid', '?'),
old_seat=data.get('old_seat'), new_seat=data.get('new_seat'),
)
elif logentry.action_type == 'pretix.event.order.changed.subevent':
old_se = str(event.subevents.get(pk=data['old_subevent']))
new_se = str(event.subevents.get(pk=data['new_subevent']))
+16
View File
@@ -279,6 +279,22 @@ styles. It is advisable to set a prefix for your form to avoid clashes with othe
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
subevent_forms = EventPluginSignal(
providing_args=['request', 'subevent']
)
"""
This signal allows you to return additional forms that should be rendered on the subevent creation
or modification page. You are passed ``request`` and ``subevent`` arguments and are expected to return
an instance of a form class that you bind yourself when appropriate. Your form will be executed
as part of the standard validation and rendering cycle and rendered using default bootstrap
styles. It is advisable to set a prefix for your form to avoid clashes with other plugins.
``subevent`` can be ``None`` during creation. Before ``save()`` is called, a ``subevent`` property of
your form instance will automatically being set to the subevent that has just been created.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
oauth_application_registered = Signal(
providing_args=["user", "application"]
)
@@ -145,7 +145,11 @@
<a href="{{ nav.url }}" title="{{ nav.title }}" {% if nav.active %}class="active"{% endif %}
{% if nav.children %}class="dropdown-toggle" data-toggle="dropdown"{% endif %}>
{% if nav.icon %}
<span class="fa fa-{{ nav.icon }}"></span>
{% if "<svg" in nav.icon %}
{{ nav.icon|safe }}
{% else %}
<span class="fa fa-{{ nav.icon }}"></span>
{% endif %}
<span class="visible-xs-inline">{{ nav.label }}</span>
{% else %}
{{ nav.label }}
@@ -270,7 +274,11 @@
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
{% if nav.children %}class="has-children"{% endif %}>
{% if nav.icon %}
<i class="fa fa-{{ nav.icon }} fa-fw"></i>
{% if "<svg" in nav.icon %}
{{ nav.icon|safe }}
{% else %}
<span class="fa fa-fw fa-{{ nav.icon }}"></span>
{% endif %}
{% endif %}
{{ nav.label }}
</a>
@@ -72,7 +72,7 @@
</h3>
</div>
<div class="panel-body">
<div class="form-order-change" data-pricecalc-endpoint="{% url "api-v1:orderposition-price_calc" organizer=order.event.organizer.slug event=order.event.slug pk=position.pk %}">
<div class="form-order-change" data-pricecalc-endpoint="{% url "api-v1:orderposition-price_calc" organizer=order.event.organizer.slug event=order.event.slug pk=position.pk %}" {% if position.subevent %}data-subevent="{{ position.subevent.id }}{% endif %}">
{% bootstrap_form_errors position.form %}
{% if position.custom_error %}
<div class="alert alert-danger">
@@ -87,20 +87,6 @@
<strong>{% trans "Change to" %}</strong>
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Product" %}</strong>
</div>
<div class="col-sm-5">
{{ position.item }}
{% if position.variation %}
{{ position.variation }}
{% endif %}
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.itemvar layout='inline' %}
</div>
</div>
{% if request.event.has_subevents %}
<div class="row">
@@ -116,6 +102,34 @@
</div>
{% endif %}
{% if position.seat %}
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Seat" %}</strong>
</div>
<div class="col-sm-5">
{{ position.seat }}
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.seat layout='inline' %}
</div>
</div>
{% endif %}
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Product" %}</strong>
</div>
<div class="col-sm-5">
{{ position.item }}
{% if position.variation %}
{{ position.variation }}
{% endif %}
</div>
<div class="col-sm-4">
{% bootstrap_field position.form.itemvar layout='inline' %}
</div>
</div>
<div class="row">
<div class="col-sm-3">
<strong>{% trans "Price" %}</strong>
@@ -182,6 +196,7 @@
{% if add_form.subevent %}
{% bootstrap_field add_form.subevent layout="control" %}
{% endif %}
{% bootstrap_field add_form.seat layout="control" %}
</div>
</div>
</div>
@@ -258,6 +258,15 @@
<span class="fa fa-fw fa-check" data-toggle="tooltip_html" title="{{ c.list.name }}<br>{% blocktrans trimmed with date=c.datetime|date:'SHORT_DATETIME_FORMAT' %}First scanned: {{ date }}{% endblocktrans %}"></span>
{% endfor %}
{% 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:" %}
<a href="{% url "control:event.voucher" event=request.event.slug organizer=request.event.organizer.slug voucher=line.voucher.pk %}">
@@ -434,6 +434,12 @@
</button>
</p>
</fieldset>
<fieldset>
<legend>{% trans "Additional settings" %}</legend>
{% for f in plugin_forms %}
{% bootstrap_form f layout="control" %}
{% endfor %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
@@ -192,6 +192,12 @@
</button>
</p>
</fieldset>
<fieldset>
<legend>{% trans "Additional settings" %}</legend>
{% for f in plugin_forms %}
{% bootstrap_form f layout="control" %}
{% endfor %}
</fieldset>
</div>
{% if subevent.pk %}
<div class="col-xs-12 col-lg-2">
+1
View File
@@ -140,6 +140,7 @@ urlpatterns = [
url(r'^pdf/editor/(?P<filename>[^/]+).pdf$', pdf.PdfView.as_view(), name='pdf.background'),
url(r'^subevents/$', subevents.SubEventList.as_view(), name='event.subevents'),
url(r'^subevents/select2$', typeahead.subevent_select2, name='event.subevents.select2'),
url(r'^seats/select2$', typeahead.seat_select2, name='event.seats.select2'),
url(r'^subevents/(?P<subevent>\d+)/$', subevents.SubEventUpdate.as_view(), name='event.subevent'),
url(r'^subevents/(?P<subevent>\d+)/delete$', subevents.SubEventDelete.as_view(),
name='event.subevent.delete'),
+5 -1
View File
@@ -1236,7 +1236,8 @@ class OrderChange(OrderView):
ocm.add_position(item, variation,
self.add_form.cleaned_data['price'],
self.add_form.cleaned_data.get('addon_to'),
self.add_form.cleaned_data.get('subevent'))
self.add_form.cleaned_data.get('subevent'),
self.add_form.cleaned_data.get('seat'))
except OrderError as e:
self.add_form.custom_error = str(e)
return False
@@ -1266,6 +1267,9 @@ class OrderChange(OrderView):
if item != p.item or variation != p.variation:
ocm.change_item(p, item, variation)
if p.seat and p.form.cleaned_data['seat'] and p.form.cleaned_data['seat'] != p.seat:
ocm.change_seat(p, p.form.cleaned_data['seat'])
if self.request.event.has_subevents and p.form.cleaned_data['subevent'] and p.form.cleaned_data['subevent'] != p.subevent:
ocm.change_subevent(p, p.form.cleaned_data['subevent'])
+63 -15
View File
@@ -3,6 +3,7 @@ from datetime import datetime
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rruleset
from django.contrib import messages
from django.core.files import File
from django.db import transaction
from django.db.models import F, IntegerField, OuterRef, Prefetch, Subquery, Sum
from django.db.models.functions import Coalesce
@@ -32,6 +33,7 @@ from pretix.control.forms.subevents import (
SubEventMetaValueForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import subevent_forms
from pretix.control.views import PaginationMixin
from pretix.control.views.event import MetaDataEditorMixin
from pretix.helpers.models import modelcopy
@@ -135,6 +137,16 @@ class SubEventEditorMixin(MetaDataEditorMixin):
meta_form = SubEventMetaValueForm
meta_model = SubEventMetaValue
@cached_property
def plugin_forms(self):
forms = []
for rec, resp in subevent_forms.send(sender=self.request.event, subevent=self.object, request=self.request):
if isinstance(resp, (list, tuple)):
forms.extend(resp)
else:
forms.append(resp)
return forms
def _make_meta_form(self, p, val_instances):
if not hasattr(self, '_default_meta'):
self._default_meta = self.request.event.meta_data
@@ -294,6 +306,7 @@ class SubEventEditorMixin(MetaDataEditorMixin):
ctx['cl_formset'] = self.cl_formset
ctx['itemvar_forms'] = self.itemvar_forms
ctx['meta_forms'] = self.meta_forms
ctx['plugin_forms'] = self.plugin_forms
return ctx
@cached_property
@@ -347,7 +360,7 @@ class SubEventEditorMixin(MetaDataEditorMixin):
def is_valid(self, form):
return form.is_valid() and all([f.is_valid() for f in self.itemvar_forms]) and self.formset.is_valid() and (
all([f.is_valid() for f in self.meta_forms])
) and self.cl_formset.is_valid()
) and self.cl_formset.is_valid() and all(f.is_valid() for f in self.plugin_forms)
class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateView):
@@ -361,9 +374,9 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi
self.object = self.get_object()
form = self.get_form()
if self.is_valid(form):
return self.form_valid(form)
else:
return self.form_invalid(form)
r = self.form_valid(form)
return r
return self.form_invalid(form)
def get_object(self, queryset=None) -> SubEvent:
try:
@@ -384,12 +397,23 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi
# TODO: LogEntry?
messages.success(self.request, _('Your changes have been saved.'))
if form.has_changed():
if form.has_changed() or any(f.has_changed() for f in self.plugin_forms):
data = {
k: form.cleaned_data.get(k) for k in form.changed_data
}
for f in self.plugin_forms:
data.update({
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.changed_data
})
self.object.log_action(
'pretix.subevent.changed', user=self.request.user, data={
k: form.cleaned_data.get(k) for k in form.changed_data
}
'pretix.subevent.changed', user=self.request.user, data=data
)
for f in self.plugin_forms:
f.subevent = self.object
f.save()
return super().form_valid(form)
def get_success_url(self) -> str:
@@ -416,8 +440,7 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi
form = self.get_form()
if self.is_valid(form):
return self.form_valid(form)
else:
return self.form_invalid(form)
return self.form_invalid(form)
def get_success_url(self) -> str:
return reverse('control:event.subevents', kwargs={
@@ -442,7 +465,16 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi
messages.success(self.request, pgettext_lazy('subevent', 'The new date has been created.'))
ret = super().form_valid(form)
self.object = form.instance
form.instance.log_action('pretix.subevent.added', data=dict(form.cleaned_data), user=self.request.user)
data = dict(form.cleaned_data)
for f in self.plugin_forms:
data.update({
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.cleaned_data
})
form.instance.log_action('pretix.subevent.added', data=dict(data), user=self.request.user)
self.save_formset(form.instance)
self.save_cl_formset(form.instance)
@@ -452,6 +484,9 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi
for f in self.meta_forms:
f.instance.subevent = form.instance
self.save_meta()
for f in self.plugin_forms:
f.subevent = form.instance
f.save()
return ret
@cached_property
@@ -657,7 +692,6 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea
@transaction.atomic
def form_valid(self, form):
tz = self.request.event.timezone
cnt = 0
for rdate in self.get_rrule_set():
@@ -685,7 +719,15 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea
else None
)
se.save()
se.log_action('pretix.subevent.added', data=dict(form.cleaned_data), user=self.request.user)
data = dict(form.cleaned_data)
for f in self.plugin_forms:
data.update({
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.cleaned_data
})
se.log_action('pretix.subevent.added', data=data, user=self.request.user)
for f in self.meta_forms:
if f.cleaned_data.get('value'):
@@ -731,6 +773,11 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea
i.subevent = se
i.save()
for f in self.plugin_forms:
f.is_valid()
f.subevent = se
f.save()
cnt += 1
messages.success(self.request, pgettext_lazy('subevent', '{} new dates have been created.').format(cnt))
@@ -742,7 +789,8 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea
def post(self, request, *args, **kwargs):
form = self.get_form()
self.object = SubEvent(event=self.request.event)
if self.is_valid(form):
return self.form_valid(form)
else:
return self.form_invalid(form)
return self.form_invalid(form)
+41 -1
View File
@@ -11,7 +11,7 @@ from django.utils.formats import get_format
from django.utils.timezone import make_aware
from django.utils.translation import pgettext, ugettext as _
from pretix.base.models import Order, Organizer, User, Voucher
from pretix.base.models import Order, Organizer, SubEvent, User, Voucher
from pretix.control.forms.event import EventWizardCopyForm
from pretix.control.permissions import event_permission_required
from pretix.helpers.daterange import daterange
@@ -221,6 +221,46 @@ def nav_context_list(request):
return JsonResponse(doc)
@event_permission_required("can_view_orders")
def seat_select2(request, **kwargs):
query = request.GET.get('query', '')
try:
page = int(request.GET.get('page', '1'))
except ValueError:
page = 1
if request.event.has_subevents:
try:
qs = request.event.subevents.get(active=True, pk=request.GET.get('subevent', 0)).free_seats
except SubEvent.DoesNotExist:
qs = request.event.seats.none()
else:
qs = request.event.free_seats
qs = qs.filter(
Q(name__icontains=query) | Q(seat_guid__icontains=query)
).order_by('name').select_related('product', 'subevent')
total = qs.count()
pagesize = 20
offset = (page - 1) * pagesize
doc = {
'results': [
{
'id': e.pk,
'text': '{} ({})'.format(e.name, str(e.product)),
'product': e.product_id,
'event': str(e.subevent) if e.subevent else ''
}
for e in qs[offset:offset + pagesize]
],
'pagination': {
"more": total >= (offset + pagesize)
}
}
return JsonResponse(doc)
@event_permission_required(None)
def subevent_select2(request, **kwargs):
query = request.GET.get('query', '')
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="14" viewBox="0 0 4.7624999 3.7041668">
<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>

After

Width:  |  Height:  |  Size: 1.1 KiB

+12
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=[]
)
@@ -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 %}
@@ -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>
@@ -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 %}
@@ -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">
@@ -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>
@@ -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 %}
@@ -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 %}
@@ -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>
+4
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'),
+4 -4
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 = []
+18 -4
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
+61 -5
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:
+2
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()
@@ -1,6 +1,13 @@
.sidebar-nav li > a > .fa {
color: $navbar-inverse-bg;
}
.sidebar-nav li > a > svg {
position: relative;
top: 3px;
path {
fill: $navbar-inverse-bg;
}
}
.nav .testmode a {
background: $brand-warning;
color: black;
@@ -168,6 +175,11 @@
border: 1px solid #ddd;
}
svg.svg-icon {
position: relative;
top: 2px;
}
@include table-row-variant('success', lighten($brand-success, 40%));
@include table-row-variant('info', lighten($brand-info, 30%));
@include table-row-variant('warning', lighten($brand-warning, 40%));
@@ -46,6 +46,58 @@ $(function () {
);
};
$itemvar.on("change", update_price);
$subevent.on("change", update_price);
$subevent.on("change", update_price).on("change", function () {
var seat = $(this).closest(".form-order-change").find("[id$=seat]");
if (seat.length) {
seat.prop("required", !!$subevent.val());
}
});
});
$('[data-model-select2=seat]').each(function () {
var $s = $(this);
$s.select2({
theme: "bootstrap",
delay: 100,
allowClear: !$s.prop("required"),
width: '100%',
language: $("body").attr("data-select2-locale"),
placeholder: $(this).attr("data-placeholder"),
ajax: {
url: function() {
var se = $(this).closest(".form-order-change, .form-horizontal").attr("data-subevent");
var url = $(this).attr('data-select2-url');
var changed = $(this).closest(".form-order-change, .form-horizontal").find("[id$=subevent]").val();
if (changed) {
return url + '?subevent=' + changed;
} else if (se) {
return url + '?subevent=' + se;
} else {
return url;
}
},
data: function (params) {
return {
query: params.term,
page: params.page || 1
}
}
},
templateResult: function (res) {
if (!res.id) {
return res.text;
}
var $ret = $("<span>").append(
$("<span>").addClass("primary").append($("<div>").text(res.text).html())
);
if (res.event) {
$ret.append(
$("<span>").addClass("secondary").append(
$("<span>").addClass("fa fa-calendar fa-fw")
).append(" ").append($("<div>").text(res.event).html())
);
}
return $ret;
},
});
});
});
@@ -197,6 +197,11 @@ $(function () {
is_enabled = true;
}
});
$(".input-seat-selection option").each(function() {
if ($(this).val() && $(this).val() !== "" && $(this).prop('selected')) {
is_enabled = true;
}
});
}
if (!is_enabled) {
$("#btn-add-to-cart").prop("disabled", !is_enabled).popover({'content': gettext("Please enter a quantity for one of the ticket types."), 'placement': 'top', 'trigger': 'hover focus'});
@@ -205,7 +210,8 @@ $(function () {
}
};
update_cart_form();
$(".product-row input[type=checkbox], .variations input[type=checkbox], .product-row input[type=radio], .variations input[type=radio], .input-item-count").on("change mouseup keyup", update_cart_form);
$(".product-row input[type=checkbox], .variations input[type=checkbox], .product-row input[type=radio], .variations input[type=radio], .input-item-count, .input-seat-selection")
.on("change mouseup keyup", update_cart_form);
$(".table-calendar td.has-events").click(function () {
var $tr = $(this).closest(".table-calendar").find(".selected-day");
@@ -44,6 +44,7 @@ var strings = {
'back': django.pgettext('widget', 'Back'),
'next_month': django.pgettext('widget', 'Next month'),
'previous_month': django.pgettext('widget', 'Previous month'),
'show_seating': django.pgettext('widget', 'Open seat selection'),
'days': {
'MO': django.gettext('Mo'),
'TU': django.gettext('Tu'),
@@ -557,6 +558,26 @@ var shared_methods = {
window.open(redirect_url);
}
},
startseating: function () {
var redirect_url = this.$root.target_url + 'w/' + widget_id;
if (this.$root.subevent){
redirect_url += '/' + this.$root.subevent;
}
redirect_url += '/seatingframe/?iframe=1&locale=' + lang;
if (this.$root.cart_id) {
redirect_url += '&take_cart_id=' + this.$root.cart_id;
}
if (this.$root.widget_data) {
redirect_url += '&widget_data=' + escape(this.$root.widget_data_json);
}
if (this.$root.useIframe) {
var iframe = this.$root.overlay.$children[0].$refs['frame-container'].children[0];
this.$root.overlay.frame_loading = true;
iframe.src = redirect_url;
} else {
window.open(redirect_url);
}
},
handleResize: function () {
this.mobile = this.$refs.wrapper.clientWidth <= 800;
}
@@ -678,6 +699,11 @@ Vue.component('pretix-widget-event-form', {
+ strings['cart_exists']
+ '<div class="pretix-widget-clear"></div>'
+ '</div>'
+ '<div class="pretix-widget-seating-link-wrapper" v-if="this.$root.has_seating_plan">'
+ '<button class="pretix-widget-seating-link" @click.prevent="$parent.startseating">'
+ strings['show_seating']
+ '</button>'
+ '</div>'
+ '<category v-for="category in this.$root.categories" :category="category" :key="category.id"></category>'
+ '<div class="pretix-widget-action" v-if="$root.display_add_to_cart">'
+ '<button @click="$parent.buy" type="submit">' + strings.buy + '</button>'
@@ -1061,6 +1087,7 @@ var shared_root_methods = {
root.cart_id = cart_id;
root.cart_exists = data.cart_exists;
root.vouchers_exist = data.vouchers_exist;
root.has_seating_plan = data.has_seating_plan;
root.itemnum = data.itemnum;
}
if (root.loading > 0) {
@@ -1226,7 +1253,8 @@ var create_widget = function (element) {
disable_vouchers: disable_vouchers,
cart_exists: false,
itemcount: 0,
overlay: null
overlay: null,
has_seating_plan: false
}
},
created: function () {
@@ -478,6 +478,15 @@
font-weight: bold;
}
}
.pretix-widget-seating-link-wrapper {
padding: 0 15px;
margin: 15px 0 10px;
}
.pretix-widget-seating-link {
display: block;
width: 100%;
}
}
@keyframes pretix-widget-bounce-in {
@@ -0,0 +1,225 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"version": "0.0.1",
"title": "Seating Plan",
"description": "Seating plan for venues",
"type": "object",
"properties": {
"name": {
"type": "string"
},
"zones": {
"type": "array",
"description": "List of zones",
"items": { "$ref": "#/definitions/zone" }
},
"categories": {
"type": "array",
"description": "List of categories",
"items": { "$ref": "#/definitions/category" }
},
"size": {
"type": "object",
"description": "Size of the entire plan (in pixels)",
"properties": {
"width": { "type": "integer" },
"height": { "type": "integer" }
},
"required": ["width", "height"],
"additionalProperties": false
}
},
"required": ["zones", "name", "categories"],
"additionalProperties": false,
"definitions": {
"zone": {
"type": "object",
"description": "Zone represents the parent entity that groups all other entities. The zone itself can be hidden or displayed. Examples of different zones would be 'main area', 'balcony', etc.",
"properties": {
"name": {
"type": "string",
"description": "Name of the zone"
},
"displayed": {
"type": "boolean",
"description": "Should the zone outlines be displayed or not?"
},
"position": { "$ref": "#/definitions/position" },
"rows": {
"type": "array",
"description": "List of rows",
"items": { "$ref": "#/definitions/row" }
},
"areas": {
"type": "array",
"description": "List of areas",
"items": { "$ref": "#/definitions/area" }
},
"row_number_position": {
"type": ["string", "null"],
"enum": ["left", "right", null],
"description": "Should the row numbers be hidden?"
}
},
"required": ["position", "rows"],
"additionalProperties": false
},
"row": {
"type": "object",
"description": "Row is simply a collection of seats with some additional information that simplifies working with seats.",
"$comment": "Might need more (editor) infromation like direction, angle, curvature",
"properties": {
"row_number": {
"type": "string",
"description": "Row number or name by which it can be identified"
},
"seats": {
"type": "array",
"description": "List of seats in this row",
"items": { "$ref": "#/definitions/seat" }
},
"number_of_seats": {
"type": "integer",
"$comment": "This property might be redundant. Since the seats are nested within the row, it's easy to just count them."
},
"seats_spacing": {
"type": "integer",
"description": "How far apart should the seats be positioned?"
},
"row_number_position": {
"type": ["string", "null"],
"enum": ["left", "right", null],
"description": "Should the row numbers be hidden? (Overrides the zone setting)"
},
"position": {
"$ref": "#/definitions/position"
}
},
"required": ["seats", "row_number"],
"additionalProperties": false
},
"seat": {
"type": "object",
"description": "Individual seat that can be reserved.",
"properties": {
"seat_guid": {
"type": "string",
"description": "Seat global ID by which it can be identified. It should be globally unique (not just per row). It doesn't have to be pretty since it won't be shown to the user."
},
"seat_number": {
"type": "string",
"description": "Human-readable seat number."
},
"position": { "$ref": "#/definitions/position" },
"category": {
"type": "string",
"description": "What category does this seat belong to? This needs to refer to the name of a category defined on the top level of the file. Keep in mind that there is no way to enfore this requirement with this version of JSON schema."
}
},
"required": ["seat_guid", "seat_number", "position", "category"],
"additionalProperties": false
},
"area": {
"type": "object",
"description": "Area can represent anything from general admission area, to stage, bar or even tables.",
"$comment": "TODO needs a definition: should it be defined with parameters or with a free-form svg?",
"properties": {
"color": { "type": "string" },
"border_color": { "type": "string" },
"rotation": { "type": "number" },
"position": {
"$ref": "#/definitions/position"
},
"shape": {
"type": "string",
"enum": ["polygon", "rectangle", "ellipse", "circle", "text"]
},
"polygon": {
"type": "object",
"properties": {
"points": {
"type": "array",
"items": { "$ref": "#/definitions/position" }
}
},
"required": ["points"],
"additionalProperties": false
},
"rectangle": {
"type": "object",
"properties": {
"width": {
"type": "integer"
},
"height": {
"type": "integer"
}
},
"required": ["width", "height"],
"additionalProperties": false
},
"ellipse": {
"type": "object",
"properties": {
"radius": { "$ref": "#/definitions/position" }
},
"required": ["radius"],
"additionalProperties": false
},
"circle": {
"type": "object",
"properties": {
"radius": {
"type": "integer"
}
},
"required": ["radius"],
"additionalProperties": false
},
"text": {
"type": "object",
"properties": {
"text": { "type": "string" },
"color": { "type": "string" },
"position": {
"$ref": "#/definitions/position"
}
},
"required": ["text", "position"],
"additionalProperties": false
}
},
"additionalProperties": false
},
"position": {
"type": "object",
"description": "Position of the element",
"properties": {
"x": {
"type": "integer"
},
"y": {
"type": "integer"
}
},
"required": ["x", "y"],
"additionalProperties": false
},
"category": {
"type": "object",
"description": "A category of seats, e.g. a price level.",
"properties": {
"name": {
"type": "string",
"description": "Internal name of the seats, e.g. 'stalls'. This should be used to map this to actual shop products."
},
"color": {
"type": "string",
"description": "The color used to draw seats of this category."
}
},
"required": ["name"],
"additionalProperties": false
}
}
}
+1
View File
@@ -35,6 +35,7 @@ sentry-sdk==0.7.*
babel
django-i18nfield>=1.4.0
django-hijack>=2.1.10,<2.2.0
jsonschema
openpyxl
django-oauth-toolkit==1.2.*
oauthlib==2.1.*
+2
View File
@@ -138,6 +138,8 @@ setup(
'defusedcsv',
'vat_moss==0.11.0',
'django-localflavor>=2.2',
'django-localflavor',
'jsonschema',
'django-hijack>=2.1.10,<2.2.0',
'openpyxl',
'django-oauth-toolkit==1.2.*',
+107 -1
View File
@@ -8,7 +8,7 @@ from django.utils.timezone import now
from django_scopes import scopes_disabled
from pytz import UTC
from pretix.base.models import Question
from pretix.base.models import Question, SeatingPlan
from pretix.base.models.orders import CartPosition
@@ -64,6 +64,7 @@ TEST_CARTPOSITION_RES = {
'datetime': '2018-06-11T10:00:00Z',
'expires': '2018-06-11T10:00:00Z',
'includes_tax': True,
'seat': None,
'answers': []
}
@@ -621,3 +622,108 @@ def test_cartpos_create_quota_validation(token_client, organizer, event, item, q
)
assert resp.status_code == 400
assert resp.data == ['There is not enough quota available on quota "Budget Quota" to perform the operation.']
@pytest.fixture
def seat(event, organizer, item):
SeatingPlan.objects.create(
name="Plan", organizer=organizer, layout="{}"
)
event.seat_category_mappings.create(
layout_category='Stalls', product=item
)
return event.seats.create(name="A1", product=item, seat_guid="A1")
@pytest.mark.django_db
def test_cartpos_create_with_seat(token_client, organizer, event, item, quota, seat, question):
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
res['seat'] = seat.seat_guid
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
p = CartPosition.objects.get(pk=resp.data['id'])
assert p.seat == seat
@pytest.mark.django_db
def test_cartpos_create_with_blocked_seat(token_client, organizer, event, item, quota, seat, question):
seat.blocked = True
seat.save()
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
res['seat'] = seat.seat_guid
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The selected seat "A1" is not available.']
@pytest.mark.django_db
def test_cartpos_create_with_used_seat(token_client, organizer, event, item, quota, seat, question):
CartPosition.objects.create(
event=event, cart_id='aaa', item=item,
price=21.5, expires=now() + datetime.timedelta(minutes=10), seat=seat
)
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
res['seat'] = seat.seat_guid
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The selected seat "A1" is not available.']
@pytest.mark.django_db
def test_cartpos_create_with_unknown_seat(token_client, organizer, event, item, quota, seat, question):
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
res['seat'] = seat.seat_guid + '_'
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The specified seat does not exist.']
@pytest.mark.django_db
def test_cartpos_create_require_seat(token_client, organizer, event, item, quota, seat, question):
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The specified product requires to choose a seat.']
@pytest.mark.django_db
def test_cartpos_create_unseated(token_client, organizer, event, item, quota, seat, question):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
quota.items.add(item2)
res = copy.deepcopy(CARTPOS_CREATE_PAYLOAD)
res['item'] = item2.pk
res['seat'] = seat.seat_guid
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/cartpositions/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == ['The specified product does not allow to choose a seat.']
+305 -1
View File
@@ -8,7 +8,9 @@ from django_countries.fields import Country
from django_scopes import scopes_disabled
from pytz import UTC
from pretix.base.models import Event, InvoiceAddress, Order, OrderPosition
from pretix.base.models import (
Event, InvoiceAddress, Order, OrderPosition, SeatingPlan,
)
from pretix.base.models.orders import OrderFee
@@ -69,6 +71,8 @@ TEST_EVENT_RES = {
"location": None,
"slug": "dummy",
"has_subevents": False,
"seating_plan": None,
"seat_category_mapping": {},
"meta_data": {"type": "Conference"},
'plugins': [
'pretix.plugins.banktransfer',
@@ -618,3 +622,303 @@ def test_event_with_order_position_not_delete(token_client, organizer, event, it
'set \'live\' to false to hide the event and take the shop offline instead."}'
with scopes_disabled():
assert organizer.events.filter(pk=event.id).exists()
@pytest.fixture
def seatingplan(event, organizer, item):
return SeatingPlan.objects.create(
name="Plan", organizer=organizer, layout="""{
"name": "Grosser Saal",
"categories": [
{
"name": "Stalls",
"color": "red"
}
],
"zones": [
{
"name": "Main Area",
"position": {
"x": 0,
"y": 0
},
"rows": [
{
"row_number": "0",
"seats": [
{
"seat_guid": "0-0",
"seat_number": "0-0",
"position": {
"x": 0,
"y": 0
},
"category": "Stalls"
},
{
"seat_guid": "0-1",
"seat_number": "0-1",
"position": {
"x": 33,
"y": 0
},
"category": "Stalls"
},
{
"seat_guid": "0-2",
"seat_number": "0-2",
"position": {
"x": 66,
"y": 0
},
"category": "Stalls"
}
],
"position": {
"x": 0,
"y": 0
}
}
]
}
],
"size": {
"width": 600,
"height": 400
}
}"""
)
@pytest.mark.django_db
def test_event_update_seating(token_client, organizer, event, item, seatingplan):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"seating_plan": seatingplan.pk,
"seat_category_mapping": {
"Stalls": item.pk
}
},
format='json'
)
assert resp.status_code == 200
event.refresh_from_db()
assert event.seating_plan == seatingplan
with scopes_disabled():
assert event.seats.count() == 3
assert event.seats.filter(product=item).count() == 3
m = event.seat_category_mappings.get()
assert m.layout_category == 'Stalls'
assert m.product == item
@pytest.mark.django_db
def test_event_update_seating_invalid_product(token_client, organizer, event, item, seatingplan):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"seating_plan": seatingplan.pk,
"seat_category_mapping": {
"Stalls": item.pk + 2
}
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"seat_category_mapping":["Item \'%d\' does not exist."]}' % (item.pk + 2)
@pytest.mark.django_db
def test_event_update_seating_change_mapping(token_client, organizer, event, item, seatingplan):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"seating_plan": seatingplan.pk,
"seat_category_mapping": {
"Stalls": item.pk
}
},
format='json'
)
assert resp.status_code == 200
event.refresh_from_db()
assert event.seating_plan == seatingplan
with scopes_disabled():
assert event.seats.count() == 3
assert event.seats.filter(product=item).count() == 3
m = event.seat_category_mappings.get()
assert m.layout_category == 'Stalls'
assert m.product == item
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"seat_category_mapping": {
"VIP": item.pk,
}
},
format='json'
)
assert resp.status_code == 200
event.refresh_from_db()
assert event.seating_plan == seatingplan
with scopes_disabled():
assert event.seats.count() == 3
m = event.seat_category_mappings.get()
assert event.seats.filter(product=None).count() == 3
assert m.layout_category == 'VIP'
assert m.product == item
@pytest.mark.django_db
def test_remove_seating(token_client, organizer, event, item, seatingplan):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"seating_plan": seatingplan.pk,
"seat_category_mapping": {
"Stalls": item.pk
}
},
format='json'
)
assert resp.status_code == 200
event.refresh_from_db()
assert event.seating_plan == seatingplan
with scopes_disabled():
assert event.seats.count() == 3
assert event.seat_category_mappings.count() == 1
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"seating_plan": None
},
format='json'
)
assert resp.status_code == 200
event.refresh_from_db()
assert event.seating_plan is None
with scopes_disabled():
assert event.seats.count() == 0
assert event.seat_category_mappings.count() == 0
@pytest.mark.django_db
def test_remove_seating_forbidden(token_client, organizer, event, item, seatingplan, order_position):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"seating_plan": seatingplan.pk,
"seat_category_mapping": {
"Stalls": item.pk
}
},
format='json'
)
assert resp.status_code == 200
event.refresh_from_db()
assert event.seating_plan == seatingplan
with scopes_disabled():
assert event.seats.count() == 3
assert event.seat_category_mappings.count() == 1
order_position.seat = event.seats.first()
order_position.save()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"seating_plan": None
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"seating_plan":["You can not change the plan since seat \\"0-0\\" is not ' \
'present in the new plan and is already sold."]}'
@pytest.mark.django_db
def test_no_seating_for_series(token_client, organizer, event, item, seatingplan, order_position):
event.has_subevents = True
event.save()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"seating_plan": seatingplan.pk,
"seat_category_mapping": {
"Stalls": item.pk
}
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["Event series should not directly be assigned a seating plan."]}'
@pytest.mark.django_db
def test_event_create_with_seating(token_client, organizer, event, meta_prop, seatingplan):
resp = token_client.post(
'/api/v1/organizers/{}/events/'.format(organizer.slug),
{
"name": {
"de": "Demo Konference 2020 Test",
"en": "Demo Conference 2020 Test"
},
"live": False,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": "2017-12-28T10:00:00Z",
"date_admission": None,
"is_public": False,
"presale_start": None,
"presale_end": None,
"location": None,
"slug": "2030",
"seating_plan": seatingplan.pk,
"meta_data": {
meta_prop.name: "Conference"
}
},
format='json'
)
assert resp.status_code == 201
with scopes_disabled():
event = Event.objects.get(slug=resp.data['slug'])
assert event.seating_plan == seatingplan
assert event.seats.count() == 3
assert event.seat_category_mappings.count() == 0
@pytest.mark.django_db
def test_event_create_with_seating_maps(token_client, organizer, event, meta_prop, seatingplan):
resp = token_client.post(
'/api/v1/organizers/{}/events/'.format(organizer.slug),
{
"name": {
"de": "Demo Konference 2020 Test",
"en": "Demo Conference 2020 Test"
},
"live": False,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": "2017-12-28T10:00:00Z",
"date_admission": None,
"is_public": False,
"presale_start": None,
"presale_end": None,
"location": None,
"slug": "2030",
"seating_plan": seatingplan.pk,
"seat_category_mapping": {
"Foo": 1,
},
"meta_data": {
meta_prop.name: "Conference"
}
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"seat_category_mapping":["You cannot specify seat category mappings on event creation."]}'
+202 -1
View File
@@ -13,7 +13,9 @@ from pytz import UTC
from stripe.error import APIConnectionError
from tests.plugins.stripe.test_provider import MockedCharge
from pretix.base.models import InvoiceAddress, Order, OrderPosition, Question
from pretix.base.models import (
InvoiceAddress, Order, OrderPosition, Question, SeatingPlan,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund,
)
@@ -139,6 +141,7 @@ TEST_ORDERPOSITION_RES = {
"pseudonymization_id": "ABCDEFGHKL",
"checkins": [],
"downloads": [],
"seat": None,
"answers": [
{
"question": 1,
@@ -2519,6 +2522,204 @@ def test_order_create_paid_generate_invoice(token_client, organizer, event, item
assert p.payment_date.minute == 20
@pytest.fixture
def seat(event, organizer, item):
SeatingPlan.objects.create(
name="Plan", organizer=organizer, layout="{}"
)
event.seat_category_mappings.create(
layout_category='Stalls', product=item
)
return event.seats.create(name="A1", product=item, seat_guid="A1")
@pytest.mark.django_db
def test_order_create_with_seat(token_client, organizer, event, item, quota, seat, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['seat'] = seat.seat_guid
res['positions'][0]['answers'][0]['question'] = question.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
p = o.positions.first()
assert p.seat == seat
@pytest.mark.django_db
def test_order_create_with_blocked_seat(token_client, organizer, event, item, quota, seat, question):
seat.blocked = True
seat.save()
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['seat'] = seat.seat_guid
res['positions'][0]['answers'][0]['question'] = question.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{'seat': ['The selected seat "A1" is not available.']},
]
}
@pytest.mark.django_db
def test_order_create_with_used_seat(token_client, organizer, event, item, quota, seat, question):
CartPosition.objects.create(
event=event, cart_id='aaa', item=item,
price=21.5, expires=now() + datetime.timedelta(minutes=10), seat=seat
)
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['seat'] = seat.seat_guid
res['positions'][0]['answers'][0]['question'] = question.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{'seat': ['The selected seat "A1" is not available.']},
]
}
@pytest.mark.django_db
def test_order_create_with_unknown_seat(token_client, organizer, event, item, quota, seat, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['seat'] = seat.seat_guid + '_'
res['positions'][0]['answers'][0]['question'] = question.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{'seat': ['The specified seat does not exist.']},
]
}
@pytest.mark.django_db
def test_order_create_require_seat(token_client, organizer, event, item, quota, seat, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['answers'][0]['question'] = question.pk
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{'seat': ['The specified product requires to choose a seat.']},
]
}
@pytest.mark.django_db
def test_order_create_unseated(token_client, organizer, event, item, quota, seat, question):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
quota.items.add(item2)
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item2.pk
res['positions'][0]['answers'][0]['question'] = question.pk
res['positions'][0]['seat'] = seat.seat_guid
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{'seat': ['The specified product does not allow to choose a seat.']},
]
}
@pytest.mark.django_db
def test_order_create_with_duplicate_seat(token_client, organizer, event, item, quota, seat, question):
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'] = [
{
"positionid": 1,
"item": item.pk,
"variation": None,
"price": "23.00",
"attendee_name_parts": {"full_name": "Peter"},
"attendee_email": None,
"addon_to": None,
"answers": [],
"subevent": None,
"seat": seat.seat_guid
},
{
"positionid": 2,
"item": item.pk,
"variation": None,
"price": "23.00",
"attendee_name_parts": {"full_name": "Peter"},
"attendee_email": None,
"addon_to": 1,
"answers": [],
"subevent": None,
"seat": seat.seat_guid
}
]
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 400
assert resp.data == {
'positions': [
{},
{'seat': ['The selected seat "A1" is not available.']},
]
}
@pytest.mark.django_db
def test_order_create_with_seat_consumed_from_cart(token_client, organizer, event, item, quota, seat, question):
CartPosition.objects.create(
event=event, cart_id='aaa', item=item,
price=21.5, expires=now() + datetime.timedelta(minutes=10), seat=seat
)
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
res['positions'][0]['item'] = item.pk
res['positions'][0]['seat'] = seat.seat_guid
res['positions'][0]['answers'][0]['question'] = question.pk
res['consume_carts'] = ['aaa']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/'.format(
organizer.slug, event.slug
), format='json', data=res
)
assert resp.status_code == 201
with scopes_disabled():
o = Order.objects.get(code=resp.data['code'])
p = o.positions.first()
assert p.seat == seat
REFUND_CREATE_PAYLOAD = {
"state": "created",
"provider": "manual",
+174
View File
@@ -0,0 +1,174 @@
import copy
import json
import pytest
from django_scopes import scopes_disabled
from pretix.base.models import SeatingPlan
SAMPLE_PLAN = """{
"name": "Sample plan",
"categories": [
{
"name": "Stalls",
"color": "#33ffff"
}
],
"zones": [
{
"name": "Main Area",
"position": {
"x": 0,
"y": 0
},
"row_number_position": "left",
"rows": [
{
"row_number": "0",
"seats": [
{
"seat_guid": "0-0",
"seat_number": "0-0",
"position": {
"x": 0,
"y": 0
},
"category": "Stalls"
},
{
"seat_guid": "0-1",
"seat_number": "0-1",
"position": {
"x": 30,
"y": 0
},
"category": "Stalls"
}
],
"position": {
"x": 40,
"y": 25
}
}
]
}
],
"size": {
"width": 600,
"height": 500
}
}"""
@pytest.fixture
def seatingplan(organizer, event):
wh = organizer.seating_plans.create(
name="Plan",
layout=SAMPLE_PLAN
)
return wh
TEST_PLAN_RES = {
"id": 1,
"name": "Plan",
"layout": json.loads(SAMPLE_PLAN)
}
@pytest.mark.django_db
def test_plan_list(token_client, organizer, event, seatingplan):
res = dict(TEST_PLAN_RES)
res["id"] = seatingplan.pk
resp = token_client.get('/api/v1/organizers/{}/seatingplans/'.format(organizer.slug))
assert resp.status_code == 200
assert [res] == resp.data['results']
@pytest.mark.django_db
def test_plan_detail(token_client, organizer, event, seatingplan):
res = dict(TEST_PLAN_RES)
res["id"] = seatingplan.pk
resp = token_client.get('/api/v1/organizers/{}/seatingplans/{}/'.format(organizer.slug, seatingplan.pk))
assert resp.status_code == 200
assert res == resp.data
TEST_PLAN_CREATE_PAYLOAD = {
"name": "Plan 2",
"layout": json.loads(SAMPLE_PLAN)
}
@pytest.mark.django_db
def test_plan_create(token_client, organizer, event):
resp = token_client.post(
'/api/v1/organizers/{}/seatingplans/'.format(organizer.slug),
TEST_PLAN_CREATE_PAYLOAD,
format='json'
)
assert resp.status_code == 201
with scopes_disabled():
cl = SeatingPlan.objects.get(pk=resp.data['id'])
assert json.loads(cl.layout) == TEST_PLAN_CREATE_PAYLOAD['layout']
@pytest.mark.django_db
def test_plan_create_invalid_layout(token_client, organizer, event):
res = copy.copy(TEST_PLAN_CREATE_PAYLOAD)
res['layout'] = {'foo': 'bar'}
resp = token_client.post(
'/api/v1/organizers/{}/seatingplans/'.format(organizer.slug),
res,
format='json'
)
assert resp.status_code == 400
@pytest.mark.django_db
def test_plan_patch(token_client, organizer, event, seatingplan):
resp = token_client.patch(
'/api/v1/organizers/{}/seatingplans/{}/'.format(organizer.slug, seatingplan.pk),
{
'name': 'Foo'
},
format='json'
)
assert resp.status_code == 200
seatingplan.refresh_from_db()
assert seatingplan.name == "Foo"
@pytest.mark.django_db
def test_plan_delete(token_client, organizer, event, seatingplan):
resp = token_client.delete(
'/api/v1/organizers/{}/seatingplans/{}/'.format(organizer.slug, seatingplan.pk),
)
assert resp.status_code == 204
with scopes_disabled():
assert SeatingPlan.objects.count() == 0
@pytest.mark.django_db
def test_plan_patch_used(token_client, organizer, event, seatingplan):
event.seating_plan = seatingplan
event.save()
resp = token_client.patch(
'/api/v1/organizers/{}/seatingplans/{}/'.format(organizer.slug, seatingplan.pk),
{
'name': 'Foo'
},
format='json'
)
assert resp.status_code == 403
@pytest.mark.django_db
def test_plan_delete_used(token_client, organizer, event, seatingplan):
event.seating_plan = seatingplan
event.save()
resp = token_client.delete(
'/api/v1/organizers/{}/seatingplans/{}/'.format(organizer.slug, seatingplan.pk),
)
assert resp.status_code == 403
+258 -1
View File
@@ -7,7 +7,9 @@ from django_countries.fields import Country
from django_scopes import scopes_disabled
from pytz import UTC
from pretix.base.models import InvoiceAddress, Order, OrderPosition
from pretix.base.models import (
InvoiceAddress, Order, OrderPosition, SeatingPlan, SubEvent,
)
from pretix.base.models.orders import OrderFee
@@ -72,6 +74,8 @@ TEST_SUBEVENT_RES = {
'name': {'en': 'Foobar'},
'date_from': '2017-12-27T10:00:00Z',
'presale_end': None,
'seating_plan': None,
"seat_category_mapping": {},
'id': 1,
'variation_price_overrides': [],
'location': None,
@@ -573,3 +577,256 @@ def test_subevent_with_order_position_not_delete(token_client, organizer, event,
'orders. Please set \'active\' to false instead to hide it from users."}'
with scopes_disabled():
assert event.subevents.filter(pk=subevent.id).exists()
@pytest.fixture
def seatingplan(event, organizer, item):
return SeatingPlan.objects.create(
name="Plan", organizer=organizer, layout="""{
"name": "Grosser Saal",
"categories": [
{
"name": "Stalls",
"color": "red"
}
],
"zones": [
{
"name": "Main Area",
"position": {
"x": 0,
"y": 0
},
"rows": [
{
"row_number": "0",
"seats": [
{
"seat_guid": "0-0",
"seat_number": "0-0",
"position": {
"x": 0,
"y": 0
},
"category": "Stalls"
},
{
"seat_guid": "0-1",
"seat_number": "0-1",
"position": {
"x": 33,
"y": 0
},
"category": "Stalls"
},
{
"seat_guid": "0-2",
"seat_number": "0-2",
"position": {
"x": 66,
"y": 0
},
"category": "Stalls"
}
],
"position": {
"x": 0,
"y": 0
}
}
]
}
],
"size": {
"width": 600,
"height": 400
}
}"""
)
@pytest.mark.django_db
def test_subevent_update_seating(token_client, organizer, event, item, subevent, seatingplan):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/subevents/{}/'.format(organizer.slug, event.slug, subevent.pk),
{
"seating_plan": seatingplan.pk,
"seat_category_mapping": {
"Stalls": item.pk
}
},
format='json'
)
assert resp.status_code == 200
subevent.refresh_from_db()
assert subevent.seating_plan == seatingplan
with scopes_disabled():
assert subevent.seats.count() == 3
assert subevent.seats.filter(product=item).count() == 3
m = subevent.seat_category_mappings.get()
assert m.layout_category == 'Stalls'
assert m.product == item
@pytest.mark.django_db
def test_subevent_update_seating_invalid_product(token_client, organizer, event, item, seatingplan, subevent):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/subevents/{}/'.format(organizer.slug, event.slug, subevent.pk),
{
"seating_plan": seatingplan.pk,
"seat_category_mapping": {
"Stalls": item.pk + 2
}
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"seat_category_mapping":["Item \'%d\' does not exist."]}' % (item.pk + 2)
@pytest.mark.django_db
def test_subevent_update_seating_change_mapping(token_client, organizer, event, item, seatingplan, subevent):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/subevents/{}/'.format(organizer.slug, event.slug, subevent.pk),
{
"seating_plan": seatingplan.pk,
"seat_category_mapping": {
"Stalls": item.pk
}
},
format='json'
)
assert resp.status_code == 200
subevent.refresh_from_db()
assert subevent.seating_plan == seatingplan
with scopes_disabled():
assert subevent.seats.count() == 3
assert subevent.seats.filter(product=item).count() == 3
m = subevent.seat_category_mappings.get()
assert m.layout_category == 'Stalls'
assert m.product == item
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/subevents/{}/'.format(organizer.slug, event.slug, subevent.pk),
{
"seat_category_mapping": {
"VIP": item.pk,
}
},
format='json'
)
assert resp.status_code == 200
subevent.refresh_from_db()
assert subevent.seating_plan == seatingplan
with scopes_disabled():
assert subevent.seats.count() == 3
m = subevent.seat_category_mappings.get()
assert subevent.seats.filter(product=None).count() == 3
assert m.layout_category == 'VIP'
assert m.product == item
@pytest.mark.django_db
def test_remove_seating(token_client, organizer, event, item, seatingplan, subevent):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/subevents/{}/'.format(organizer.slug, event.slug, subevent.pk),
{
"seating_plan": seatingplan.pk,
"seat_category_mapping": {
"Stalls": item.pk
}
},
format='json'
)
assert resp.status_code == 200
subevent.refresh_from_db()
assert subevent.seating_plan == seatingplan
with scopes_disabled():
assert subevent.seats.count() == 3
assert subevent.seat_category_mappings.count() == 1
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/subevents/{}/'.format(organizer.slug, event.slug, subevent.pk),
{
"seating_plan": None
},
format='json'
)
assert resp.status_code == 200
subevent.refresh_from_db()
assert subevent.seating_plan is None
with scopes_disabled():
assert subevent.seats.count() == 0
assert subevent.seat_category_mappings.count() == 0
@pytest.mark.django_db
def test_remove_seating_forbidden(token_client, organizer, event, item, seatingplan, order_position, subevent):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/subevents/{}/'.format(organizer.slug, event.slug, subevent.pk),
{
"seating_plan": seatingplan.pk,
"seat_category_mapping": {
"Stalls": item.pk
}
},
format='json'
)
assert resp.status_code == 200
subevent.refresh_from_db()
assert subevent.seating_plan == seatingplan
with scopes_disabled():
assert subevent.seats.count() == 3
assert subevent.seat_category_mappings.count() == 1
order_position.seat = subevent.seats.first()
order_position.save()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/subevents/{}/'.format(organizer.slug, event.slug, subevent.pk),
{
"seating_plan": None
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"seating_plan":["You can not change the plan since seat \\"0-0\\" is not ' \
'present in the new plan and is already sold."]}'
@pytest.mark.django_db
def test_subevent_create_with_seating(token_client, organizer, event, subevent, item, seatingplan):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/subevents/'.format(organizer.slug, event.slug),
{
"name": {
"de": "Demo Subevent 2020 Test",
"en": "Demo Subevent 2020 Test"
},
"active": False,
"date_from": "2017-12-27T10:00:00Z",
"date_to": "2017-12-28T10:00:00Z",
"date_admission": None,
"presale_start": None,
"presale_end": None,
"location": None,
"item_price_overrides": [
{
"item": item.pk,
"price": "23.42"
}
],
"variation_price_overrides": [],
"seat_category_mapping": {
"Stalls": item.pk
},
"meta_data": {},
"seating_plan": seatingplan.pk,
},
format='json'
)
assert resp.status_code == 201
with scopes_disabled():
subevent = SubEvent.objects.get(pk=resp.data['id'])
assert subevent.seats.count() == 3
assert subevent.seat_category_mappings.count() == 1
+152 -1
View File
@@ -18,7 +18,7 @@ from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, CartPosition, Checkin, CheckinList, Event, Item, ItemCategory,
ItemVariation, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund,
Organizer, Question, Quota, User, Voucher, WaitingListEntry,
Organizer, Question, Quota, SeatingPlan, User, Voucher, WaitingListEntry,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.items import (
@@ -1971,6 +1971,157 @@ class CheckinListTestCase(TestCase):
assert lists[3].percent == 25
class SeatingTestCase(TestCase):
def setUp(self):
self.organizer = Organizer.objects.create(name='Dummy', slug='dummy')
with scope(organizer=self.organizer):
self.event = Event.objects.create(
organizer=self.organizer, name='Dummy', slug='dummy',
date_from=now(), date_to=now() - timedelta(hours=1),
)
self.ticket = self.event.items.create(name="Ticket", default_price=12)
self.plan = SeatingPlan.objects.create(
name="Plan", organizer=self.organizer, layout="{}"
)
self.event.seat_category_mappings.create(
layout_category='Stalls', product=self.ticket
)
self.seat_a1 = self.event.seats.create(name="A1", product=self.ticket, blocked=False)
self.seat_a2 = self.event.seats.create(name="A2", product=self.ticket, blocked=False)
@classscope(attr='organizer')
def test_free(self):
assert set(self.event.free_seats) == {self.seat_a1, self.seat_a2}
assert self.seat_a1.is_available()
assert self.seat_a2.is_available()
@classscope(attr='organizer')
def test_blocked(self):
self.seat_a1.blocked = True
self.seat_a1.save()
assert set(self.event.free_seats) == {self.seat_a2}
assert not self.seat_a1.is_available()
assert self.seat_a2.is_available()
@classscope(attr='organizer')
def test_order_pending(self):
o = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test', total=Decimal("30"),
locale='en', status=Order.STATUS_PENDING, datetime=now(),
expires=now() + timedelta(days=10),
)
OrderPosition.objects.create(
order=o, item=self.ticket, variation=None, price=Decimal("12"),
seat=self.seat_a1
)
assert set(self.event.free_seats) == {self.seat_a2}
assert not self.seat_a1.is_available()
@classscope(attr='organizer')
def test_order_paid(self):
o = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test', total=Decimal("30"),
locale='en', status=Order.STATUS_PAID, datetime=now(),
expires=now() + timedelta(days=10),
)
OrderPosition.objects.create(
order=o, item=self.ticket, variation=None, price=Decimal("12"),
seat=self.seat_a1
)
assert set(self.event.free_seats) == {self.seat_a2}
assert not self.seat_a1.is_available()
@classscope(attr='organizer')
def test_order_expired(self):
o = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test', total=Decimal("30"),
locale='en', status=Order.STATUS_EXPIRED, datetime=now(),
expires=now() + timedelta(days=10),
)
OrderPosition.objects.create(
order=o, item=self.ticket, variation=None, price=Decimal("12"),
seat=self.seat_a1
)
assert set(self.event.free_seats) == {self.seat_a1, self.seat_a2}
assert self.seat_a1.is_available()
@classscope(attr='organizer')
def test_cart_active(self):
CartPosition.objects.create(
event=self.event, cart_id='a', item=self.ticket, seat=self.seat_a1,
price=23, expires=now() + timedelta(minutes=10)
)
assert set(self.event.free_seats) == {self.seat_a2}
assert not self.seat_a1.is_available()
@classscope(attr='organizer')
def test_cart_expired(self):
CartPosition.objects.create(
event=self.event, cart_id='a', item=self.ticket, seat=self.seat_a1,
price=23, expires=now() - timedelta(minutes=10)
)
assert set(self.event.free_seats) == {self.seat_a1, self.seat_a2}
assert self.seat_a1.is_available()
@classscope(attr='organizer')
def test_subevent_order_pending(self):
se1 = self.event.subevents.create(date_from=now(), name="SE 1")
self.seat_a1.subevent = se1
self.seat_a1.save()
o = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test', total=Decimal("30"),
locale='en', status=Order.STATUS_PAID, datetime=now(),
expires=now() + timedelta(days=10),
)
OrderPosition.objects.create(
order=o, item=self.ticket, variation=None, price=Decimal("12"),
seat=self.seat_a1, subevent=se1
)
assert set(se1.free_seats) == set()
assert not self.seat_a1.is_available()
@classscope(attr='organizer')
def test_subevent_order_canceled(self):
se1 = self.event.subevents.create(date_from=now(), name="SE 1")
self.seat_a1.subevent = se1
self.seat_a1.save()
o = Order.objects.create(
code='FOO', event=self.event, email='dummy@dummy.test', total=Decimal("30"),
locale='en', status=Order.STATUS_CANCELED, datetime=now(),
expires=now() + timedelta(days=10),
)
OrderPosition.objects.create(
order=o, item=self.ticket, variation=None, price=Decimal("12"),
seat=self.seat_a1, subevent=se1
)
assert set(se1.free_seats) == {self.seat_a1}
assert self.seat_a1.is_available()
@classscope(attr='organizer')
def test_subevent_cart_active(self):
se1 = self.event.subevents.create(date_from=now(), name="SE 1")
self.seat_a1.subevent = se1
self.seat_a1.save()
CartPosition.objects.create(
event=self.event, cart_id='a', item=self.ticket, seat=self.seat_a1,
price=23, expires=now() + timedelta(minutes=10), subevent=se1
)
assert set(se1.free_seats) == set()
assert not self.seat_a1.is_available()
@classscope(attr='organizer')
def test_subevent_cart_expired(self):
se1 = self.event.subevents.create(date_from=now(), name="SE 1")
self.seat_a1.subevent = se1
self.seat_a1.save()
CartPosition.objects.create(
event=self.event, cart_id='a', item=self.ticket, seat=self.seat_a1,
price=23, expires=now() - timedelta(minutes=10), subevent=se1
)
assert set(se1.free_seats) == {self.seat_a1}
assert self.seat_a1.is_available()
@pytest.mark.django_db
@pytest.mark.parametrize("qtype,answer,expected", [
(Question.TYPE_STRING, "a", "a"),
+217
View File
@@ -12,6 +12,7 @@ from django_scopes import scope
from pretix.base.decimal import round_decimal
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, Order, OrderPosition, Organizer,
SeatingPlan,
)
from pretix.base.models.items import SubEventItem
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
@@ -635,6 +636,19 @@ class OrderChangeManagerTests(TestCase):
self.quota.items.add(self.ticket2)
self.quota.items.add(self.shirt)
self.stalls = Item.objects.create(event=self.event, name='Stalls', tax_rule=self.tr7,
default_price=Decimal('23.00'), admission=True)
self.plan = SeatingPlan.objects.create(
name="Plan", organizer=self.o, layout="{}"
)
self.event.seat_category_mappings.create(
layout_category='Stalls', product=self.stalls
)
self.quota.items.add(self.stalls)
self.seat_a1 = self.event.seats.create(name="A1", product=self.stalls, seat_guid="A1")
self.seat_a2 = self.event.seats.create(name="A2", product=self.stalls, seat_guid="A2")
self.seat_a3 = self.event.seats.create(name="A3", product=self.stalls, seat_guid="A3")
def _enable_reverse_charge(self):
self.tr7.eu_reverse_charge = True
self.tr7.home_country = Country('DE')
@@ -1631,3 +1645,206 @@ class OrderChangeManagerTests(TestCase):
assert self.order.status == Order.STATUS_PENDING
assert o2.total == Decimal('0.00')
assert o2.status == Order.STATUS_PAID
@classscope(attr='o')
def test_change_seat_circular(self):
self.op1.seat = self.seat_a1
self.op1.save()
self.op2.seat = self.seat_a2
self.op2.save()
self.ocm.change_seat(self.op1, self.seat_a2)
self.ocm.change_seat(self.op2, self.seat_a1)
self.ocm.commit()
self.op1.refresh_from_db()
self.op2.refresh_from_db()
assert self.op1.seat == self.seat_a2
assert self.op2.seat == self.seat_a1
@classscope(attr='o')
def test_change_seat_duplicate(self):
self.op1.seat = self.seat_a1
self.op1.save()
self.op2.seat = self.seat_a2
self.op2.save()
self.ocm.change_seat(self.op1, self.seat_a2)
with self.assertRaises(OrderError):
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a1
@classscope(attr='o')
def test_change_seat_and_cancel(self):
self.op1.seat = self.seat_a1
self.op1.save()
self.op2.seat = self.seat_a2
self.op2.save()
self.ocm.change_seat(self.op1, self.seat_a2)
self.ocm.cancel(self.op2)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a2
@classscope(attr='o')
def test_change_seat(self):
self.op1.seat = self.seat_a1
self.op1.save()
self.ocm.change_seat(self.op1, self.seat_a2)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a2
@classscope(attr='o')
def test_change_add_seat(self):
# does this make sense or do we block it based on the item? this is currently not reachable through the UI
self.ocm.change_seat(self.op1, self.seat_a1)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a1
@classscope(attr='o')
def test_remove_seat(self):
# does this make sense or do we block it based on the item? this is currently not reachable through the UI
self.op1.seat = self.seat_a1
self.op1.save()
self.ocm.change_seat(self.op1, None)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat is None
@classscope(attr='o')
def test_add_with_seat(self):
self.ocm.add_position(self.stalls, None, price=Decimal('13.00'), seat=self.seat_a3)
self.ocm.commit()
op3 = self.order.positions.last()
assert op3.item == self.stalls
assert op3.seat == self.seat_a3
@classscope(attr='o')
def test_add_with_taken_seat(self):
self.op1.seat = self.seat_a1
self.op1.save()
self.ocm.add_position(self.stalls, None, price=Decimal('13.00'), seat=self.seat_a1)
with self.assertRaises(OrderError):
self.ocm.commit()
@classscope(attr='o')
def test_add_with_seat_required(self):
with self.assertRaises(OrderError):
self.ocm.add_position(self.stalls, None, price=Decimal('13.00'))
@classscope(attr='o')
def test_add_with_seat_forbidden(self):
with self.assertRaises(OrderError):
self.ocm.add_position(self.ticket, None, price=Decimal('13.00'), seat=self.seat_a1)
@classscope(attr='o')
def test_add_with_seat_blocked(self):
self.seat_a1.blocked = True
self.seat_a1.save()
self.ocm.add_position(self.stalls, None, price=Decimal('13.00'), seat=self.seat_a1)
with self.assertRaises(OrderError):
self.ocm.commit()
@classscope(attr='o')
def test_change_seat_to_blocked(self):
self.seat_a2.blocked = True
self.seat_a2.save()
self.op1.seat = self.seat_a1
self.op1.save()
self.ocm.change_seat(self.op1, self.seat_a2)
with self.assertRaises(OrderError):
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a1
@classscope(attr='o')
def test_change_seat_require_subevent_change(self):
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
self.op1.subevent = se1
self.op1.seat = self.seat_a1
self.op1.save()
self.quota.subevent = se2
self.quota.save()
self.seat_a1.subevent = se1
self.seat_a1.save()
self.seat_a2.subevent = se2
self.seat_a2.save()
self.ocm.change_seat(self.op1, self.seat_a2)
with self.assertRaises(OrderError):
self.ocm.commit()
@classscope(attr='o')
def test_change_subevent_require_seat_change(self):
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
self.op1.subevent = se1
self.op1.seat = self.seat_a1
self.op1.save()
self.quota.subevent = se2
self.quota.save()
self.seat_a1.subevent = se1
self.seat_a1.save()
self.seat_a2.subevent = se2
self.seat_a2.save()
self.ocm.change_subevent(self.op1, se2)
with self.assertRaises(OrderError):
self.ocm.commit()
@classscope(attr='o')
def test_change_subevent_and_seat(self):
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
self.op1.subevent = se1
self.op1.seat = self.seat_a1
self.op1.save()
self.quota.subevent = se2
self.quota.save()
self.seat_a1.subevent = se1
self.seat_a1.save()
self.seat_a2.subevent = se2
self.seat_a2.save()
self.ocm.change_subevent(self.op1, se2)
self.ocm.change_seat(self.op1, self.seat_a2)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a2
assert self.op1.subevent == se2
@classscope(attr='o')
def test_change_seat_inside_subevent(self):
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
self.op1.subevent = se1
self.op1.seat = self.seat_a1
self.op1.save()
self.seat_a1.subevent = se1
self.seat_a1.save()
self.seat_a2.subevent = se1
self.seat_a2.save()
self.ocm.change_seat(self.op1, self.seat_a2)
self.ocm.commit()
self.op1.refresh_from_db()
assert self.op1.seat == self.seat_a2
assert self.op1.subevent == se1
@classscope(attr='o')
def test_add_with_seat_and_subevent_mismatch(self):
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
self.quota.subevent = se2
self.quota.save()
self.seat_a1.subevent = se1
self.seat_a1.save()
self.ocm.change_subevent(self.op1, se2)
with self.assertRaises(OrderError):
self.ocm.add_position(self.ticket, None, price=Decimal('13.00'), subevent=se2, seat=self.seat_a1)
+142
View File
@@ -713,6 +713,103 @@ def test_order_extend_expired_quota_empty_ignore(client, env):
assert o.status == Order.STATUS_PENDING
@pytest.mark.django_db
def test_order_extend_expired_seat_free(client, env):
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
o.expires = now() - timedelta(days=5)
o.status = Order.STATUS_EXPIRED
o.save()
generate_cancellation(generate_invoice(o))
seat_a1 = env[0].seats.create(name="A1", product=env[3], seat_guid="A1")
p = o.positions.first()
p.seat = seat_a1
p.save()
q = Quota.objects.create(event=env[0], size=3)
q.items.add(env[3])
newdate = (now() + timedelta(days=20)).strftime("%Y-%m-%d %H:%M:%S")
client.login(email='dummy@dummy.dummy', password='dummy')
assert o.invoices.count() == 2
response = client.post('/control/event/dummy/dummy/orders/FOO/extend', {
'expires': newdate
}, follow=True)
assert b'alert-success' in response.content
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
assert o.expires.strftime("%Y-%m-%d %H:%M:%S") == newdate[:10] + " 23:59:59"
assert o.status == Order.STATUS_PENDING
assert o.invoices.count() == 3
@pytest.mark.django_db
def test_order_extend_expired_seat_blocked(client, env):
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
o.expires = now() - timedelta(days=5)
o.status = Order.STATUS_EXPIRED
olddate = o.expires
o.save()
seat_a1 = env[0].seats.create(name="A1", product=env[3], seat_guid="A1", blocked=True)
p = o.positions.first()
p.seat = seat_a1
p.save()
q = Quota.objects.create(event=env[0], size=100)
q.items.add(env[3])
newdate = (now() + timedelta(days=20)).strftime("%Y-%m-%d %H:%M:%S")
client.login(email='dummy@dummy.dummy', password='dummy')
response = client.post('/control/event/dummy/dummy/orders/FOO/extend', {
'expires': newdate
}, follow=True)
assert b'alert-danger' in response.content
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
assert o.expires.strftime("%Y-%m-%d %H:%M:%S") == olddate.strftime("%Y-%m-%d %H:%M:%S")
assert o.status == Order.STATUS_EXPIRED
@pytest.mark.django_db
def test_order_extend_expired_seat_taken(client, env):
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
o.expires = now() - timedelta(days=5)
o.status = Order.STATUS_EXPIRED
olddate = o.expires
o.save()
seat_a1 = env[0].seats.create(name="A1", product=env[3], seat_guid="A1")
p = o.positions.first()
p.seat = seat_a1
p.save()
o = Order.objects.create(
code='BAR', event=env[0], email='dummy@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() + timedelta(days=10),
total=14, locale='en'
)
OrderPosition.objects.create(
order=o,
item=env[3],
variation=None,
price=Decimal("14"),
attendee_name_parts={'full_name': "Peter", "_scheme": "full"},
seat=seat_a1
)
q = Quota.objects.create(event=env[0], size=100)
q.items.add(env[3])
newdate = (now() + timedelta(days=20)).strftime("%Y-%m-%d %H:%M:%S")
client.login(email='dummy@dummy.dummy', password='dummy')
response = client.post('/control/event/dummy/dummy/orders/FOO/extend', {
'expires': newdate
}, follow=True)
assert b'alert-danger' in response.content
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
assert o.expires.strftime("%Y-%m-%d %H:%M:%S") == olddate.strftime("%Y-%m-%d %H:%M:%S")
assert o.status == Order.STATUS_EXPIRED
@pytest.mark.django_db
def test_order_extend_expired_quota_partial(client, env):
with scopes_disabled():
@@ -831,12 +928,57 @@ def test_order_mark_paid_forced(client, env):
'amount': str(o.pending_sum),
'force': 'on'
}, follow=True)
print(response.content.decode())
assert 'alert-success' in response.content.decode()
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
assert o.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_order_mark_paid_expired_seat_taken(client, env):
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
o.expires = now() - timedelta(days=5)
o.status = Order.STATUS_EXPIRED
olddate = o.expires
o.save()
seat_a1 = env[0].seats.create(name="A1", product=env[3], seat_guid="A1")
p = o.positions.first()
p.seat = seat_a1
p.save()
o = Order.objects.create(
code='BAR', event=env[0], email='dummy@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() + timedelta(days=10),
total=14, locale='en'
)
OrderPosition.objects.create(
order=o,
item=env[3],
variation=None,
price=Decimal("14"),
attendee_name_parts={'full_name': "Peter", "_scheme": "full"},
seat=seat_a1
)
q = Quota.objects.create(event=env[0], size=100)
q.items.add(env[3])
client.login(email='dummy@dummy.dummy', password='dummy')
response = client.post('/control/event/dummy/dummy/orders/FOO/transition', {
'status': 'p',
'payment_date': now().date().isoformat(),
'amount': str(o.pending_sum),
'force': 'on'
}, follow=True)
assert b'alert-danger' in response.content
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
assert o.expires.strftime("%Y-%m-%d %H:%M:%S") == olddate.strftime("%Y-%m-%d %H:%M:%S")
assert o.status == Order.STATUS_EXPIRED
@pytest.mark.django_db
def test_order_go_lowercase(client, env):
client.login(email='dummy@dummy.dummy', password='dummy')
+157 -1
View File
@@ -12,7 +12,7 @@ from django_scopes import scopes_disabled
from pretix.base.decimal import round_decimal
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemCategory, ItemVariation,
Organizer, Question, QuestionAnswer, Quota, Voucher,
Organizer, Question, QuestionAnswer, Quota, SeatingPlan, Voucher,
)
from pretix.base.models.items import (
ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
@@ -2826,3 +2826,159 @@ class CartBundleTest(CartTestMixin, TestCase):
assert a.price == Decimal('1.40')
assert a.tax_rate == Decimal('0.00')
assert not a.includes_tax
class CartSeatingTest(CartTestMixin, TestCase):
@scopes_disabled()
def setUp(self):
super().setUp()
self.plan = SeatingPlan.objects.create(
name="Plan", organizer=self.orga, layout="{}"
)
self.event.seat_category_mappings.create(
layout_category='Stalls', product=self.ticket
)
self.seat_a1 = self.event.seats.create(name="A1", product=self.ticket, seat_guid="A1")
self.seat_a2 = self.event.seats.create(name="A2", product=self.ticket, seat_guid="A2")
self.seat_a3 = self.event.seats.create(name="A3", product=self.ticket, seat_guid="A3")
self.cm = CartManager(event=self.event, cart_id=self.session_key)
def test_add_with_seat_without_variation(self):
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'seat_%d' % self.ticket.id: self.seat_a1.seat_guid,
}, follow=True)
with scopes_disabled():
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1)
self.assertEqual(objs[0].item, self.ticket)
self.assertEqual(objs[0].seat, self.seat_a1)
self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, 23)
def test_add_with_seat_with_missing_variation(self):
with scopes_disabled():
v1 = self.ticket.variations.create(value='Regular', active=True)
self.quota_tickets.variations.add(v1)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'seat_%d' % self.ticket.id: self.seat_a1.seat_guid,
}, follow=True)
with scopes_disabled():
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 0)
def test_add_seat_blocked(self):
self.seat_a1.blocked = True
self.seat_a1.save()
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'seat_%d' % self.ticket.id: self.seat_a1.seat_guid,
}, follow=True)
with scopes_disabled():
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 0)
def test_add_seat_unseated_product(self):
with scopes_disabled():
self.event.seat_category_mappings.all().delete()
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'seat_%d' % self.ticket.id: self.seat_a1.seat_guid,
}, follow=True)
with scopes_disabled():
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 0)
def test_add_seat_wrong_product(self):
with scopes_disabled():
self.event.seat_category_mappings.all().update(product=self.shirt)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'seat_%d' % self.ticket.id: self.seat_a1.seat_guid,
}, follow=True)
with scopes_disabled():
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 0)
def test_add_seat_unknown(self):
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'seat_%d' % self.ticket.id: 'asdasdasd',
}, follow=True)
with scopes_disabled():
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 0)
def test_add_seat_required(self):
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
}, follow=True)
with scopes_disabled():
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 0)
def test_add_with_seat_with_variation(self):
with scopes_disabled():
v1 = self.ticket.variations.create(value='Regular', active=True)
self.quota_tickets.variations.add(v1)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'seat_%d_%d' % (self.ticket.id, v1.pk): self.seat_a1.seat_guid,
}, follow=True)
with scopes_disabled():
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1)
self.assertEqual(objs[0].item, self.ticket)
self.assertEqual(objs[0].seat, self.seat_a1)
self.assertEqual(objs[0].variation, v1)
self.assertEqual(objs[0].price, 23)
def test_add_with_seat_to_cart_twice(self):
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket, seat=self.seat_a1,
price=23, expires=now() + timedelta(minutes=10)
)
with scopes_disabled():
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'seat_%d' % self.ticket.id: self.seat_a1.seat_guid,
}, follow=True)
with scopes_disabled():
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1)
self.assertEqual(objs[0].seat, self.seat_a1)
def test_add_used_seat_to_cart(self):
CartPosition.objects.create(
event=self.event, cart_id='aaa', item=self.ticket, seat=self.seat_a1,
price=23, expires=now() + timedelta(minutes=10)
)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'seat_%d' % self.ticket.id: self.seat_a1.seat_guid,
}, follow=True)
with scopes_disabled():
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 0)
@scopes_disabled()
def test_extend_seat_still_available(self):
with scopes_disabled():
cp = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket, seat=self.seat_a1,
price=21.5, expires=now() - timedelta(minutes=10)
)
self.cm.commit()
cp.refresh_from_db()
assert cp.seat == self.seat_a1
@scopes_disabled()
def test_extend_seat_taken(self):
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket, seat=self.seat_a1,
price=21.5, expires=now() - timedelta(minutes=10)
)
CartPosition.objects.create(
event=self.event, cart_id='secondcart', item=self.ticket, seat=self.seat_a1,
price=21.5, expires=now() + timedelta(minutes=10)
)
with self.assertRaises(CartError):
self.cm.commit()
assert not CartPosition.objects.filter(cart_id=self.session_key).exists()
+84 -1
View File
@@ -17,7 +17,7 @@ from pretix.base.decimal import round_decimal
from pretix.base.models import (
CartPosition, Event, Invoice, InvoiceAddress, Item, ItemCategory, Order,
OrderPayment, OrderPosition, Organizer, Question, QuestionAnswer, Quota,
Voucher,
SeatingPlan, Voucher,
)
from pretix.base.models.items import (
ItemAddOn, ItemBundle, ItemVariation, SubEventItem,
@@ -2590,3 +2590,86 @@ class CheckoutBundleTest(BaseCheckoutTestCase, TestCase):
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1)
class CheckoutSeatingTest(BaseCheckoutTestCase, TestCase):
@scopes_disabled()
def setUp(self):
super().setUp()
self.plan = SeatingPlan.objects.create(
name="Plan", organizer=self.orga, layout="{}"
)
self.event.seat_category_mappings.create(
layout_category='Stalls', product=self.ticket
)
self.seat_a1 = self.event.seats.create(name="A1", product=self.ticket, seat_guid="A1")
self.seat_a2 = self.event.seats.create(name="A2", product=self.ticket, seat_guid="A2")
self.seat_a3 = self.event.seats.create(name="A3", product=self.ticket, seat_guid="A3")
self.cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=21.5, expires=now() + timedelta(minutes=10), seat=self.seat_a1
)
@scopes_disabled()
def test_passes(self):
oid = _perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {}, 'web')
o = Order.objects.get(pk=oid)
op = o.positions.first()
assert op.item == self.ticket
assert op.seat == self.seat_a1
@scopes_disabled()
def test_seat_required(self):
self.cp1.seat = None
self.cp1.save()
with self.assertRaises(OrderError):
_perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {}, 'web')
assert not CartPosition.objects.filter(pk=self.cp1.pk).exists()
@scopes_disabled()
def test_seat_not_allowed(self):
self.cp1.item = self.workshop1
self.cp1.save()
with self.assertRaises(OrderError):
_perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {}, 'web')
assert not CartPosition.objects.filter(pk=self.cp1.pk).exists()
@scopes_disabled()
def test_seat_invalid_product(self):
self.cp1.item = self.workshop1
self.cp1.save()
self.event.seat_category_mappings.create(
layout_category='Foo', product=self.workshop1
)
with self.assertRaises(OrderError):
_perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {}, 'web')
assert not CartPosition.objects.filter(pk=self.cp1.pk).exists()
@scopes_disabled()
def test_seat_multiple_times_same_seat(self):
cp2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=21.5, expires=now() + timedelta(minutes=10), seat=self.seat_a1
)
with self.assertRaises(OrderError):
_perform_order(self.event, 'manual', [self.cp1.pk, cp2.pk], 'admin@example.org', 'en', None, {}, 'web')
assert not CartPosition.objects.filter(pk=self.cp1.pk).exists()
assert not CartPosition.objects.filter(pk=cp2.pk).exists()
@scopes_disabled()
def test_seat_blocked(self):
self.seat_a1.blocked = True
self.seat_a1.save()
with self.assertRaises(OrderError):
_perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {}, 'web')
assert not CartPosition.objects.filter(pk=self.cp1.pk).exists()
@scopes_disabled()
def test_seat_taken(self):
CartPosition.objects.create(
event=self.event, cart_id=self.session_key + '_other', item=self.ticket,
price=21.5, expires=now() + timedelta(minutes=10), seat=self.seat_a1
)
with self.assertRaises(OrderError):
_perform_order(self.event, 'manual', [self.cp1.pk], 'admin@example.org', 'en', None, {}, 'web')
assert not CartPosition.objects.filter(pk=self.cp1.pk).exists()
+4
View File
@@ -133,6 +133,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
"vouchers_exist": False,
"waiting_list_enabled": False,
"error": None,
"has_seating_plan": False,
"items_by_category": [
{
"items": [
@@ -214,6 +215,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
"currency": "EUR",
"show_variations_expanded": False,
"display_net_prices": False,
"has_seating_plan": False,
"vouchers_exist": True,
"waiting_list_enabled": False,
"error": None,
@@ -262,6 +264,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
"show_variations_expanded": False,
"display_net_prices": False,
"vouchers_exist": True,
"has_seating_plan": False,
"waiting_list_enabled": False,
"error": None,
"items_by_category": [
@@ -324,6 +327,7 @@ class WidgetCartTest(CartTestMixin, TestCase):
"currency": "EUR",
"show_variations_expanded": False,
"display_net_prices": False,
"has_seating_plan": False,
"vouchers_exist": True,
"waiting_list_enabled": False,
"error": "This voucher is expired.",