mirror of
https://github.com/pretix/pretix.git
synced 2025-12-11 01:22:28 +00:00
Compare commits
8 Commits
validate-u
...
headless
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdece7a782 | ||
|
|
ab4ecd0b6b | ||
|
|
8f13c03245 | ||
|
|
3b664f8b76 | ||
|
|
92eb5e3ece | ||
|
|
ad38a7a407 | ||
|
|
51bdb274bd | ||
|
|
8cba60dd93 |
31
doc/_templates/index.html
vendored
31
doc/_templates/index.html
vendored
@@ -54,6 +54,23 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sectionbox">
|
||||||
|
<div class="icon">
|
||||||
|
<a href="storefrontapi/index.html">
|
||||||
|
<span class="fa fa-shopping-cart fa-fw"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="text">
|
||||||
|
<a href="storefrontapi/index.html">
|
||||||
|
<strong>Storefront API</strong>
|
||||||
|
</a>
|
||||||
|
<p>
|
||||||
|
Documentation and reference of the headless shopping API exposed by pretix for building a custom
|
||||||
|
storefront.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="clearfix"></div>
|
||||||
<div class="sectionbox">
|
<div class="sectionbox">
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<a href="development/index.html">
|
<a href="development/index.html">
|
||||||
@@ -68,7 +85,6 @@
|
|||||||
pretix.</p>
|
pretix.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="clearfix"></div>
|
|
||||||
<div class="sectionbox">
|
<div class="sectionbox">
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<a href="plugins/index.html">
|
<a href="plugins/index.html">
|
||||||
@@ -82,19 +98,6 @@
|
|||||||
<p>Documentation and details on plugins that ship with pretix or are officially supported.</p>
|
<p>Documentation and details on plugins that ship with pretix or are officially supported.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sectionbox">
|
|
||||||
<div class="icon">
|
|
||||||
<a href="contents.html">
|
|
||||||
<span class="fa fa-list fa-fw"></span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="text">
|
|
||||||
<a href="contents.html">
|
|
||||||
<strong>Table of contents</strong>
|
|
||||||
</a>
|
|
||||||
<p>Detailled overview of everything contained in this documentation.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="clearfix"></div>
|
<div class="clearfix"></div>
|
||||||
|
|
||||||
<h2>Useful links</h2>
|
<h2>Useful links</h2>
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ Field specific input errors include the name of the offending fields as keys in
|
|||||||
|
|
||||||
If you see errors of type ``429 Too Many Requests``, you should read our documentation on :ref:`rest-ratelimit`.
|
If you see errors of type ``429 Too Many Requests``, you should read our documentation on :ref:`rest-ratelimit`.
|
||||||
|
|
||||||
|
.. _`rest-types`:
|
||||||
|
|
||||||
Data types
|
Data types
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Table of contents
|
|||||||
user/index
|
user/index
|
||||||
admin/index
|
admin/index
|
||||||
api/index
|
api/index
|
||||||
|
storefrontapi/index
|
||||||
development/index
|
development/index
|
||||||
plugins/index
|
plugins/index
|
||||||
license/faq
|
license/faq
|
||||||
|
|||||||
114
doc/storefrontapi/fundamentals.rst
Normal file
114
doc/storefrontapi/fundamentals.rst
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
Basic concepts
|
||||||
|
==============
|
||||||
|
|
||||||
|
This page describes basic concepts and definition that you need to know to interact
|
||||||
|
with our Storefront API, such as authentication, pagination and similar definitions.
|
||||||
|
|
||||||
|
.. _`storefront-auth`:
|
||||||
|
|
||||||
|
Authentication
|
||||||
|
--------------
|
||||||
|
|
||||||
|
The storefront API requires authentication with an API key. You receive two kinds of API keys for the storefront API:
|
||||||
|
Publishable keys and private keys. Publishable keys should be used when your website directly connects to the API.
|
||||||
|
Private keys should be used only on server-to-server connections.
|
||||||
|
|
||||||
|
Localization
|
||||||
|
------------
|
||||||
|
|
||||||
|
The storefront API will return localized and translated strings in many cases if you set an ``Accept-Language`` header.
|
||||||
|
The selected locale will only be respected if it is active for the organizer or event in question.
|
||||||
|
|
||||||
|
.. _`storefront-compat`:
|
||||||
|
|
||||||
|
Compatibility
|
||||||
|
-------------
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
The storefront API is currently considered experimental and may change without notice.
|
||||||
|
Once we declare the API stable, the following compatibility policy will apply.
|
||||||
|
|
||||||
|
We try to avoid any breaking changes to our API to avoid hassle on your end. If possible, we'll
|
||||||
|
build new features in a way that keeps all pre-existing API usage unchanged. In some cases,
|
||||||
|
this might not be possible or only possible with restrictions. In these case, any
|
||||||
|
backwards-incompatible changes will be prominently noted in the "Changes to the REST API"
|
||||||
|
section of our release notes. If possible, we will announce them multiple releases in advance.
|
||||||
|
|
||||||
|
We treat the following types of changes as *backwards-compatible* so we ask you to make sure
|
||||||
|
that your clients can deal with them properly:
|
||||||
|
|
||||||
|
* Support of new API endpoints
|
||||||
|
* Support of new HTTP methods for a given API endpoint
|
||||||
|
* Support of new query parameters for a given API endpoint
|
||||||
|
* New fields contained in API responses
|
||||||
|
* New possible values of enumeration-like fields
|
||||||
|
* Response body structure or message texts on failed requests (``4xx``, ``5xx`` response codes)
|
||||||
|
|
||||||
|
We treat the following types of changes as *backwards-incompatible*:
|
||||||
|
|
||||||
|
* Type changes of fields in API responses
|
||||||
|
* New required input fields for an API endpoint
|
||||||
|
* New required type for input fields of an API endpoint
|
||||||
|
* Removal of endpoints, API methods or fields
|
||||||
|
|
||||||
|
Pagination
|
||||||
|
----------
|
||||||
|
|
||||||
|
Most lists of objects returned by pretix' API will be paginated. The response will take
|
||||||
|
the form of:
|
||||||
|
|
||||||
|
.. sourcecode:: javascript
|
||||||
|
|
||||||
|
{
|
||||||
|
"count": 117,
|
||||||
|
"next": "https://pretix.eu/api/v1/organizers/?page=2",
|
||||||
|
"previous": null,
|
||||||
|
"results": […],
|
||||||
|
}
|
||||||
|
|
||||||
|
As you can see, the response contains the total number of results in the field ``count``.
|
||||||
|
The fields ``next`` and ``previous`` contain links to the next and previous page of results,
|
||||||
|
respectively, or ``null`` if there is no such page. You can use those URLs to retrieve the
|
||||||
|
respective page.
|
||||||
|
|
||||||
|
The field ``results`` contains a list of objects representing the first results. For most
|
||||||
|
objects, every page contains 50 results. You can specify a lower pagination size using the
|
||||||
|
``page_size`` query parameter, but no more than 50.
|
||||||
|
|
||||||
|
Errors
|
||||||
|
------
|
||||||
|
|
||||||
|
Error responses (of type 400-499) are returned in one of the following forms, depending on
|
||||||
|
the type of error. General errors look like:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 405 Method Not Allowed
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 42
|
||||||
|
|
||||||
|
{"detail": "Method 'DELETE' not allowed."}
|
||||||
|
|
||||||
|
Field specific input errors include the name of the offending fields as keys in the response:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 400 Bad Request
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 94
|
||||||
|
|
||||||
|
{"amount": ["A valid integer is required."], "description": ["This field may not be blank."]}
|
||||||
|
|
||||||
|
If you see errors of type ``429 Too Many Requests``, you should read our documentation on :ref:`rest-ratelimit`.
|
||||||
|
|
||||||
|
Time Machine
|
||||||
|
------------
|
||||||
|
|
||||||
|
Just like our shop frontend, the API allows simulating responses at a different point in time using the
|
||||||
|
``X-Storefront-Time-Machine-Date`` header. This mechanism only works when the shop is in test mode.
|
||||||
|
|
||||||
|
Data types
|
||||||
|
----------
|
||||||
|
|
||||||
|
See :ref:`data types <rest-types>` of the REST API.
|
||||||
17
doc/storefrontapi/index.rst
Normal file
17
doc/storefrontapi/index.rst
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
.. _`storefront-api`:
|
||||||
|
|
||||||
|
Storefront API
|
||||||
|
==============
|
||||||
|
|
||||||
|
This part of the documentation contains information about the headless e-commerce
|
||||||
|
API exposed by pretix that can be used to build a custom checkout experience.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
The storefront API is currently considered experimental and may change without notice.
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
|
||||||
|
fundamentals
|
||||||
|
reference/index
|
||||||
7
doc/storefrontapi/reference/index.rst
Normal file
7
doc/storefrontapi/reference/index.rst
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
API Reference
|
||||||
|
=============
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 2
|
||||||
|
|
||||||
|
foo
|
||||||
1
setup.py
1
setup.py
@@ -24,7 +24,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
import setuptools
|
import setuptools
|
||||||
|
|
||||||
|
|
||||||
sys.path.append(str(Path.cwd() / 'src'))
|
sys.path.append(str(Path.cwd() / 'src'))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ INSTALLED_APPS = [
|
|||||||
'pretix.presale',
|
'pretix.presale',
|
||||||
'pretix.multidomain',
|
'pretix.multidomain',
|
||||||
'pretix.api',
|
'pretix.api',
|
||||||
|
'pretix.storefrontapi',
|
||||||
'pretix.helpers',
|
'pretix.helpers',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'djangoformsetjs',
|
'djangoformsetjs',
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2025-01-01 20:25
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("pretixbase", "0274_tax_codes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CheckoutSession",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True, primary_key=True, serialize=False
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("cart_id", models.CharField(max_length=255, unique=True)),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("testmode", models.BooleanField(default=False)),
|
||||||
|
("session_data", models.JSONField(default=dict)),
|
||||||
|
(
|
||||||
|
"customer",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="checkout_sessions",
|
||||||
|
to="pretixbase.customer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="pretixbase.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"sales_channel",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="pretixbase.saleschannel",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="invoiceaddress",
|
||||||
|
name="checkout_session",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="invoice_address",
|
||||||
|
to="pretixbase.checkoutsession",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -55,7 +55,7 @@ from django.conf import settings
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
|
Case, Exists, F, Max, OuterRef, Prefetch, Q, Subquery, Sum, Value, When,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce, Greatest
|
from django.db.models.functions import Coalesce, Greatest
|
||||||
from django.db.models.signals import post_delete
|
from django.db.models.signals import post_delete
|
||||||
@@ -3063,6 +3063,64 @@ class Transaction(models.Model):
|
|||||||
return self.tax_value * self.count
|
return self.tax_value * self.count
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutSession(models.Model):
|
||||||
|
"""
|
||||||
|
A checkout session optionally bundles cart positions with additional information. This is historically
|
||||||
|
not required in pretix and currently only used in the Storefront API.
|
||||||
|
"""
|
||||||
|
event = models.ForeignKey(
|
||||||
|
Event,
|
||||||
|
verbose_name=_("Event"),
|
||||||
|
related_name="checkout_sessions",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
cart_id = models.CharField(
|
||||||
|
max_length=255, unique=True,
|
||||||
|
verbose_name=_("Cart ID (e.g. session key)"),
|
||||||
|
)
|
||||||
|
created = models.DateTimeField(
|
||||||
|
verbose_name=_("Date"),
|
||||||
|
auto_now_add=True,
|
||||||
|
)
|
||||||
|
customer = models.ForeignKey(
|
||||||
|
Customer,
|
||||||
|
related_name='checkout_sessions',
|
||||||
|
null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
)
|
||||||
|
sales_channel = models.ForeignKey(
|
||||||
|
"SalesChannel",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
testmode = models.BooleanField(default=False)
|
||||||
|
session_data = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
def get_cart_positions(self, prefetch_questions=False):
|
||||||
|
qs = CartPosition.objects.filter(event=self.event, cart_id=self.cart_id).select_related(
|
||||||
|
"item", "variation", "subevent",
|
||||||
|
)
|
||||||
|
if prefetch_questions:
|
||||||
|
qqs = self.event.questions.filter(ask_during_checkin=False, hidden=False)
|
||||||
|
qs = qs.prefetch_related(
|
||||||
|
Prefetch("answers",
|
||||||
|
QuestionAnswer.objects.prefetch_related("options"),
|
||||||
|
to_attr="answerlist"),
|
||||||
|
Prefetch("item__questions",
|
||||||
|
qqs.prefetch_related(
|
||||||
|
Prefetch("options", QuestionOption.objects.prefetch_related(Prefetch(
|
||||||
|
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
|
||||||
|
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
|
||||||
|
# a prefetch lookup on this query...
|
||||||
|
"question",
|
||||||
|
Question.objects.none(),
|
||||||
|
to_attr="dummy"
|
||||||
|
)))
|
||||||
|
).select_related("dependency_question"),
|
||||||
|
to_attr="questions_to_ask")
|
||||||
|
)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
class CartPosition(AbstractPosition):
|
class CartPosition(AbstractPosition):
|
||||||
"""
|
"""
|
||||||
A cart position is similar to an order line, except that it is not
|
A cart position is similar to an order line, except that it is not
|
||||||
@@ -3245,6 +3303,13 @@ class CartPosition(AbstractPosition):
|
|||||||
|
|
||||||
class InvoiceAddress(models.Model):
|
class InvoiceAddress(models.Model):
|
||||||
last_modified = models.DateTimeField(auto_now=True)
|
last_modified = models.DateTimeField(auto_now=True)
|
||||||
|
checkout_session = models.OneToOneField(
|
||||||
|
CheckoutSession,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='invoice_address',
|
||||||
|
on_delete=models.CASCADE
|
||||||
|
)
|
||||||
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
|
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
|
||||||
customer = models.ForeignKey(
|
customer = models.ForeignKey(
|
||||||
Customer,
|
Customer,
|
||||||
|
|||||||
@@ -722,6 +722,10 @@ class BasePaymentProvider:
|
|||||||
"""
|
"""
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def storefrontapi_prepare(self, session_data, total, info):
|
||||||
|
# TODO: docstring
|
||||||
|
return True
|
||||||
|
|
||||||
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]:
|
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]:
|
||||||
"""
|
"""
|
||||||
Will be called after the user selects this provider as their payment method.
|
Will be called after the user selects this provider as their payment method.
|
||||||
@@ -1447,6 +1451,28 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def storefrontapi_prepare(self, session_data, total, info):
|
||||||
|
# todo: validate gift card not paid with gift card
|
||||||
|
try:
|
||||||
|
gc = self.event.organizer.accepted_gift_cards.get(
|
||||||
|
secret=info.get("giftcard").strip()
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._add_giftcard_to_cart(session_data, gc)
|
||||||
|
return True
|
||||||
|
except ValidationError as e:
|
||||||
|
raise PaymentException(str(e.message))
|
||||||
|
except GiftCard.DoesNotExist:
|
||||||
|
if self.event.vouchers.filter(code__iexact=info.get("giftcard")).exists():
|
||||||
|
raise PaymentException(
|
||||||
|
_("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
|
||||||
|
"the product selection.")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise PaymentException(_("This gift card is not known."))
|
||||||
|
except GiftCard.MultipleObjectsReturned:
|
||||||
|
raise PaymentException(_("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event."))
|
||||||
|
|
||||||
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]:
|
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]:
|
||||||
for p in get_cart(request):
|
for p in get_cart(request):
|
||||||
if p.item.issue_giftcard:
|
if p.item.issue_giftcard:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
from django.db.models import Exists, OuterRef
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
@@ -32,6 +33,7 @@ from pretix.base.models.customers import CustomerSSOGrant
|
|||||||
|
|
||||||
from ..models import CachedFile, CartPosition, InvoiceAddress
|
from ..models import CachedFile, CartPosition, InvoiceAddress
|
||||||
from ..models.auth import UserKnownLoginSource
|
from ..models.auth import UserKnownLoginSource
|
||||||
|
from ..models.orders import CheckoutSession
|
||||||
from ..signals import periodic_task
|
from ..signals import periodic_task
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +44,10 @@ def clean_cart_positions(sender, **kwargs):
|
|||||||
cp.delete()
|
cp.delete()
|
||||||
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
|
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
|
||||||
cp.delete()
|
cp.delete()
|
||||||
|
for cs in CheckoutSession.objects.filter(created__lt=now() - timedelta(days=14)).exclude(
|
||||||
|
Exists(CartPosition.objects.filter(cart_id=OuterRef("cart_id")))
|
||||||
|
):
|
||||||
|
cs.delete()
|
||||||
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
|
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
|
||||||
ia.delete()
|
ia.delete()
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from typing import List
|
|||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
from pretix.base.models import CartPosition, ItemCategory, SalesChannel
|
from pretix.base.models import CartPosition, ItemCategory, SalesChannel
|
||||||
from pretix.presale.views.event import get_grouped_items
|
from pretix.base.storelogic.products import get_items_for_product_list
|
||||||
|
|
||||||
|
|
||||||
class DummyCategory:
|
class DummyCategory:
|
||||||
@@ -161,7 +161,7 @@ class CrossSellingService:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def _prepare_items(self, subevent, items_qs, discount_info):
|
def _prepare_items(self, subevent, items_qs, discount_info):
|
||||||
items, _btn = get_grouped_items(
|
items, _btn = get_items_for_product_list(
|
||||||
self.event,
|
self.event,
|
||||||
subevent=subevent,
|
subevent=subevent,
|
||||||
voucher=None,
|
voucher=None,
|
||||||
|
|||||||
2
src/pretix/base/storelogic/__init__.py
Normal file
2
src/pretix/base/storelogic/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
class IncompleteError(Exception):
|
||||||
|
pass
|
||||||
118
src/pretix/base/storelogic/addons.py
Normal file
118
src/pretix/base/storelogic/addons.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import copy
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from pretix.base.models.tax import TaxedPrice
|
||||||
|
from pretix.base.storelogic.products import get_items_for_product_list
|
||||||
|
|
||||||
|
|
||||||
|
def addons_is_completed(cart_positions):
|
||||||
|
for cartpos in cart_positions.filter(addon_to__isnull=True).prefetch_related(
|
||||||
|
'item__addons', 'item__addons__addon_category', 'addons', 'addons__item'
|
||||||
|
):
|
||||||
|
a = cartpos.addons.all()
|
||||||
|
for iao in cartpos.item.addons.all():
|
||||||
|
found = len([1 for p in a if p.item.category_id == iao.addon_category_id and not p.is_bundled])
|
||||||
|
if found < iao.min_count or found > iao.max_count:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def addons_is_applicable(cart_positions):
|
||||||
|
return cart_positions.filter(item__addons__isnull=False).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def get_addon_groups(event, sales_channel, customer, cart_positions):
|
||||||
|
quota_cache = {}
|
||||||
|
item_cache = {}
|
||||||
|
groups = []
|
||||||
|
for cartpos in sorted(cart_positions.filter(addon_to__isnull=True).prefetch_related(
|
||||||
|
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation',
|
||||||
|
), key=lambda c: c.sort_key):
|
||||||
|
groupentry = {
|
||||||
|
'pos': cartpos,
|
||||||
|
'item': cartpos.item,
|
||||||
|
'variation': cartpos.variation,
|
||||||
|
'categories': []
|
||||||
|
}
|
||||||
|
|
||||||
|
current_addon_products = defaultdict(list)
|
||||||
|
for a in cartpos.addons.all():
|
||||||
|
if not a.is_bundled:
|
||||||
|
current_addon_products[a.item_id, a.variation_id].append(a)
|
||||||
|
|
||||||
|
for iao in cartpos.item.addons.all():
|
||||||
|
ckey = '{}-{}'.format(cartpos.subevent.pk if cartpos.subevent else 0, iao.addon_category.pk)
|
||||||
|
|
||||||
|
if ckey not in item_cache:
|
||||||
|
# Get all items to possibly show
|
||||||
|
items, _btn = get_items_for_product_list(
|
||||||
|
event,
|
||||||
|
subevent=cartpos.subevent,
|
||||||
|
voucher=None,
|
||||||
|
channel=sales_channel,
|
||||||
|
base_qs=iao.addon_category.items,
|
||||||
|
allow_addons=True,
|
||||||
|
quota_cache=quota_cache,
|
||||||
|
memberships=(
|
||||||
|
customer.usable_memberships(
|
||||||
|
for_event=cartpos.subevent or event,
|
||||||
|
testmode=event.testmode
|
||||||
|
)
|
||||||
|
if customer else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
item_cache[ckey] = items
|
||||||
|
else:
|
||||||
|
# We can use the cache to prevent a database fetch, but we need separate Python objects
|
||||||
|
# or our things below like setting `i.initial` will do the wrong thing.
|
||||||
|
items = [copy.copy(i) for i in item_cache[ckey]]
|
||||||
|
for i in items:
|
||||||
|
i.available_variations = [copy.copy(v) for v in i.available_variations]
|
||||||
|
|
||||||
|
for i in items:
|
||||||
|
i.allow_waitinglist = False
|
||||||
|
|
||||||
|
if i.has_variations:
|
||||||
|
for v in i.available_variations:
|
||||||
|
v.initial = len(current_addon_products[i.pk, v.pk])
|
||||||
|
if v.initial and i.free_price:
|
||||||
|
a = current_addon_products[i.pk, v.pk][0]
|
||||||
|
v.initial_price = TaxedPrice(
|
||||||
|
net=a.price - a.tax_value,
|
||||||
|
gross=a.price,
|
||||||
|
tax=a.tax_value,
|
||||||
|
name=a.item.tax_rule.name if a.item.tax_rule else "",
|
||||||
|
rate=a.tax_rate,
|
||||||
|
code=a.item.tax_rule.code if a.item.tax_rule else None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
v.initial_price = v.suggested_price
|
||||||
|
i.expand = any(v.initial for v in i.available_variations)
|
||||||
|
else:
|
||||||
|
i.initial = len(current_addon_products[i.pk, None])
|
||||||
|
if i.initial and i.free_price:
|
||||||
|
a = current_addon_products[i.pk, None][0]
|
||||||
|
i.initial_price = TaxedPrice(
|
||||||
|
net=a.price - a.tax_value,
|
||||||
|
gross=a.price,
|
||||||
|
tax=a.tax_value,
|
||||||
|
name=a.item.tax_rule.name if a.item.tax_rule else "",
|
||||||
|
rate=a.tax_rate,
|
||||||
|
code=a.item.tax_rule.code if a.item.tax_rule else None,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
i.initial_price = i.suggested_price
|
||||||
|
|
||||||
|
if items:
|
||||||
|
groupentry['categories'].append({
|
||||||
|
'category': iao.addon_category,
|
||||||
|
'price_included': iao.price_included or (cartpos.voucher_id and cartpos.voucher.all_addons_included),
|
||||||
|
'multi_allowed': iao.multi_allowed,
|
||||||
|
'min_count': iao.min_count,
|
||||||
|
'max_count': iao.max_count,
|
||||||
|
'iao': iao,
|
||||||
|
'items': items
|
||||||
|
})
|
||||||
|
if groupentry['categories']:
|
||||||
|
groups.append(groupentry)
|
||||||
|
return groups
|
||||||
271
src/pretix/base/storelogic/fields.py
Normal file
271
src/pretix/base/storelogic/fields.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.validators import EmailValidator
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from pretix.base.models import CartPosition, Question
|
||||||
|
from pretix.base.services.checkin import _save_answers
|
||||||
|
from pretix.base.storelogic import IncompleteError
|
||||||
|
from pretix.presale.signals import question_form_fields
|
||||||
|
|
||||||
|
|
||||||
|
class Field:
|
||||||
|
@property
|
||||||
|
def identifier(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def help_text(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def required(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def validation_hints(self):
|
||||||
|
raise {}
|
||||||
|
|
||||||
|
def validate_input(self, value):
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class PositionField(Field):
|
||||||
|
def save_input(self, position, value):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def current_value(self, position):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class SessionField(Field):
|
||||||
|
def save_input(self, session_data, value):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def current_value(self, session_data):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionField(PositionField):
|
||||||
|
def __init__(self, question: Question):
|
||||||
|
self.question = question
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self):
|
||||||
|
return self.question.question
|
||||||
|
|
||||||
|
@property
|
||||||
|
def help_text(self):
|
||||||
|
return self.question.help_text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self):
|
||||||
|
return self.question.type
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identifier(self):
|
||||||
|
return f"question_{self.question.identifier}"
|
||||||
|
|
||||||
|
def validate_input(self, value):
|
||||||
|
return self.question.clean_answer(value)
|
||||||
|
|
||||||
|
def required(self, value):
|
||||||
|
return self.question.required
|
||||||
|
|
||||||
|
def validation_hints(self):
|
||||||
|
d = {
|
||||||
|
"valid_number_min": self.question.valid_number_min,
|
||||||
|
"valid_number_max": self.question.valid_number_max,
|
||||||
|
"valid_date_min": self.question.valid_date_min,
|
||||||
|
"valid_date_max": self.question.valid_date_max,
|
||||||
|
"valid_datetime_min": self.question.valid_datetime_min,
|
||||||
|
"valid_datetime_max": self.question.valid_datetime_max,
|
||||||
|
"valid_string_length_max": self.question.valid_string_length_max,
|
||||||
|
"dependency_on": f"question_{self.question.dependency_question.identifier}" if self.question.dependency_question_id else None,
|
||||||
|
"dependency_values": self.question.dependency_values,
|
||||||
|
}
|
||||||
|
if self.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||||
|
d["choices"] = [
|
||||||
|
{
|
||||||
|
"identifier": opt.identifier,
|
||||||
|
"label": str(opt.answer)
|
||||||
|
}
|
||||||
|
for opt in self.question.options.all()
|
||||||
|
]
|
||||||
|
return d
|
||||||
|
|
||||||
|
def save_input(self, position, value):
|
||||||
|
answers = [a for a in position.answerlist if a.question_id == self.question.id]
|
||||||
|
if answers:
|
||||||
|
answers = {self.question: answers[0]}
|
||||||
|
else:
|
||||||
|
answers = {}
|
||||||
|
_save_answers(position, answers, {self.question: value})
|
||||||
|
|
||||||
|
def current_value(self, position):
|
||||||
|
answers = [a for a in position.answerlist if a.question_id == self.question.id]
|
||||||
|
if answers:
|
||||||
|
if self.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
|
||||||
|
return ",".join([a.idenitifer for a in answers[0].options.all()])
|
||||||
|
else:
|
||||||
|
return answers[0].answer
|
||||||
|
|
||||||
|
|
||||||
|
class SyntheticSessionField(SessionField):
|
||||||
|
def __init__(self, label, help_text, type, identifier, required, save_func, get_func, validate_func):
|
||||||
|
self._label = label
|
||||||
|
self._help_text = help_text
|
||||||
|
self._type = type
|
||||||
|
self._identifier = identifier
|
||||||
|
self._required = required
|
||||||
|
self._save_func = save_func
|
||||||
|
self._get_func = get_func
|
||||||
|
self._validate_func = validate_func
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self):
|
||||||
|
return self._label
|
||||||
|
|
||||||
|
@property
|
||||||
|
def help_text(self):
|
||||||
|
return self._help_text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self):
|
||||||
|
return self._type
|
||||||
|
|
||||||
|
@property
|
||||||
|
def required(self):
|
||||||
|
return self._required
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identifier(self):
|
||||||
|
return self._identifier
|
||||||
|
|
||||||
|
def validation_hints(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_input(self, session_data, value):
|
||||||
|
self._save_func(session_data, value)
|
||||||
|
|
||||||
|
def current_value(self, session_data):
|
||||||
|
return self._get_func(session_data)
|
||||||
|
|
||||||
|
def validate_input(self, value):
|
||||||
|
return self._validate_func(value)
|
||||||
|
|
||||||
|
|
||||||
|
def get_checkout_fields(event):
|
||||||
|
fields = []
|
||||||
|
# TODO: support contact_form_fields
|
||||||
|
# TODO: support contact_form_fields_override
|
||||||
|
|
||||||
|
# email
|
||||||
|
fields.append(SyntheticSessionField(
|
||||||
|
label=_("Email"),
|
||||||
|
help_text=None,
|
||||||
|
type=Question.TYPE_STRING, # TODO: Add a type?
|
||||||
|
identifier="email",
|
||||||
|
required=True,
|
||||||
|
get_func=lambda session_data: session_data.get("email"),
|
||||||
|
save_func=lambda session_data, value: session_data.update({"email": value}),
|
||||||
|
validate_func=lambda value: EmailValidator()(value) or value,
|
||||||
|
))
|
||||||
|
|
||||||
|
# TODO: phone
|
||||||
|
# TODO: invoice address
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def get_position_fields(event, pos: CartPosition):
|
||||||
|
# TODO: support override sets
|
||||||
|
fields = []
|
||||||
|
|
||||||
|
for q in pos.item.questions_to_ask:
|
||||||
|
fields.append(QuestionField(q))
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_fields_are_completed(event, positions, cart_session, invoice_address, all_optional, cart_is_free):
|
||||||
|
try:
|
||||||
|
emailval = EmailValidator()
|
||||||
|
if not cart_session.get('email') and not all_optional:
|
||||||
|
raise IncompleteError(_('Please enter a valid email address.'))
|
||||||
|
if cart_session.get('email'):
|
||||||
|
emailval(cart_session.get('email'))
|
||||||
|
except ValidationError:
|
||||||
|
raise IncompleteError(_('Please enter a valid email address.'))
|
||||||
|
|
||||||
|
address_asked = (
|
||||||
|
event.settings.invoice_address_asked and (not event.settings.invoice_address_not_asked_free or not cart_is_free)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not all_optional:
|
||||||
|
if address_asked:
|
||||||
|
if event.settings.invoice_address_required and (not invoice_address or not invoice_address.street):
|
||||||
|
raise IncompleteError(_('Please enter your invoicing address.'))
|
||||||
|
|
||||||
|
if event.settings.invoice_name_required and (not invoice_address or not invoice_address.name):
|
||||||
|
raise IncompleteError(_('Please enter your name.'))
|
||||||
|
|
||||||
|
for cp in positions:
|
||||||
|
answ = {
|
||||||
|
aw.question_id: aw for aw in cp.answerlist
|
||||||
|
}
|
||||||
|
question_cache = {
|
||||||
|
q.pk: q for q in cp.item.questions_to_ask
|
||||||
|
}
|
||||||
|
|
||||||
|
def question_is_visible(parentid, qvals):
|
||||||
|
if parentid not in question_cache:
|
||||||
|
return False
|
||||||
|
parentq = question_cache[parentid]
|
||||||
|
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id,
|
||||||
|
parentq.dependency_values):
|
||||||
|
return False
|
||||||
|
if parentid not in answ:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
('True' in qvals and answ[parentid].answer == 'True')
|
||||||
|
or ('False' in qvals and answ[parentid].answer == 'False')
|
||||||
|
or (any(qval in [o.identifier for o in answ[parentid].options.all()] for qval in qvals))
|
||||||
|
)
|
||||||
|
|
||||||
|
def question_is_required(q):
|
||||||
|
return (
|
||||||
|
q.required and
|
||||||
|
(not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not all_optional:
|
||||||
|
for q in cp.item.questions_to_ask:
|
||||||
|
if question_is_required(q) and q.id not in answ:
|
||||||
|
raise IncompleteError(_('Please fill in answers to all required questions.'))
|
||||||
|
if cp.item.ask_attendee_data and event.settings.get('attendee_names_required', as_type=bool) \
|
||||||
|
and not cp.attendee_name_parts:
|
||||||
|
raise IncompleteError(_('Please fill in answers to all required questions.'))
|
||||||
|
if cp.item.ask_attendee_data and event.settings.get('attendee_emails_required', as_type=bool) \
|
||||||
|
and cp.attendee_email is None:
|
||||||
|
raise IncompleteError(_('Please fill in answers to all required questions.'))
|
||||||
|
if cp.item.ask_attendee_data and event.settings.get('attendee_company_required', as_type=bool) \
|
||||||
|
and cp.company is None:
|
||||||
|
raise IncompleteError(_('Please fill in answers to all required questions.'))
|
||||||
|
if cp.item.ask_attendee_data and event.settings.get('attendee_addresses_required', as_type=bool) \
|
||||||
|
and (cp.street is None and cp.city is None and cp.country is None):
|
||||||
|
raise IncompleteError(_('Please fill in answers to all required questions.'))
|
||||||
|
|
||||||
|
responses = question_form_fields.send(sender=event, position=cp)
|
||||||
|
form_data = cp.meta_info_data.get('question_form_data', {})
|
||||||
|
for r, response in sorted(responses, key=lambda r: str(r[0])):
|
||||||
|
for key, value in response.items():
|
||||||
|
if value.required and not form_data.get(key):
|
||||||
|
raise IncompleteError(_('Please fill in answers to all required questions.'))
|
||||||
132
src/pretix/base/storelogic/payment.py
Normal file
132
src/pretix/base/storelogic/payment.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import copy
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from pretix.base.storelogic import IncompleteError
|
||||||
|
from pretix.base.templatetags.money import money_filter
|
||||||
|
|
||||||
|
|
||||||
|
def payment_is_applicable(event, total, cart_positions, invoice_address, cart_session, request):
|
||||||
|
for cartpos in cart_positions:
|
||||||
|
if cartpos.requires_approval(invoice_address=invoice_address):
|
||||||
|
if 'payments' in cart_session:
|
||||||
|
del cart_session['payments']
|
||||||
|
return False
|
||||||
|
|
||||||
|
used_providers = {p['provider'] for p in cart_session.get('payments', [])}
|
||||||
|
for provider in event.get_payment_providers().values():
|
||||||
|
if provider.is_implicit(request) if callable(provider.is_implicit) else provider.is_implicit:
|
||||||
|
# TODO: do we need a different is_allowed for storefrontapi?
|
||||||
|
if provider.is_allowed(request, total=total):
|
||||||
|
cart_session['payments'] = [
|
||||||
|
{
|
||||||
|
'id': str(uuid.uuid4()),
|
||||||
|
'provider': provider.identifier,
|
||||||
|
'multi_use_supported': False,
|
||||||
|
'min_value': None,
|
||||||
|
'max_value': None,
|
||||||
|
'info_data': {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return False
|
||||||
|
elif provider.identifier in used_providers:
|
||||||
|
# is_allowed might have changed, e.g. after add-on selection
|
||||||
|
cart_session['payments'] = [p for p in cart_session['payments'] if
|
||||||
|
p['provider'] != provider.identifier]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def current_selected_payments(event, total, cart_session, total_includes_payment_fees=False, fail=False):
|
||||||
|
def _remove_payment(payment_id):
|
||||||
|
cart_session['payments'] = [p for p in cart_session['payments'] if p.get('id') != payment_id]
|
||||||
|
|
||||||
|
raw_payments = copy.deepcopy(cart_session.get('payments', []))
|
||||||
|
payments = []
|
||||||
|
total_remaining = total
|
||||||
|
for p in raw_payments:
|
||||||
|
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
||||||
|
# places in the code base:
|
||||||
|
# - pretix.base.services.cart.get_fees
|
||||||
|
# - pretix.base.services.orders._get_fees
|
||||||
|
# - pretix.presale.storelogic.payment.current_selected_payments
|
||||||
|
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
|
||||||
|
_remove_payment(p['id'])
|
||||||
|
if fail:
|
||||||
|
raise IncompleteError(
|
||||||
|
_('Your selected payment method can only be used for a payment of at least {amount}.').format(
|
||||||
|
amount=money_filter(Decimal(p['min_value']), event.currency)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
to_pay = total_remaining
|
||||||
|
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||||
|
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||||
|
|
||||||
|
pprov = event.get_payment_providers(cached=True).get(p['provider'])
|
||||||
|
if not pprov:
|
||||||
|
_remove_payment(p['id'])
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not total_includes_payment_fees:
|
||||||
|
fee = pprov.calculate_fee(to_pay)
|
||||||
|
total_remaining += fee
|
||||||
|
to_pay += fee
|
||||||
|
else:
|
||||||
|
fee = Decimal('0.00')
|
||||||
|
|
||||||
|
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||||
|
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||||
|
|
||||||
|
p['payment_amount'] = to_pay
|
||||||
|
p['provider_name'] = pprov.public_name
|
||||||
|
p['pprov'] = pprov
|
||||||
|
p['fee'] = fee
|
||||||
|
total_remaining -= to_pay
|
||||||
|
payments.append(p)
|
||||||
|
return payments
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_payment_is_completed(event, total, cart_session, request):
|
||||||
|
def _remove_payment(payment_id):
|
||||||
|
cart_session['payments'] = [p for p in cart_session['payments'] if p.get('id') != payment_id]
|
||||||
|
|
||||||
|
if not cart_session.get('payments'):
|
||||||
|
raise IncompleteError(_('Please select a payment method to proceed.'))
|
||||||
|
|
||||||
|
selected = current_selected_payments(event, total, cart_session, fail=True, total_includes_payment_fees=True)
|
||||||
|
if sum(p['payment_amount'] for p in selected) != total:
|
||||||
|
raise IncompleteError(_('Please select a payment method to proceed.'))
|
||||||
|
|
||||||
|
if len([p for p in selected if not p['multi_use_supported']]) > 1:
|
||||||
|
raise ImproperlyConfigured('Multiple non-multi-use providers in session, should never happen')
|
||||||
|
|
||||||
|
for p in selected:
|
||||||
|
# TODO: do we need a different is_allowed for storefrontapi?
|
||||||
|
if not p['pprov'] or not p['pprov'].is_enabled or not p['pprov'].is_allowed(request, total=total):
|
||||||
|
_remove_payment(p['id'])
|
||||||
|
if p['payment_amount']:
|
||||||
|
raise IncompleteError(_('Please select a payment method to proceed.'))
|
||||||
|
|
||||||
|
if not p['multi_use_supported'] and not p['pprov'].payment_is_valid_session(request):
|
||||||
|
raise IncompleteError(_('The payment information you entered was incomplete.'))
|
||||||
|
|
||||||
|
|
||||||
|
def current_payments_valid(cart_session, amount):
|
||||||
|
singleton_payments = [p for p in cart_session.get('payments', []) if not p.get('multi_use_supported')]
|
||||||
|
if len(singleton_payments) > 1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
matched = Decimal('0.00')
|
||||||
|
for p in cart_session.get('payments', []):
|
||||||
|
if p.get('min_value') and (amount - matched) < Decimal(p['min_value']):
|
||||||
|
continue
|
||||||
|
if p.get('max_value') and (amount - matched) > Decimal(p['max_value']):
|
||||||
|
matched += Decimal(p['max_value'])
|
||||||
|
else:
|
||||||
|
matched = Decimal('0.00')
|
||||||
|
|
||||||
|
return matched == Decimal('0.00'), amount - matched
|
||||||
396
src/pretix/base/storelogic/products.py
Normal file
396
src/pretix/base/storelogic/products.py
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import (
|
||||||
|
Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value,
|
||||||
|
)
|
||||||
|
from django.db.models.lookups import Exact
|
||||||
|
|
||||||
|
from pretix.base.models import (
|
||||||
|
ItemVariation, Quota, SalesChannel, SeatCategoryMapping,
|
||||||
|
)
|
||||||
|
from pretix.base.models.items import (
|
||||||
|
ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
|
||||||
|
)
|
||||||
|
from pretix.base.services.quotas import QuotaAvailability
|
||||||
|
from pretix.base.timemachine import time_machine_now
|
||||||
|
from pretix.presale.signals import item_description
|
||||||
|
|
||||||
|
|
||||||
|
def item_group_by_category(items):
|
||||||
|
return sorted(
|
||||||
|
[
|
||||||
|
# a group is a tuple of a category and a list of items
|
||||||
|
(cat, [i for i in items if i.category == cat])
|
||||||
|
for cat in set([i.category for i in items])
|
||||||
|
# insert categories into a set for uniqueness
|
||||||
|
# a set is unsorted, so sort again by category
|
||||||
|
],
|
||||||
|
key=lambda group: (group[0].position, group[0].id) if (group[0] is not None and group[0].id is not None) else (0, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_items_for_product_list(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0,
|
||||||
|
base_qs=None, allow_addons=False, allow_cross_sell=False,
|
||||||
|
quota_cache=None, filter_items=None, filter_categories=None, memberships=None,
|
||||||
|
ignore_hide_sold_out_for_item_ids=None):
|
||||||
|
base_qs_set = base_qs is not None
|
||||||
|
base_qs = base_qs if base_qs is not None else event.items
|
||||||
|
|
||||||
|
requires_seat = Exists(
|
||||||
|
SeatCategoryMapping.objects.filter(
|
||||||
|
product_id=OuterRef('pk'),
|
||||||
|
subevent=subevent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not event.settings.seating_choice:
|
||||||
|
requires_seat = Value(0, output_field=IntegerField())
|
||||||
|
|
||||||
|
variation_q = (
|
||||||
|
Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info')) &
|
||||||
|
Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info'))
|
||||||
|
)
|
||||||
|
if not voucher or not voucher.show_hidden_items:
|
||||||
|
variation_q &= Q(hide_without_voucher=False)
|
||||||
|
|
||||||
|
if memberships is not None:
|
||||||
|
prefetch_membership_types = ['require_membership_types']
|
||||||
|
else:
|
||||||
|
prefetch_membership_types = []
|
||||||
|
|
||||||
|
prefetch_var = Prefetch(
|
||||||
|
'variations',
|
||||||
|
to_attr='available_variations',
|
||||||
|
queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).annotate(
|
||||||
|
subevent_disabled=Exists(
|
||||||
|
SubEventItemVariation.objects.filter(
|
||||||
|
Q(disabled=True)
|
||||||
|
| (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now()))
|
||||||
|
| (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())),
|
||||||
|
variation_id=OuterRef('pk'),
|
||||||
|
subevent=subevent,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
).filter(
|
||||||
|
variation_q,
|
||||||
|
Q(all_sales_channels=True) | Q(limit_sales_channels=channel),
|
||||||
|
active=True,
|
||||||
|
quotas__isnull=False,
|
||||||
|
subevent_disabled=False
|
||||||
|
).prefetch_related(
|
||||||
|
*prefetch_membership_types,
|
||||||
|
Prefetch('quotas',
|
||||||
|
to_attr='_subevent_quotas',
|
||||||
|
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
|
||||||
|
subevent=subevent).select_related("subevent"))
|
||||||
|
).distinct()
|
||||||
|
)
|
||||||
|
prefetch_quotas = Prefetch(
|
||||||
|
'quotas',
|
||||||
|
to_attr='_subevent_quotas',
|
||||||
|
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent).select_related("subevent")
|
||||||
|
)
|
||||||
|
prefetch_bundles = Prefetch(
|
||||||
|
'bundles',
|
||||||
|
queryset=ItemBundle.objects.using(settings.DATABASE_REPLICA).prefetch_related(
|
||||||
|
Prefetch('bundled_item',
|
||||||
|
queryset=event.items.using(settings.DATABASE_REPLICA).select_related(
|
||||||
|
'tax_rule').prefetch_related(
|
||||||
|
Prefetch('quotas',
|
||||||
|
to_attr='_subevent_quotas',
|
||||||
|
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
|
||||||
|
subevent=subevent)),
|
||||||
|
)),
|
||||||
|
Prefetch('bundled_variation',
|
||||||
|
queryset=ItemVariation.objects.using(
|
||||||
|
settings.DATABASE_REPLICA
|
||||||
|
).select_related('item', 'item__tax_rule').filter(item__event=event).prefetch_related(
|
||||||
|
Prefetch('quotas',
|
||||||
|
to_attr='_subevent_quotas',
|
||||||
|
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
|
||||||
|
subevent=subevent)),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
items = base_qs.using(settings.DATABASE_REPLICA).filter_available(
|
||||||
|
channel=channel.identifier, voucher=voucher, allow_addons=allow_addons, allow_cross_sell=allow_cross_sell
|
||||||
|
).select_related(
|
||||||
|
'category', 'tax_rule', # for re-grouping
|
||||||
|
'hidden_if_available',
|
||||||
|
).prefetch_related(
|
||||||
|
*prefetch_membership_types,
|
||||||
|
Prefetch(
|
||||||
|
'hidden_if_item_available',
|
||||||
|
queryset=event.items.annotate(
|
||||||
|
has_variations=Count('variations'),
|
||||||
|
).prefetch_related(
|
||||||
|
prefetch_var,
|
||||||
|
prefetch_quotas,
|
||||||
|
prefetch_bundles,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
prefetch_quotas,
|
||||||
|
prefetch_var,
|
||||||
|
prefetch_bundles,
|
||||||
|
).annotate(
|
||||||
|
quotac=Count('quotas'),
|
||||||
|
has_variations=Count('variations'),
|
||||||
|
subevent_disabled=Exists(
|
||||||
|
SubEventItem.objects.filter(
|
||||||
|
Q(disabled=True)
|
||||||
|
| (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now()))
|
||||||
|
| (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())),
|
||||||
|
item_id=OuterRef('pk'),
|
||||||
|
subevent=subevent,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
mandatory_priced_addons=Exists(
|
||||||
|
ItemAddOn.objects.filter(
|
||||||
|
base_item_id=OuterRef('pk'),
|
||||||
|
min_count__gte=1,
|
||||||
|
price_included=False
|
||||||
|
)
|
||||||
|
),
|
||||||
|
requires_seat=requires_seat,
|
||||||
|
).filter(
|
||||||
|
quotac__gt=0, subevent_disabled=False,
|
||||||
|
).order_by('category__position', 'category_id', 'position', 'name')
|
||||||
|
if require_seat:
|
||||||
|
items = items.filter(requires_seat__gt=0)
|
||||||
|
elif require_seat is not None:
|
||||||
|
items = items.filter(requires_seat=0)
|
||||||
|
|
||||||
|
if filter_items:
|
||||||
|
items = items.filter(pk__in=[a for a in filter_items if a.isdigit()])
|
||||||
|
if filter_categories:
|
||||||
|
items = items.filter(category_id__in=[a for a in filter_categories if a.isdigit()])
|
||||||
|
|
||||||
|
display_add_to_cart = False
|
||||||
|
quota_cache_key = f'item_quota_cache:{subevent.id if subevent else 0}:{channel.identifier}:{bool(require_seat)}'
|
||||||
|
quota_cache = quota_cache or event.cache.get(quota_cache_key) or {}
|
||||||
|
quota_cache_existed = bool(quota_cache)
|
||||||
|
|
||||||
|
if subevent:
|
||||||
|
item_price_override = subevent.item_price_overrides
|
||||||
|
var_price_override = subevent.var_price_overrides
|
||||||
|
else:
|
||||||
|
item_price_override = {}
|
||||||
|
var_price_override = {}
|
||||||
|
|
||||||
|
restrict_vars = set()
|
||||||
|
if voucher and voucher.quota_id:
|
||||||
|
# If a voucher is set to a specific quota, we need to filter out on that level
|
||||||
|
restrict_vars = set(voucher.quota.variations.all())
|
||||||
|
|
||||||
|
quotas_to_compute = []
|
||||||
|
for item in items:
|
||||||
|
assert item.event_id == event.pk
|
||||||
|
item.event = event # save a database query if this is looked up
|
||||||
|
if item.has_variations:
|
||||||
|
for v in item.available_variations:
|
||||||
|
for q in v._subevent_quotas:
|
||||||
|
if q.pk not in quota_cache:
|
||||||
|
quotas_to_compute.append(q)
|
||||||
|
else:
|
||||||
|
for q in item._subevent_quotas:
|
||||||
|
if q.pk not in quota_cache:
|
||||||
|
quotas_to_compute.append(q)
|
||||||
|
|
||||||
|
if quotas_to_compute:
|
||||||
|
qa = QuotaAvailability()
|
||||||
|
qa.queue(*quotas_to_compute)
|
||||||
|
qa.compute()
|
||||||
|
quota_cache.update({q.pk: r for q, r in qa.results.items()})
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
if voucher and voucher.item_id and voucher.variation_id:
|
||||||
|
# Restrict variations if the voucher only allows one
|
||||||
|
item.available_variations = [v for v in item.available_variations
|
||||||
|
if v.pk == voucher.variation_id]
|
||||||
|
|
||||||
|
if channel.type_instance.unlimited_items_per_order:
|
||||||
|
max_per_order = sys.maxsize
|
||||||
|
else:
|
||||||
|
max_per_order = item.max_per_order or int(event.settings.max_items_per_order)
|
||||||
|
|
||||||
|
if item.hidden_if_available:
|
||||||
|
q = item.hidden_if_available.availability(_cache=quota_cache)
|
||||||
|
if q[0] == Quota.AVAILABILITY_OK:
|
||||||
|
item._remove = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item.hidden_if_item_available:
|
||||||
|
if item.hidden_if_item_available.has_variations:
|
||||||
|
dependency_available = any(
|
||||||
|
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK
|
||||||
|
for var in item.hidden_if_item_available.available_variations
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
|
||||||
|
dependency_available = q[0] == Quota.AVAILABILITY_OK
|
||||||
|
if dependency_available:
|
||||||
|
item._remove = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if item.require_membership and item.require_membership_hidden:
|
||||||
|
if not memberships or not any([m.membership_type in item.require_membership_types.all() for m in memberships]):
|
||||||
|
item._remove = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
item.current_unavailability_reason = item.unavailability_reason(has_voucher=voucher, subevent=subevent)
|
||||||
|
|
||||||
|
item.description = str(item.description)
|
||||||
|
for recv, resp in item_description.send(sender=event, item=item, variation=None, subevent=subevent):
|
||||||
|
if resp:
|
||||||
|
item.description += ("<br/>" if item.description else "") + resp
|
||||||
|
|
||||||
|
if not item.has_variations:
|
||||||
|
item._remove = False
|
||||||
|
if not bool(item._subevent_quotas):
|
||||||
|
item._remove = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
if voucher and (voucher.allow_ignore_quota or voucher.block_quota):
|
||||||
|
item.cached_availability = (
|
||||||
|
Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
item.cached_availability = list(
|
||||||
|
item.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not (
|
||||||
|
ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids
|
||||||
|
) and event.settings.hide_sold_out and item.cached_availability[0] < Quota.AVAILABILITY_RESERVED:
|
||||||
|
item._remove = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
item.order_max = min(
|
||||||
|
item.cached_availability[1]
|
||||||
|
if item.cached_availability[1] is not None else sys.maxsize,
|
||||||
|
max_per_order
|
||||||
|
)
|
||||||
|
|
||||||
|
original_price = item_price_override.get(item.pk, item.default_price)
|
||||||
|
voucher_reduced = False
|
||||||
|
if voucher:
|
||||||
|
price = voucher.calculate_price(original_price)
|
||||||
|
voucher_reduced = price < original_price
|
||||||
|
include_bundled = not voucher.all_bundles_included
|
||||||
|
else:
|
||||||
|
price = original_price
|
||||||
|
include_bundled = True
|
||||||
|
|
||||||
|
item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
|
||||||
|
if item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
|
||||||
|
item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled)
|
||||||
|
else:
|
||||||
|
item.suggested_price = item.display_price
|
||||||
|
|
||||||
|
if price != original_price:
|
||||||
|
item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True)
|
||||||
|
else:
|
||||||
|
item.original_price = (
|
||||||
|
item.tax(item.original_price, currency=event.currency, include_bundled=True,
|
||||||
|
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
|
||||||
|
if item.original_price else None
|
||||||
|
)
|
||||||
|
if not display_add_to_cart:
|
||||||
|
display_add_to_cart = not item.requires_seat and item.order_max > 0
|
||||||
|
else:
|
||||||
|
for var in item.available_variations:
|
||||||
|
if var.require_membership and var.require_membership_hidden:
|
||||||
|
if not memberships or not any([m.membership_type in var.require_membership_types.all() for m in memberships]):
|
||||||
|
var._remove = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
var.description = str(var.description)
|
||||||
|
for recv, resp in item_description.send(sender=event, item=item, variation=var, subevent=subevent):
|
||||||
|
if resp:
|
||||||
|
var.description += ("<br/>" if var.description else "") + resp
|
||||||
|
|
||||||
|
if voucher and (voucher.allow_ignore_quota or voucher.block_quota):
|
||||||
|
var.cached_availability = (
|
||||||
|
Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
var.cached_availability = list(
|
||||||
|
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
var.order_max = min(
|
||||||
|
var.cached_availability[1]
|
||||||
|
if var.cached_availability[1] is not None else sys.maxsize,
|
||||||
|
max_per_order
|
||||||
|
)
|
||||||
|
|
||||||
|
original_price = var_price_override.get(var.pk, var.price)
|
||||||
|
voucher_reduced = False
|
||||||
|
if voucher:
|
||||||
|
price = voucher.calculate_price(original_price)
|
||||||
|
voucher_reduced = price < original_price
|
||||||
|
include_bundled = not voucher.all_bundles_included
|
||||||
|
else:
|
||||||
|
price = original_price
|
||||||
|
include_bundled = True
|
||||||
|
|
||||||
|
var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
|
||||||
|
|
||||||
|
if item.free_price and var.free_price_suggestion is not None and not voucher_reduced:
|
||||||
|
var.suggested_price = item.tax(max(price, var.free_price_suggestion), currency=event.currency,
|
||||||
|
include_bundled=include_bundled)
|
||||||
|
elif item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
|
||||||
|
var.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency,
|
||||||
|
include_bundled=include_bundled)
|
||||||
|
else:
|
||||||
|
var.suggested_price = var.display_price
|
||||||
|
|
||||||
|
if price != original_price:
|
||||||
|
var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True)
|
||||||
|
else:
|
||||||
|
var.original_price = (
|
||||||
|
var.tax(var.original_price or item.original_price, currency=event.currency,
|
||||||
|
include_bundled=True,
|
||||||
|
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
|
||||||
|
) if var.original_price or item.original_price else None
|
||||||
|
|
||||||
|
if not display_add_to_cart:
|
||||||
|
display_add_to_cart = not item.requires_seat and var.order_max > 0
|
||||||
|
|
||||||
|
var.current_unavailability_reason = var.unavailability_reason(has_voucher=voucher, subevent=subevent)
|
||||||
|
|
||||||
|
item.original_price = (
|
||||||
|
item.tax(item.original_price, currency=event.currency, include_bundled=True,
|
||||||
|
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
|
||||||
|
if item.original_price else None
|
||||||
|
)
|
||||||
|
|
||||||
|
item.available_variations = [
|
||||||
|
v for v in item.available_variations if v._subevent_quotas and (
|
||||||
|
not voucher or not voucher.quota_id or v in restrict_vars
|
||||||
|
) and not getattr(v, '_remove', False)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not (ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids) and event.settings.hide_sold_out:
|
||||||
|
item.available_variations = [v for v in item.available_variations
|
||||||
|
if v.cached_availability[0] >= Quota.AVAILABILITY_RESERVED]
|
||||||
|
|
||||||
|
if voucher and voucher.variation_id:
|
||||||
|
item.available_variations = [v for v in item.available_variations
|
||||||
|
if v.pk == voucher.variation_id]
|
||||||
|
|
||||||
|
if len(item.available_variations) > 0:
|
||||||
|
item.min_price = min([v.display_price.net if event.settings.display_net_prices else
|
||||||
|
v.display_price.gross for v in item.available_variations])
|
||||||
|
item.max_price = max([v.display_price.net if event.settings.display_net_prices else
|
||||||
|
v.display_price.gross for v in item.available_variations])
|
||||||
|
item.best_variation_availability = max([v.cached_availability[0] for v in item.available_variations])
|
||||||
|
|
||||||
|
item._remove = not bool(item.available_variations)
|
||||||
|
|
||||||
|
if not quota_cache_existed and not voucher and not allow_addons and not base_qs_set and not filter_items and not filter_categories:
|
||||||
|
event.cache.set(quota_cache_key, quota_cache, 5)
|
||||||
|
items = [item for item in items
|
||||||
|
if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove]
|
||||||
|
return items, display_add_to_cart
|
||||||
@@ -67,6 +67,7 @@ class EventSlugBanlistValidator(BanlistValidator):
|
|||||||
'_global',
|
'_global',
|
||||||
'__debug__',
|
'__debug__',
|
||||||
'api',
|
'api',
|
||||||
|
'storefrontapi',
|
||||||
'events',
|
'events',
|
||||||
'csp_report',
|
'csp_report',
|
||||||
'widget',
|
'widget',
|
||||||
@@ -91,6 +92,7 @@ class OrganizerSlugBanlistValidator(BanlistValidator):
|
|||||||
'__debug__',
|
'__debug__',
|
||||||
'about',
|
'about',
|
||||||
'api',
|
'api',
|
||||||
|
'storefrontapi',
|
||||||
'csp_report',
|
'csp_report',
|
||||||
'widget',
|
'widget',
|
||||||
'lead',
|
'lead',
|
||||||
|
|||||||
@@ -40,5 +40,5 @@ class PretixControlConfig(AppConfig):
|
|||||||
label = 'pretixcontrol'
|
label = 'pretixcontrol'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
from .views import dashboards # noqa
|
|
||||||
from . import logdisplay # noqa
|
from . import logdisplay # noqa
|
||||||
|
from .views import dashboards # noqa
|
||||||
|
|||||||
@@ -360,6 +360,9 @@ class BankTransfer(BasePaymentProvider):
|
|||||||
}
|
}
|
||||||
return template.render(ctx)
|
return template.render(ctx)
|
||||||
|
|
||||||
|
def storefrontapi_prepare(self, session_data, total, info):
|
||||||
|
return True
|
||||||
|
|
||||||
def checkout_prepare(self, request, total):
|
def checkout_prepare(self, request, total):
|
||||||
form = self.payment_form(request)
|
form = self.payment_form(request)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
|||||||
@@ -33,8 +33,6 @@
|
|||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
import copy
|
import copy
|
||||||
import inspect
|
import inspect
|
||||||
import uuid
|
|
||||||
from collections import defaultdict
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -42,7 +40,6 @@ from django.contrib import messages
|
|||||||
from django.core.cache import caches
|
from django.core.cache import caches
|
||||||
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||||
from django.core.signing import BadSignature, loads
|
from django.core.signing import BadSignature, loads
|
||||||
from django.core.validators import EmailValidator
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Count, F, Q, Sum
|
from django.db.models import Count, F, Q, Sum
|
||||||
from django.db.models.functions import Cast
|
from django.db.models.functions import Cast
|
||||||
@@ -61,7 +58,7 @@ from pretix.base.models.items import Question
|
|||||||
from pretix.base.models.orders import (
|
from pretix.base.models.orders import (
|
||||||
InvoiceAddress, OrderPayment, QuestionAnswer,
|
InvoiceAddress, OrderPayment, QuestionAnswer,
|
||||||
)
|
)
|
||||||
from pretix.base.models.tax import TaxedPrice, TaxRule
|
from pretix.base.models.tax import TaxRule
|
||||||
from pretix.base.services.cart import (
|
from pretix.base.services.cart import (
|
||||||
CartError, CartManager, add_payment_to_cart, error_messages, get_fees,
|
CartError, CartManager, add_payment_to_cart, error_messages, get_fees,
|
||||||
set_cart_addons,
|
set_cart_addons,
|
||||||
@@ -72,6 +69,14 @@ from pretix.base.services.orders import perform_order
|
|||||||
from pretix.base.services.tasks import EventTask
|
from pretix.base.services.tasks import EventTask
|
||||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||||
from pretix.base.signals import validate_cart_addons
|
from pretix.base.signals import validate_cart_addons
|
||||||
|
from pretix.base.storelogic import IncompleteError
|
||||||
|
from pretix.base.storelogic.addons import (
|
||||||
|
addons_is_applicable, addons_is_completed, get_addon_groups,
|
||||||
|
)
|
||||||
|
from pretix.base.storelogic.fields import ensure_fields_are_completed
|
||||||
|
from pretix.base.storelogic.payment import (
|
||||||
|
current_payments_valid, ensure_payment_is_completed, payment_is_applicable,
|
||||||
|
)
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
from pretix.base.templatetags.phone_format import phone_format
|
from pretix.base.templatetags.phone_format import phone_format
|
||||||
from pretix.base.templatetags.rich_text import rich_text_snippet
|
from pretix.base.templatetags.rich_text import rich_text_snippet
|
||||||
@@ -87,7 +92,7 @@ from pretix.presale.forms.customer import AuthenticationForm, RegistrationForm
|
|||||||
from pretix.presale.signals import (
|
from pretix.presale.signals import (
|
||||||
checkout_all_optional, checkout_confirm_messages, checkout_flow_steps,
|
checkout_all_optional, checkout_confirm_messages, checkout_flow_steps,
|
||||||
contact_form_fields, contact_form_fields_overrides,
|
contact_form_fields, contact_form_fields_overrides,
|
||||||
order_api_meta_from_request, order_meta_from_request, question_form_fields,
|
order_api_meta_from_request, order_meta_from_request,
|
||||||
question_form_fields_overrides,
|
question_form_fields_overrides,
|
||||||
)
|
)
|
||||||
from pretix.presale.utils import customer_login
|
from pretix.presale.utils import customer_login
|
||||||
@@ -98,7 +103,6 @@ from pretix.presale.views.cart import (
|
|||||||
_items_from_post_data, cart_session, create_empty_cart_id,
|
_items_from_post_data, cart_session, create_empty_cart_id,
|
||||||
get_or_create_cart_id,
|
get_or_create_cart_id,
|
||||||
)
|
)
|
||||||
from pretix.presale.views.event import get_grouped_items
|
|
||||||
from pretix.presale.views.questions import QuestionsViewMixin
|
from pretix.presale.views.questions import QuestionsViewMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -493,7 +497,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
|||||||
self.request = request
|
self.request = request
|
||||||
|
|
||||||
# check whether addons are applicable
|
# check whether addons are applicable
|
||||||
if get_cart(request).filter(item__addons__isnull=False).exists():
|
if addons_is_applicable(get_cart(request)):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# don't re-check whether cross-selling is applicable if we're already past the AddOnsStep
|
# don't re-check whether cross-selling is applicable if we're already past the AddOnsStep
|
||||||
@@ -517,19 +521,9 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
|||||||
return request._checkoutflow_addons_applicable
|
return request._checkoutflow_addons_applicable
|
||||||
|
|
||||||
def is_completed(self, request, warn=False):
|
def is_completed(self, request, warn=False):
|
||||||
if getattr(self, '_completed', None) is not None:
|
if getattr(self, '_completed', None) is None:
|
||||||
return self._completed
|
self._completed = addons_is_completed(get_cart(request))
|
||||||
for cartpos in get_cart(request).filter(addon_to__isnull=True).prefetch_related(
|
return self._completed
|
||||||
'item__addons', 'item__addons__addon_category', 'addons', 'addons__item'
|
|
||||||
):
|
|
||||||
a = cartpos.addons.all()
|
|
||||||
for iao in cartpos.item.addons.all():
|
|
||||||
found = len([1 for p in a if p.item.category_id == iao.addon_category_id and not p.is_bundled])
|
|
||||||
if found < iao.min_count or found > iao.max_count:
|
|
||||||
self._completed = False
|
|
||||||
return False
|
|
||||||
self._completed = True
|
|
||||||
return True
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def forms(self):
|
def forms(self):
|
||||||
@@ -537,100 +531,12 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
|||||||
A list of forms with one form for each cart position that can have add-ons.
|
A list of forms with one form for each cart position that can have add-ons.
|
||||||
All forms have a custom prefix, so that they can all be submitted at once.
|
All forms have a custom prefix, so that they can all be submitted at once.
|
||||||
"""
|
"""
|
||||||
formset = []
|
return get_addon_groups(
|
||||||
quota_cache = {}
|
self.request.event,
|
||||||
item_cache = {}
|
self.request.sales_channel,
|
||||||
for cartpos in sorted(get_cart(self.request).filter(addon_to__isnull=True).prefetch_related(
|
getattr(self.request, 'customer', None),
|
||||||
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation',
|
get_cart(self.request),
|
||||||
), key=lambda c: c.sort_key):
|
)
|
||||||
formsetentry = {
|
|
||||||
'pos': cartpos,
|
|
||||||
'item': cartpos.item,
|
|
||||||
'variation': cartpos.variation,
|
|
||||||
'categories': []
|
|
||||||
}
|
|
||||||
|
|
||||||
current_addon_products = defaultdict(list)
|
|
||||||
for a in cartpos.addons.all():
|
|
||||||
if not a.is_bundled:
|
|
||||||
current_addon_products[a.item_id, a.variation_id].append(a)
|
|
||||||
|
|
||||||
for iao in cartpos.item.addons.all():
|
|
||||||
ckey = '{}-{}'.format(cartpos.subevent.pk if cartpos.subevent else 0, iao.addon_category.pk)
|
|
||||||
|
|
||||||
if ckey not in item_cache:
|
|
||||||
# Get all items to possibly show
|
|
||||||
items, _btn = get_grouped_items(
|
|
||||||
self.request.event,
|
|
||||||
subevent=cartpos.subevent,
|
|
||||||
voucher=None,
|
|
||||||
channel=self.request.sales_channel,
|
|
||||||
base_qs=iao.addon_category.items,
|
|
||||||
allow_addons=True,
|
|
||||||
quota_cache=quota_cache,
|
|
||||||
memberships=(
|
|
||||||
self.request.customer.usable_memberships(
|
|
||||||
for_event=cartpos.subevent or self.request.event,
|
|
||||||
testmode=self.request.event.testmode
|
|
||||||
)
|
|
||||||
if getattr(self.request, 'customer', None) else None
|
|
||||||
),
|
|
||||||
)
|
|
||||||
item_cache[ckey] = items
|
|
||||||
else:
|
|
||||||
# We can use the cache to prevent a database fetch, but we need separate Python objects
|
|
||||||
# or our things below like setting `i.initial` will do the wrong thing.
|
|
||||||
items = [copy.copy(i) for i in item_cache[ckey]]
|
|
||||||
for i in items:
|
|
||||||
i.available_variations = [copy.copy(v) for v in i.available_variations]
|
|
||||||
|
|
||||||
for i in items:
|
|
||||||
i.allow_waitinglist = False
|
|
||||||
|
|
||||||
if i.has_variations:
|
|
||||||
for v in i.available_variations:
|
|
||||||
v.initial = len(current_addon_products[i.pk, v.pk])
|
|
||||||
if v.initial and i.free_price:
|
|
||||||
a = current_addon_products[i.pk, v.pk][0]
|
|
||||||
v.initial_price = TaxedPrice(
|
|
||||||
net=a.price - a.tax_value,
|
|
||||||
gross=a.price,
|
|
||||||
tax=a.tax_value,
|
|
||||||
name=a.item.tax_rule.name if a.item.tax_rule else "",
|
|
||||||
rate=a.tax_rate,
|
|
||||||
code=a.item.tax_rule.code if a.item.tax_rule else None,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
v.initial_price = v.suggested_price
|
|
||||||
i.expand = any(v.initial for v in i.available_variations)
|
|
||||||
else:
|
|
||||||
i.initial = len(current_addon_products[i.pk, None])
|
|
||||||
if i.initial and i.free_price:
|
|
||||||
a = current_addon_products[i.pk, None][0]
|
|
||||||
i.initial_price = TaxedPrice(
|
|
||||||
net=a.price - a.tax_value,
|
|
||||||
gross=a.price,
|
|
||||||
tax=a.tax_value,
|
|
||||||
name=a.item.tax_rule.name if a.item.tax_rule else "",
|
|
||||||
rate=a.tax_rate,
|
|
||||||
code=a.item.tax_rule.code if a.item.tax_rule else None,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
i.initial_price = i.suggested_price
|
|
||||||
|
|
||||||
if items:
|
|
||||||
formsetentry['categories'].append({
|
|
||||||
'category': iao.addon_category,
|
|
||||||
'price_included': iao.price_included or (cartpos.voucher_id and cartpos.voucher.all_addons_included),
|
|
||||||
'multi_allowed': iao.multi_allowed,
|
|
||||||
'min_count': iao.min_count,
|
|
||||||
'max_count': iao.max_count,
|
|
||||||
'iao': iao,
|
|
||||||
'items': items
|
|
||||||
})
|
|
||||||
if formsetentry['categories']:
|
|
||||||
formset.append(formsetentry)
|
|
||||||
return formset
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def cross_selling_is_applicable(self):
|
def cross_selling_is_applicable(self):
|
||||||
@@ -1006,91 +912,20 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
|||||||
def is_completed(self, request, warn=False):
|
def is_completed(self, request, warn=False):
|
||||||
self.request = request
|
self.request = request
|
||||||
try:
|
try:
|
||||||
emailval = EmailValidator()
|
ensure_fields_are_completed(
|
||||||
if not self.cart_session.get('email') and not self.all_optional:
|
self.event,
|
||||||
if warn:
|
self._positions_for_questions,
|
||||||
messages.warning(request, _('Please enter a valid email address.'))
|
self.cart_session,
|
||||||
return False
|
self.invoice_address,
|
||||||
if self.cart_session.get('email'):
|
self.all_optional,
|
||||||
emailval(self.cart_session.get('email'))
|
get_cart_is_free(request),
|
||||||
except ValidationError:
|
)
|
||||||
|
except IncompleteError as e:
|
||||||
if warn:
|
if warn:
|
||||||
messages.warning(request, _('Please enter a valid email address.'))
|
messages.warning(request, e)
|
||||||
return False
|
return False
|
||||||
|
else:
|
||||||
if not self.all_optional:
|
return True
|
||||||
|
|
||||||
if self.address_asked:
|
|
||||||
if request.event.settings.invoice_address_required and (not self.invoice_address or not self.invoice_address.street):
|
|
||||||
messages.warning(request, _('Please enter your invoicing address.'))
|
|
||||||
return False
|
|
||||||
|
|
||||||
if request.event.settings.invoice_name_required and (not self.invoice_address or not self.invoice_address.name):
|
|
||||||
messages.warning(request, _('Please enter your name.'))
|
|
||||||
return False
|
|
||||||
|
|
||||||
for cp in self._positions_for_questions:
|
|
||||||
answ = {
|
|
||||||
aw.question_id: aw for aw in cp.answerlist
|
|
||||||
}
|
|
||||||
question_cache = {
|
|
||||||
q.pk: q for q in cp.item.questions_to_ask
|
|
||||||
}
|
|
||||||
|
|
||||||
def question_is_visible(parentid, qvals):
|
|
||||||
if parentid not in question_cache:
|
|
||||||
return False
|
|
||||||
parentq = question_cache[parentid]
|
|
||||||
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
|
|
||||||
return False
|
|
||||||
if parentid not in answ:
|
|
||||||
return False
|
|
||||||
return (
|
|
||||||
('True' in qvals and answ[parentid].answer == 'True')
|
|
||||||
or ('False' in qvals and answ[parentid].answer == 'False')
|
|
||||||
or (any(qval in [o.identifier for o in answ[parentid].options.all()] for qval in qvals))
|
|
||||||
)
|
|
||||||
|
|
||||||
def question_is_required(q):
|
|
||||||
return (
|
|
||||||
q.required and
|
|
||||||
(not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values))
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.all_optional:
|
|
||||||
for q in cp.item.questions_to_ask:
|
|
||||||
if question_is_required(q) and q.id not in answ:
|
|
||||||
if warn:
|
|
||||||
messages.warning(request, _('Please fill in answers to all required questions.'))
|
|
||||||
return False
|
|
||||||
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_names_required', as_type=bool) \
|
|
||||||
and not cp.attendee_name_parts:
|
|
||||||
if warn:
|
|
||||||
messages.warning(request, _('Please fill in answers to all required questions.'))
|
|
||||||
return False
|
|
||||||
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_emails_required', as_type=bool) \
|
|
||||||
and cp.attendee_email is None:
|
|
||||||
if warn:
|
|
||||||
messages.warning(request, _('Please fill in answers to all required questions.'))
|
|
||||||
return False
|
|
||||||
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_company_required', as_type=bool) \
|
|
||||||
and cp.company is None:
|
|
||||||
if warn:
|
|
||||||
messages.warning(request, _('Please fill in answers to all required questions.'))
|
|
||||||
return False
|
|
||||||
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_addresses_required', as_type=bool) \
|
|
||||||
and (cp.street is None and cp.city is None and cp.country is None):
|
|
||||||
if warn:
|
|
||||||
messages.warning(request, _('Please fill in answers to all required questions.'))
|
|
||||||
return False
|
|
||||||
|
|
||||||
responses = question_form_fields.send(sender=self.request.event, position=cp)
|
|
||||||
form_data = cp.meta_info_data.get('question_form_data', {})
|
|
||||||
for r, response in sorted(responses, key=lambda r: str(r[0])):
|
|
||||||
for key, value in response.items():
|
|
||||||
if value.required and not form_data.get(key):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
@@ -1289,20 +1124,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
|||||||
return singleton_payments[0]
|
return singleton_payments[0]
|
||||||
|
|
||||||
def current_payments_valid(self, amount):
|
def current_payments_valid(self, amount):
|
||||||
singleton_payments = [p for p in self.cart_session.get('payments', []) if not p.get('multi_use_supported')]
|
return current_payments_valid(self.cart_session, amount)
|
||||||
if len(singleton_payments) > 1:
|
|
||||||
return False
|
|
||||||
|
|
||||||
matched = Decimal('0.00')
|
|
||||||
for p in self.cart_session.get('payments', []):
|
|
||||||
if p.get('min_value') and (amount - matched) < Decimal(p['min_value']):
|
|
||||||
continue
|
|
||||||
if p.get('max_value') and (amount - matched) > Decimal(p['max_value']):
|
|
||||||
matched += Decimal(p['max_value'])
|
|
||||||
else:
|
|
||||||
matched = Decimal('0.00')
|
|
||||||
|
|
||||||
return matched == Decimal('0.00'), amount - matched
|
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
self.request = request
|
self.request = request
|
||||||
@@ -1406,6 +1228,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
|||||||
|
|
||||||
def is_completed(self, request, warn=False):
|
def is_completed(self, request, warn=False):
|
||||||
if not self.cart_session.get('payments'):
|
if not self.cart_session.get('payments'):
|
||||||
|
# Is also in ensure_payment_is_completed, but saves us performance of cart evaluation
|
||||||
if warn:
|
if warn:
|
||||||
messages.error(request, _('Please select a payment method to proceed.'))
|
messages.error(request, _('Please select a payment method to proceed.'))
|
||||||
return False
|
return False
|
||||||
@@ -1418,58 +1241,30 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
|||||||
except TaxRule.SaleNotAllowed:
|
except TaxRule.SaleNotAllowed:
|
||||||
# ignore for now, will fail on order creation
|
# ignore for now, will fail on order creation
|
||||||
pass
|
pass
|
||||||
selected = self.current_selected_payments(total, warn=warn, total_includes_payment_fees=True)
|
|
||||||
if sum(p['payment_amount'] for p in selected) != total:
|
try:
|
||||||
|
ensure_payment_is_completed(
|
||||||
|
self.event,
|
||||||
|
total,
|
||||||
|
self.cart_session,
|
||||||
|
self.request,
|
||||||
|
)
|
||||||
|
except IncompleteError as e:
|
||||||
if warn:
|
if warn:
|
||||||
messages.error(request, _('Please select a payment method to proceed.'))
|
messages.warning(self.request, str(e))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if len([p for p in selected if not p['multi_use_supported']]) > 1:
|
|
||||||
raise ImproperlyConfigured('Multiple non-multi-use providers in session, should never happen')
|
|
||||||
|
|
||||||
for p in selected:
|
|
||||||
if not p['pprov'] or not p['pprov'].is_enabled or not self._is_allowed(p['pprov'], request):
|
|
||||||
self._remove_payment(p['id'])
|
|
||||||
if p['payment_amount']:
|
|
||||||
if warn:
|
|
||||||
messages.error(request, _('Please select a payment method to proceed.'))
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not p['multi_use_supported'] and not p['pprov'].payment_is_valid_session(request):
|
|
||||||
if warn:
|
|
||||||
messages.error(request, _('The payment information you entered was incomplete.'))
|
|
||||||
return False
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def is_applicable(self, request):
|
def is_applicable(self, request):
|
||||||
self.request = request
|
self.request = request
|
||||||
|
return payment_is_applicable(
|
||||||
for cartpos in get_cart(self.request):
|
self.event,
|
||||||
if cartpos.requires_approval(invoice_address=self.invoice_address):
|
self._total_order_value,
|
||||||
if 'payments' in self.cart_session:
|
get_cart(request),
|
||||||
del self.cart_session['payments']
|
self.invoice_address,
|
||||||
return False
|
self.cart_session,
|
||||||
|
request,
|
||||||
used_providers = {p['provider'] for p in self.cart_session.get('payments', [])}
|
)
|
||||||
for provider in self.request.event.get_payment_providers().values():
|
|
||||||
if provider.is_implicit(request) if callable(provider.is_implicit) else provider.is_implicit:
|
|
||||||
if self._is_allowed(provider, request):
|
|
||||||
self.cart_session['payments'] = [
|
|
||||||
{
|
|
||||||
'id': str(uuid.uuid4()),
|
|
||||||
'provider': provider.identifier,
|
|
||||||
'multi_use_supported': False,
|
|
||||||
'min_value': None,
|
|
||||||
'max_value': None,
|
|
||||||
'info_data': {},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
return False
|
|
||||||
elif provider.identifier in used_providers:
|
|
||||||
# is_allowed might have changed, e.g. after add-on selection
|
|
||||||
self.cart_session['payments'] = [p for p in self.cart_session['payments'] if p['provider'] != provider.identifier]
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
self.request.pci_dss_payment_page = True
|
self.request.pci_dss_payment_page = True
|
||||||
|
|||||||
@@ -31,7 +31,6 @@
|
|||||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
import copy
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -44,7 +43,6 @@ from django.db.models import Exists, OuterRef, Prefetch, Sum
|
|||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
from pretix.base.i18n import get_language_without_region
|
from pretix.base.i18n import get_language_without_region
|
||||||
@@ -54,7 +52,8 @@ from pretix.base.models import (
|
|||||||
QuestionAnswer, QuestionOption, TaxRule,
|
QuestionAnswer, QuestionOption, TaxRule,
|
||||||
)
|
)
|
||||||
from pretix.base.services.cart import get_fees
|
from pretix.base.services.cart import get_fees
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.storelogic import IncompleteError
|
||||||
|
from pretix.base.storelogic.payment import current_selected_payments
|
||||||
from pretix.helpers.cookies import set_cookie_without_samesite
|
from pretix.helpers.cookies import set_cookie_without_samesite
|
||||||
from pretix.multidomain.urlreverse import eventreverse
|
from pretix.multidomain.urlreverse import eventreverse
|
||||||
from pretix.presale.signals import question_form_fields
|
from pretix.presale.signals import question_form_fields
|
||||||
@@ -256,52 +255,16 @@ class CartMixin:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def current_selected_payments(self, total, warn=False, total_includes_payment_fees=False):
|
def current_selected_payments(self, total, warn=False, total_includes_payment_fees=False):
|
||||||
raw_payments = copy.deepcopy(self.cart_session.get('payments', []))
|
try:
|
||||||
payments = []
|
return current_selected_payments(
|
||||||
total_remaining = total
|
self.request.event,
|
||||||
for p in raw_payments:
|
total,
|
||||||
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
self.cart_session,
|
||||||
# places in the code base:
|
total_includes_payment_fees=total_includes_payment_fees,
|
||||||
# - pretix.base.services.cart.get_fees
|
fail=warn
|
||||||
# - pretix.base.services.orders._get_fees
|
)
|
||||||
# - pretix.presale.views.CartMixin.current_selected_payments
|
except IncompleteError as e:
|
||||||
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
|
messages.warning(self.request, str(e))
|
||||||
if warn:
|
|
||||||
messages.warning(
|
|
||||||
self.request,
|
|
||||||
_('Your selected payment method can only be used for a payment of at least {amount}.').format(
|
|
||||||
amount=money_filter(Decimal(p['min_value']), self.request.event.currency)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self._remove_payment(p['id'])
|
|
||||||
continue
|
|
||||||
|
|
||||||
to_pay = total_remaining
|
|
||||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
|
||||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
|
||||||
|
|
||||||
pprov = self.request.event.get_payment_providers(cached=True).get(p['provider'])
|
|
||||||
if not pprov:
|
|
||||||
self._remove_payment(p['id'])
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not total_includes_payment_fees:
|
|
||||||
fee = pprov.calculate_fee(to_pay)
|
|
||||||
total_remaining += fee
|
|
||||||
to_pay += fee
|
|
||||||
else:
|
|
||||||
fee = Decimal('0.00')
|
|
||||||
|
|
||||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
|
||||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
|
||||||
|
|
||||||
p['payment_amount'] = to_pay
|
|
||||||
p['provider_name'] = pprov.public_name
|
|
||||||
p['pprov'] = pprov
|
|
||||||
p['fee'] = fee
|
|
||||||
total_remaining -= to_pay
|
|
||||||
payments.append(p)
|
|
||||||
return payments
|
|
||||||
|
|
||||||
def _remove_payment(self, payment_id):
|
def _remove_payment(self, payment_id):
|
||||||
self.cart_session['payments'] = [p for p in self.cart_session['payments'] if p.get('id') != payment_id]
|
self.cart_session['payments'] = [p for p in self.cart_session['payments'] if p.get('id') != payment_id]
|
||||||
@@ -339,8 +302,7 @@ def get_cart(request):
|
|||||||
'item__category__position', 'item__category_id', 'item__position', 'item__name', 'variation__value'
|
'item__category__position', 'item__category_id', 'item__position', 'item__name', 'variation__value'
|
||||||
).select_related(
|
).select_related(
|
||||||
'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer',
|
'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer',
|
||||||
'item__tax_rule', 'item__category', 'used_membership', 'used_membership__membership_type'
|
'item__tax_rule', 'item__category', 'used_membership', 'used_membership__membership_type',
|
||||||
).select_related(
|
|
||||||
'addon_to'
|
'addon_to'
|
||||||
).prefetch_related(
|
).prefetch_related(
|
||||||
'addons', 'addons__item', 'addons__variation',
|
'addons', 'addons__item', 'addons__variation',
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ from pretix.base.services.cart import (
|
|||||||
CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages,
|
CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages,
|
||||||
remove_cart_position,
|
remove_cart_position,
|
||||||
)
|
)
|
||||||
|
from pretix.base.storelogic.products import (
|
||||||
|
get_items_for_product_list, item_group_by_category,
|
||||||
|
)
|
||||||
from pretix.base.timemachine import time_machine_now
|
from pretix.base.timemachine import time_machine_now
|
||||||
from pretix.base.views.tasks import AsyncAction
|
from pretix.base.views.tasks import AsyncAction
|
||||||
from pretix.helpers.http import redirect_to_url
|
from pretix.helpers.http import redirect_to_url
|
||||||
@@ -72,9 +75,6 @@ from pretix.presale.views import (
|
|||||||
CartMixin, EventViewMixin, allow_cors_if_namespaced,
|
CartMixin, EventViewMixin, allow_cors_if_namespaced,
|
||||||
allow_frame_if_namespaced, iframe_entry_view_wrapper,
|
allow_frame_if_namespaced, iframe_entry_view_wrapper,
|
||||||
)
|
)
|
||||||
from pretix.presale.views.event import (
|
|
||||||
get_grouped_items, item_group_by_category,
|
|
||||||
)
|
|
||||||
from pretix.presale.views.robots import NoSearchIndexViewMixin
|
from pretix.presale.views.robots import NoSearchIndexViewMixin
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -613,7 +613,7 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, CartMixin, TemplateView
|
|||||||
context['max_times'] = self.voucher.max_usages - self.voucher.redeemed
|
context['max_times'] = self.voucher.max_usages - self.voucher.redeemed
|
||||||
|
|
||||||
# Fetch all items
|
# Fetch all items
|
||||||
items, display_add_to_cart = get_grouped_items(
|
items, display_add_to_cart = get_items_for_product_list(
|
||||||
self.request.event,
|
self.request.event,
|
||||||
subevent=self.subevent,
|
subevent=self.subevent,
|
||||||
voucher=self.voucher,
|
voucher=self.voucher,
|
||||||
|
|||||||
@@ -34,7 +34,6 @@
|
|||||||
|
|
||||||
import calendar
|
import calendar
|
||||||
import hashlib
|
import hashlib
|
||||||
import sys
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -47,10 +46,7 @@ from django import forms
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db.models import (
|
from django.db.models import Count
|
||||||
Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value,
|
|
||||||
)
|
|
||||||
from django.db.models.lookups import Exact
|
|
||||||
from django.http import Http404, HttpResponse
|
from django.http import Http404, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
@@ -64,15 +60,9 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
from pretix.base.forms.widgets import SplitDateTimePickerWidget
|
||||||
from pretix.base.models import (
|
from pretix.base.models import Quota, Voucher
|
||||||
ItemVariation, Quota, SalesChannel, SeatCategoryMapping, Voucher,
|
|
||||||
)
|
|
||||||
from pretix.base.models.event import Event, SubEvent
|
from pretix.base.models.event import Event, SubEvent
|
||||||
from pretix.base.models.items import (
|
|
||||||
ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
|
|
||||||
)
|
|
||||||
from pretix.base.services.placeholders import PlaceholderContext
|
from pretix.base.services.placeholders import PlaceholderContext
|
||||||
from pretix.base.services.quotas import QuotaAvailability
|
|
||||||
from pretix.base.timemachine import (
|
from pretix.base.timemachine import (
|
||||||
has_time_machine_permission, time_machine_now,
|
has_time_machine_permission, time_machine_now,
|
||||||
)
|
)
|
||||||
@@ -83,12 +73,15 @@ from pretix.helpers.formats.en.formats import (
|
|||||||
from pretix.helpers.http import redirect_to_url
|
from pretix.helpers.http import redirect_to_url
|
||||||
from pretix.multidomain.urlreverse import eventreverse
|
from pretix.multidomain.urlreverse import eventreverse
|
||||||
from pretix.presale.ical import get_public_ical
|
from pretix.presale.ical import get_public_ical
|
||||||
from pretix.presale.signals import item_description, seatingframe_html_head
|
from pretix.presale.signals import seatingframe_html_head
|
||||||
from pretix.presale.views.organizer import (
|
from pretix.presale.views.organizer import (
|
||||||
EventListMixin, add_subevents_for_days, days_for_template,
|
EventListMixin, add_subevents_for_days, days_for_template,
|
||||||
filter_qs_by_attr, has_before_after, weeks_for_template,
|
filter_qs_by_attr, has_before_after, weeks_for_template,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ...base.storelogic.products import (
|
||||||
|
get_items_for_product_list, item_group_by_category,
|
||||||
|
)
|
||||||
from . import (
|
from . import (
|
||||||
CartMixin, EventViewMixin, allow_frame_if_namespaced, get_cart,
|
CartMixin, EventViewMixin, allow_frame_if_namespaced, get_cart,
|
||||||
iframe_entry_view_wrapper,
|
iframe_entry_view_wrapper,
|
||||||
@@ -97,386 +90,6 @@ from . import (
|
|||||||
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
||||||
|
|
||||||
|
|
||||||
def item_group_by_category(items):
|
|
||||||
return sorted(
|
|
||||||
[
|
|
||||||
# a group is a tuple of a category and a list of items
|
|
||||||
(cat, [i for i in items if i.category == cat])
|
|
||||||
for cat in set([i.category for i in items])
|
|
||||||
# insert categories into a set for uniqueness
|
|
||||||
# a set is unsorted, so sort again by category
|
|
||||||
],
|
|
||||||
key=lambda group: (group[0].position, group[0].id) if (
|
|
||||||
group[0] is not None and group[0].id is not None) else (0, 0)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_grouped_items(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0, base_qs=None,
|
|
||||||
allow_addons=False, allow_cross_sell=False,
|
|
||||||
quota_cache=None, filter_items=None, filter_categories=None, memberships=None,
|
|
||||||
ignore_hide_sold_out_for_item_ids=None):
|
|
||||||
base_qs_set = base_qs is not None
|
|
||||||
base_qs = base_qs if base_qs is not None else event.items
|
|
||||||
|
|
||||||
requires_seat = Exists(
|
|
||||||
SeatCategoryMapping.objects.filter(
|
|
||||||
product_id=OuterRef('pk'),
|
|
||||||
subevent=subevent
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not event.settings.seating_choice:
|
|
||||||
requires_seat = Value(0, output_field=IntegerField())
|
|
||||||
|
|
||||||
variation_q = (
|
|
||||||
Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info')) &
|
|
||||||
Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info'))
|
|
||||||
)
|
|
||||||
if not voucher or not voucher.show_hidden_items:
|
|
||||||
variation_q &= Q(hide_without_voucher=False)
|
|
||||||
|
|
||||||
if memberships is not None:
|
|
||||||
prefetch_membership_types = ['require_membership_types']
|
|
||||||
else:
|
|
||||||
prefetch_membership_types = []
|
|
||||||
|
|
||||||
prefetch_var = Prefetch(
|
|
||||||
'variations',
|
|
||||||
to_attr='available_variations',
|
|
||||||
queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).annotate(
|
|
||||||
subevent_disabled=Exists(
|
|
||||||
SubEventItemVariation.objects.filter(
|
|
||||||
Q(disabled=True)
|
|
||||||
| (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now()))
|
|
||||||
| (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())),
|
|
||||||
variation_id=OuterRef('pk'),
|
|
||||||
subevent=subevent,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
).filter(
|
|
||||||
variation_q,
|
|
||||||
Q(all_sales_channels=True) | Q(limit_sales_channels=channel),
|
|
||||||
active=True,
|
|
||||||
quotas__isnull=False,
|
|
||||||
subevent_disabled=False
|
|
||||||
).prefetch_related(
|
|
||||||
*prefetch_membership_types,
|
|
||||||
Prefetch('quotas',
|
|
||||||
to_attr='_subevent_quotas',
|
|
||||||
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
|
|
||||||
subevent=subevent).select_related("subevent"))
|
|
||||||
).distinct()
|
|
||||||
)
|
|
||||||
prefetch_quotas = Prefetch(
|
|
||||||
'quotas',
|
|
||||||
to_attr='_subevent_quotas',
|
|
||||||
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent).select_related("subevent")
|
|
||||||
)
|
|
||||||
prefetch_bundles = Prefetch(
|
|
||||||
'bundles',
|
|
||||||
queryset=ItemBundle.objects.using(settings.DATABASE_REPLICA).prefetch_related(
|
|
||||||
Prefetch('bundled_item',
|
|
||||||
queryset=event.items.using(settings.DATABASE_REPLICA).select_related(
|
|
||||||
'tax_rule').prefetch_related(
|
|
||||||
Prefetch('quotas',
|
|
||||||
to_attr='_subevent_quotas',
|
|
||||||
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
|
|
||||||
subevent=subevent)),
|
|
||||||
)),
|
|
||||||
Prefetch('bundled_variation',
|
|
||||||
queryset=ItemVariation.objects.using(
|
|
||||||
settings.DATABASE_REPLICA
|
|
||||||
).select_related('item', 'item__tax_rule').filter(item__event=event).prefetch_related(
|
|
||||||
Prefetch('quotas',
|
|
||||||
to_attr='_subevent_quotas',
|
|
||||||
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
|
|
||||||
subevent=subevent)),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
items = base_qs.using(settings.DATABASE_REPLICA).filter_available(
|
|
||||||
channel=channel.identifier, voucher=voucher, allow_addons=allow_addons, allow_cross_sell=allow_cross_sell
|
|
||||||
).select_related(
|
|
||||||
'category', 'tax_rule', # for re-grouping
|
|
||||||
'hidden_if_available',
|
|
||||||
).prefetch_related(
|
|
||||||
*prefetch_membership_types,
|
|
||||||
Prefetch(
|
|
||||||
'hidden_if_item_available',
|
|
||||||
queryset=event.items.annotate(
|
|
||||||
has_variations=Count('variations'),
|
|
||||||
).prefetch_related(
|
|
||||||
prefetch_var,
|
|
||||||
prefetch_quotas,
|
|
||||||
prefetch_bundles,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
prefetch_quotas,
|
|
||||||
prefetch_var,
|
|
||||||
prefetch_bundles,
|
|
||||||
).annotate(
|
|
||||||
quotac=Count('quotas'),
|
|
||||||
has_variations=Count('variations'),
|
|
||||||
subevent_disabled=Exists(
|
|
||||||
SubEventItem.objects.filter(
|
|
||||||
Q(disabled=True)
|
|
||||||
| (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now()))
|
|
||||||
| (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())),
|
|
||||||
item_id=OuterRef('pk'),
|
|
||||||
subevent=subevent,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
mandatory_priced_addons=Exists(
|
|
||||||
ItemAddOn.objects.filter(
|
|
||||||
base_item_id=OuterRef('pk'),
|
|
||||||
min_count__gte=1,
|
|
||||||
price_included=False
|
|
||||||
)
|
|
||||||
),
|
|
||||||
requires_seat=requires_seat,
|
|
||||||
).filter(
|
|
||||||
quotac__gt=0, subevent_disabled=False,
|
|
||||||
).order_by('category__position', 'category_id', 'position', 'name')
|
|
||||||
if require_seat:
|
|
||||||
items = items.filter(requires_seat__gt=0)
|
|
||||||
elif require_seat is not None:
|
|
||||||
items = items.filter(requires_seat=0)
|
|
||||||
|
|
||||||
if filter_items:
|
|
||||||
items = items.filter(pk__in=[a for a in filter_items if a.isdigit()])
|
|
||||||
if filter_categories:
|
|
||||||
items = items.filter(category_id__in=[a for a in filter_categories if a.isdigit()])
|
|
||||||
|
|
||||||
display_add_to_cart = False
|
|
||||||
quota_cache_key = f'item_quota_cache:{subevent.id if subevent else 0}:{channel.identifier}:{bool(require_seat)}'
|
|
||||||
quota_cache = quota_cache or event.cache.get(quota_cache_key) or {}
|
|
||||||
quota_cache_existed = bool(quota_cache)
|
|
||||||
|
|
||||||
if subevent:
|
|
||||||
item_price_override = subevent.item_price_overrides
|
|
||||||
var_price_override = subevent.var_price_overrides
|
|
||||||
else:
|
|
||||||
item_price_override = {}
|
|
||||||
var_price_override = {}
|
|
||||||
|
|
||||||
restrict_vars = set()
|
|
||||||
if voucher and voucher.quota_id:
|
|
||||||
# If a voucher is set to a specific quota, we need to filter out on that level
|
|
||||||
restrict_vars = set(voucher.quota.variations.all())
|
|
||||||
|
|
||||||
quotas_to_compute = []
|
|
||||||
for item in items:
|
|
||||||
assert item.event_id == event.pk
|
|
||||||
item.event = event # save a database query if this is looked up
|
|
||||||
if item.has_variations:
|
|
||||||
for v in item.available_variations:
|
|
||||||
for q in v._subevent_quotas:
|
|
||||||
if q.pk not in quota_cache:
|
|
||||||
quotas_to_compute.append(q)
|
|
||||||
else:
|
|
||||||
for q in item._subevent_quotas:
|
|
||||||
if q.pk not in quota_cache:
|
|
||||||
quotas_to_compute.append(q)
|
|
||||||
|
|
||||||
if quotas_to_compute:
|
|
||||||
qa = QuotaAvailability()
|
|
||||||
qa.queue(*quotas_to_compute)
|
|
||||||
qa.compute()
|
|
||||||
quota_cache.update({q.pk: r for q, r in qa.results.items()})
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
if voucher and voucher.item_id and voucher.variation_id:
|
|
||||||
# Restrict variations if the voucher only allows one
|
|
||||||
item.available_variations = [v for v in item.available_variations
|
|
||||||
if v.pk == voucher.variation_id]
|
|
||||||
|
|
||||||
if channel.type_instance.unlimited_items_per_order:
|
|
||||||
max_per_order = sys.maxsize
|
|
||||||
else:
|
|
||||||
max_per_order = item.max_per_order or int(event.settings.max_items_per_order)
|
|
||||||
|
|
||||||
if item.hidden_if_available:
|
|
||||||
q = item.hidden_if_available.availability(_cache=quota_cache)
|
|
||||||
if q[0] == Quota.AVAILABILITY_OK:
|
|
||||||
item._remove = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
if item.hidden_if_item_available:
|
|
||||||
if item.hidden_if_item_available.has_variations:
|
|
||||||
dependency_available = any(
|
|
||||||
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK
|
|
||||||
for var in item.hidden_if_item_available.available_variations
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
|
|
||||||
dependency_available = q[0] == Quota.AVAILABILITY_OK
|
|
||||||
if dependency_available:
|
|
||||||
item._remove = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
if item.require_membership and item.require_membership_hidden:
|
|
||||||
if not memberships or not any([m.membership_type in item.require_membership_types.all() for m in memberships]):
|
|
||||||
item._remove = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
item.current_unavailability_reason = item.unavailability_reason(has_voucher=voucher, subevent=subevent)
|
|
||||||
|
|
||||||
item.description = str(item.description)
|
|
||||||
for recv, resp in item_description.send(sender=event, item=item, variation=None, subevent=subevent):
|
|
||||||
if resp:
|
|
||||||
item.description += ("<br/>" if item.description else "") + resp
|
|
||||||
|
|
||||||
if not item.has_variations:
|
|
||||||
item._remove = False
|
|
||||||
if not bool(item._subevent_quotas):
|
|
||||||
item._remove = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
if voucher and (voucher.allow_ignore_quota or voucher.block_quota):
|
|
||||||
item.cached_availability = (
|
|
||||||
Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
item.cached_availability = list(
|
|
||||||
item.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not (
|
|
||||||
ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids
|
|
||||||
) and event.settings.hide_sold_out and item.cached_availability[0] < Quota.AVAILABILITY_RESERVED:
|
|
||||||
item._remove = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
item.order_max = min(
|
|
||||||
item.cached_availability[1]
|
|
||||||
if item.cached_availability[1] is not None else sys.maxsize,
|
|
||||||
max_per_order
|
|
||||||
)
|
|
||||||
|
|
||||||
original_price = item_price_override.get(item.pk, item.default_price)
|
|
||||||
voucher_reduced = False
|
|
||||||
if voucher:
|
|
||||||
price = voucher.calculate_price(original_price)
|
|
||||||
voucher_reduced = price < original_price
|
|
||||||
include_bundled = not voucher.all_bundles_included
|
|
||||||
else:
|
|
||||||
price = original_price
|
|
||||||
include_bundled = True
|
|
||||||
|
|
||||||
item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
|
|
||||||
if item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
|
|
||||||
item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled)
|
|
||||||
else:
|
|
||||||
item.suggested_price = item.display_price
|
|
||||||
|
|
||||||
if price != original_price:
|
|
||||||
item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True)
|
|
||||||
else:
|
|
||||||
item.original_price = (
|
|
||||||
item.tax(item.original_price, currency=event.currency, include_bundled=True,
|
|
||||||
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
|
|
||||||
if item.original_price else None
|
|
||||||
)
|
|
||||||
if not display_add_to_cart:
|
|
||||||
display_add_to_cart = not item.requires_seat and item.order_max > 0
|
|
||||||
else:
|
|
||||||
for var in item.available_variations:
|
|
||||||
if var.require_membership and var.require_membership_hidden:
|
|
||||||
if not memberships or not any([m.membership_type in var.require_membership_types.all() for m in memberships]):
|
|
||||||
var._remove = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
var.description = str(var.description)
|
|
||||||
for recv, resp in item_description.send(sender=event, item=item, variation=var, subevent=subevent):
|
|
||||||
if resp:
|
|
||||||
var.description += ("<br/>" if var.description else "") + resp
|
|
||||||
|
|
||||||
if voucher and (voucher.allow_ignore_quota or voucher.block_quota):
|
|
||||||
var.cached_availability = (
|
|
||||||
Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
var.cached_availability = list(
|
|
||||||
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
var.order_max = min(
|
|
||||||
var.cached_availability[1]
|
|
||||||
if var.cached_availability[1] is not None else sys.maxsize,
|
|
||||||
max_per_order
|
|
||||||
)
|
|
||||||
|
|
||||||
original_price = var_price_override.get(var.pk, var.price)
|
|
||||||
voucher_reduced = False
|
|
||||||
if voucher:
|
|
||||||
price = voucher.calculate_price(original_price)
|
|
||||||
voucher_reduced = price < original_price
|
|
||||||
include_bundled = not voucher.all_bundles_included
|
|
||||||
else:
|
|
||||||
price = original_price
|
|
||||||
include_bundled = True
|
|
||||||
|
|
||||||
var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
|
|
||||||
|
|
||||||
if item.free_price and var.free_price_suggestion is not None and not voucher_reduced:
|
|
||||||
var.suggested_price = item.tax(max(price, var.free_price_suggestion), currency=event.currency,
|
|
||||||
include_bundled=include_bundled)
|
|
||||||
elif item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
|
|
||||||
var.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency,
|
|
||||||
include_bundled=include_bundled)
|
|
||||||
else:
|
|
||||||
var.suggested_price = var.display_price
|
|
||||||
|
|
||||||
if price != original_price:
|
|
||||||
var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True)
|
|
||||||
else:
|
|
||||||
var.original_price = (
|
|
||||||
var.tax(var.original_price or item.original_price, currency=event.currency,
|
|
||||||
include_bundled=True,
|
|
||||||
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
|
|
||||||
) if var.original_price or item.original_price else None
|
|
||||||
|
|
||||||
if not display_add_to_cart:
|
|
||||||
display_add_to_cart = not item.requires_seat and var.order_max > 0
|
|
||||||
|
|
||||||
var.current_unavailability_reason = var.unavailability_reason(has_voucher=voucher, subevent=subevent)
|
|
||||||
|
|
||||||
item.original_price = (
|
|
||||||
item.tax(item.original_price, currency=event.currency, include_bundled=True,
|
|
||||||
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
|
|
||||||
if item.original_price else None
|
|
||||||
)
|
|
||||||
|
|
||||||
item.available_variations = [
|
|
||||||
v for v in item.available_variations if v._subevent_quotas and (
|
|
||||||
not voucher or not voucher.quota_id or v in restrict_vars
|
|
||||||
) and not getattr(v, '_remove', False)
|
|
||||||
]
|
|
||||||
|
|
||||||
if not (ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids) and event.settings.hide_sold_out:
|
|
||||||
item.available_variations = [v for v in item.available_variations
|
|
||||||
if v.cached_availability[0] >= Quota.AVAILABILITY_RESERVED]
|
|
||||||
|
|
||||||
if voucher and voucher.variation_id:
|
|
||||||
item.available_variations = [v for v in item.available_variations
|
|
||||||
if v.pk == voucher.variation_id]
|
|
||||||
|
|
||||||
if len(item.available_variations) > 0:
|
|
||||||
item.min_price = min([v.display_price.net if event.settings.display_net_prices else
|
|
||||||
v.display_price.gross for v in item.available_variations])
|
|
||||||
item.max_price = max([v.display_price.net if event.settings.display_net_prices else
|
|
||||||
v.display_price.gross for v in item.available_variations])
|
|
||||||
item.best_variation_availability = max([v.cached_availability[0] for v in item.available_variations])
|
|
||||||
|
|
||||||
item._remove = not bool(item.available_variations)
|
|
||||||
|
|
||||||
if not quota_cache_existed and not voucher and not allow_addons and not base_qs_set and not filter_items and not filter_categories:
|
|
||||||
event.cache.set(quota_cache_key, quota_cache, 5)
|
|
||||||
items = [item for item in items
|
|
||||||
if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove]
|
|
||||||
return items, display_add_to_cart
|
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
||||||
@method_decorator(iframe_entry_view_wrapper, 'dispatch')
|
@method_decorator(iframe_entry_view_wrapper, 'dispatch')
|
||||||
class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
|
class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
|
||||||
@@ -571,7 +184,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
|
|||||||
|
|
||||||
if not self.request.event.has_subevents or self.subevent:
|
if not self.request.event.has_subevents or self.subevent:
|
||||||
# Fetch all items
|
# Fetch all items
|
||||||
items, display_add_to_cart = get_grouped_items(
|
items, display_add_to_cart = get_items_for_product_list(
|
||||||
self.request.event,
|
self.request.event,
|
||||||
subevent=self.subevent,
|
subevent=self.subevent,
|
||||||
filter_items=self.request.GET.getlist('item'),
|
filter_items=self.request.GET.getlist('item'),
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ from pretix.base.services.orders import (
|
|||||||
from pretix.base.services.pricing import get_price
|
from pretix.base.services.pricing import get_price
|
||||||
from pretix.base.services.tickets import generate, invalidate_cache
|
from pretix.base.services.tickets import generate, invalidate_cache
|
||||||
from pretix.base.signals import order_modified, register_ticket_outputs
|
from pretix.base.signals import order_modified, register_ticket_outputs
|
||||||
|
from pretix.base.storelogic.products import get_items_for_product_list
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
from pretix.base.views.mixins import OrderQuestionsViewMixin
|
from pretix.base.views.mixins import OrderQuestionsViewMixin
|
||||||
from pretix.base.views.tasks import AsyncAction
|
from pretix.base.views.tasks import AsyncAction
|
||||||
@@ -95,7 +96,6 @@ from pretix.presale.signals import question_form_fields_overrides
|
|||||||
from pretix.presale.views import (
|
from pretix.presale.views import (
|
||||||
CartMixin, EventViewMixin, iframe_entry_view_wrapper,
|
CartMixin, EventViewMixin, iframe_entry_view_wrapper,
|
||||||
)
|
)
|
||||||
from pretix.presale.views.event import get_grouped_items
|
|
||||||
from pretix.presale.views.robots import NoSearchIndexViewMixin
|
from pretix.presale.views.robots import NoSearchIndexViewMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -1372,7 +1372,7 @@ class OrderChangeMixin:
|
|||||||
|
|
||||||
if ckey not in item_cache:
|
if ckey not in item_cache:
|
||||||
# Get all items to possibly show
|
# Get all items to possibly show
|
||||||
items, _btn = get_grouped_items(
|
items, _btn = get_items_for_product_list(
|
||||||
self.request.event,
|
self.request.event,
|
||||||
subevent=p.subevent,
|
subevent=p.subevent,
|
||||||
voucher=None,
|
voucher=None,
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ from pretix.presale.views import EventViewMixin, iframe_entry_view_wrapper
|
|||||||
|
|
||||||
from ...base.i18n import get_language_without_region
|
from ...base.i18n import get_language_without_region
|
||||||
from ...base.models import Voucher, WaitingListEntry
|
from ...base.models import Voucher, WaitingListEntry
|
||||||
|
from ...base.storelogic.products import get_items_for_product_list
|
||||||
from ..forms.waitinglist import WaitingListForm
|
from ..forms.waitinglist import WaitingListForm
|
||||||
from . import allow_frame_if_namespaced
|
from . import allow_frame_if_namespaced
|
||||||
from .event import get_grouped_items
|
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
||||||
@@ -53,7 +53,7 @@ class WaitingView(EventViewMixin, FormView):
|
|||||||
@cached_property
|
@cached_property
|
||||||
def itemvars(self):
|
def itemvars(self):
|
||||||
customer = getattr(self.request, 'customer', None)
|
customer = getattr(self.request, 'customer', None)
|
||||||
items, display_add_to_cart = get_grouped_items(
|
items, display_add_to_cart = get_items_for_product_list(
|
||||||
self.request.event,
|
self.request.event,
|
||||||
subevent=self.subevent,
|
subevent=self.subevent,
|
||||||
require_seat=None,
|
require_seat=None,
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ from pretix.base.models import (
|
|||||||
from pretix.base.services.cart import error_messages
|
from pretix.base.services.cart import error_messages
|
||||||
from pretix.base.services.placeholders import PlaceholderContext
|
from pretix.base.services.placeholders import PlaceholderContext
|
||||||
from pretix.base.settings import GlobalSettingsObject
|
from pretix.base.settings import GlobalSettingsObject
|
||||||
|
from pretix.base.storelogic.products import (
|
||||||
|
get_items_for_product_list, item_group_by_category,
|
||||||
|
)
|
||||||
from pretix.base.templatetags.rich_text import rich_text
|
from pretix.base.templatetags.rich_text import rich_text
|
||||||
from pretix.helpers.daterange import daterange
|
from pretix.helpers.daterange import daterange
|
||||||
from pretix.helpers.thumb import get_thumbnail
|
from pretix.helpers.thumb import get_thumbnail
|
||||||
@@ -68,9 +71,6 @@ from pretix.multidomain.urlreverse import build_absolute_uri
|
|||||||
from pretix.presale.forms.organizer import meta_filtersets
|
from pretix.presale.forms.organizer import meta_filtersets
|
||||||
from pretix.presale.style import get_theme_vars_css
|
from pretix.presale.style import get_theme_vars_css
|
||||||
from pretix.presale.views.cart import get_or_create_cart_id
|
from pretix.presale.views.cart import get_or_create_cart_id
|
||||||
from pretix.presale.views.event import (
|
|
||||||
get_grouped_items, item_group_by_category,
|
|
||||||
)
|
|
||||||
from pretix.presale.views.organizer import (
|
from pretix.presale.views.organizer import (
|
||||||
EventListMixin, add_events_for_days, add_subevents_for_days,
|
EventListMixin, add_events_for_days, add_subevents_for_days,
|
||||||
days_for_template, filter_qs_by_attr, weeks_for_template,
|
days_for_template, filter_qs_by_attr, weeks_for_template,
|
||||||
@@ -270,7 +270,7 @@ class WidgetAPIProductList(EventListMixin, View):
|
|||||||
).values_list('item_id', flat=True)
|
).values_list('item_id', flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
items, display_add_to_cart = get_grouped_items(
|
items, display_add_to_cart = get_items_for_product_list(
|
||||||
self.request.event,
|
self.request.event,
|
||||||
subevent=self.subevent,
|
subevent=self.subevent,
|
||||||
voucher=self.voucher,
|
voucher=self.voucher,
|
||||||
|
|||||||
@@ -439,6 +439,7 @@ CORE_MODULES = {
|
|||||||
"pretix.base",
|
"pretix.base",
|
||||||
"pretix.presale",
|
"pretix.presale",
|
||||||
"pretix.control",
|
"pretix.control",
|
||||||
|
"pretix.storefrontapi",
|
||||||
"pretix.plugins.checkinlists",
|
"pretix.plugins.checkinlists",
|
||||||
"pretix.plugins.reports",
|
"pretix.plugins.reports",
|
||||||
}
|
}
|
||||||
@@ -460,6 +461,7 @@ MIDDLEWARE = [
|
|||||||
'pretix.base.middleware.SecurityMiddleware',
|
'pretix.base.middleware.SecurityMiddleware',
|
||||||
'pretix.presale.middleware.EventMiddleware',
|
'pretix.presale.middleware.EventMiddleware',
|
||||||
'pretix.api.middleware.ApiScopeMiddleware',
|
'pretix.api.middleware.ApiScopeMiddleware',
|
||||||
|
'pretix.storefrontapi.middleware.ApiMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
0
src/pretix/storefrontapi/__init__.py
Normal file
0
src/pretix/storefrontapi/__init__.py
Normal file
30
src/pretix/storefrontapi/apps.py
Normal file
30
src/pretix/storefrontapi/apps.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PretixStorefrontApiConfig(AppConfig):
|
||||||
|
name = "pretix.storefrontapi"
|
||||||
|
label = "pretixstorefrontapi"
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
from . import signals # noqa
|
||||||
0
src/pretix/storefrontapi/endpoints/__init__.py
Normal file
0
src/pretix/storefrontapi/endpoints/__init__.py
Normal file
701
src/pretix/storefrontapi/endpoints/checkout.py
Normal file
701
src/pretix/storefrontapi/endpoints/checkout.py
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||||
|
from django.utils import translation
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from rest_framework import serializers, status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.generics import get_object_or_404
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.reverse import reverse
|
||||||
|
|
||||||
|
from pretix.base.models import Item, ItemVariation, SubEvent, TaxRule
|
||||||
|
from pretix.base.models.orders import CartPosition, CheckoutSession, OrderFee
|
||||||
|
from pretix.base.services.cart import (
|
||||||
|
add_items_to_cart, add_payment_to_cart_session, error_messages, get_fees,
|
||||||
|
set_cart_addons,
|
||||||
|
)
|
||||||
|
from pretix.base.services.orders import perform_order
|
||||||
|
from pretix.base.storelogic.addons import get_addon_groups
|
||||||
|
from pretix.base.storelogic.fields import (
|
||||||
|
get_checkout_fields, get_position_fields,
|
||||||
|
)
|
||||||
|
from pretix.base.storelogic.payment import current_selected_payments
|
||||||
|
from pretix.base.timemachine import time_machine_now
|
||||||
|
from pretix.presale.signals import (
|
||||||
|
order_api_meta_from_request, order_meta_from_request,
|
||||||
|
)
|
||||||
|
from pretix.presale.views.cart import generate_cart_id
|
||||||
|
from pretix.storefrontapi.endpoints.event import (
|
||||||
|
CategorySerializer, ItemSerializer,
|
||||||
|
)
|
||||||
|
from pretix.storefrontapi.permission import StorefrontEventPermission
|
||||||
|
from pretix.storefrontapi.serializers import I18nFlattenedModelSerializer
|
||||||
|
from pretix.storefrontapi.steps import get_steps
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CartAddLineSerializer(serializers.Serializer):
|
||||||
|
item = serializers.IntegerField()
|
||||||
|
variation = serializers.IntegerField(allow_null=True, required=False)
|
||||||
|
subevent = serializers.IntegerField(allow_null=True, required=False)
|
||||||
|
count = serializers.IntegerField(default=1)
|
||||||
|
seat = serializers.CharField(allow_null=True, required=False)
|
||||||
|
price = serializers.DecimalField(
|
||||||
|
allow_null=True, required=False, decimal_places=2, max_digits=13
|
||||||
|
)
|
||||||
|
voucher = serializers.CharField(allow_null=True, required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CartAddonLineSerializer(CartAddLineSerializer):
|
||||||
|
voucher = None
|
||||||
|
addon_to = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=CartPosition.objects.none(), required=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["addon_to"].queryset = CartPosition.objects.filter(
|
||||||
|
cart_id=self.context["cart_id"], addon_to__isnull=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
i = super().to_internal_value(data)
|
||||||
|
i["addon_to"] = i["addon_to"].pk
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
class InlineItemSerializer(I18nFlattenedModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Item
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class InlineItemVariationSerializer(I18nFlattenedModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ItemVariation
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"value",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class InlineSubEventSerializer(I18nFlattenedModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SubEvent
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"date_from",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CartFeeSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = OrderFee
|
||||||
|
fields = [
|
||||||
|
"fee_type",
|
||||||
|
"description",
|
||||||
|
"value",
|
||||||
|
"tax_rate",
|
||||||
|
"tax_value",
|
||||||
|
"internal_type",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FieldSerializer(serializers.Serializer):
|
||||||
|
identifier = serializers.CharField()
|
||||||
|
label = serializers.CharField(allow_null=True)
|
||||||
|
required = serializers.BooleanField()
|
||||||
|
type = serializers.CharField()
|
||||||
|
validation_hints = serializers.DictField()
|
||||||
|
|
||||||
|
|
||||||
|
class MinimalCartPositionSerializer(serializers.ModelSerializer):
|
||||||
|
# todo: prefetch related items
|
||||||
|
item = InlineItemSerializer(read_only=True)
|
||||||
|
variation = InlineItemVariationSerializer(read_only=True)
|
||||||
|
subevent = InlineSubEventSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CartPosition
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"addon_to",
|
||||||
|
"item",
|
||||||
|
"variation",
|
||||||
|
"subevent",
|
||||||
|
"price",
|
||||||
|
"expires",
|
||||||
|
# todo: attendee_name, attendee_email, voucher, addon_to, used_membership, seat, is_bundled, discount
|
||||||
|
# todo: address, requested_valid_from
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CartPositionSerializer(MinimalCartPositionSerializer):
|
||||||
|
def to_representation(self, instance):
|
||||||
|
d = super().to_representation(instance)
|
||||||
|
fields = get_position_fields(self.context["event"], instance)
|
||||||
|
d["fields"] = FieldSerializer(
|
||||||
|
fields, many=True, context={**self.context, "position": instance}
|
||||||
|
).data
|
||||||
|
d["fields_data"] = {f.identifier: f.current_value(instance) for f in fields}
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutSessionSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CheckoutSession
|
||||||
|
fields = [
|
||||||
|
"cart_id",
|
||||||
|
"sales_channel",
|
||||||
|
"testmode",
|
||||||
|
]
|
||||||
|
|
||||||
|
def to_representation(self, checkout):
|
||||||
|
d = super().to_representation(checkout)
|
||||||
|
|
||||||
|
cartpos = checkout.get_cart_positions(prefetch_questions=True)
|
||||||
|
total = sum(p.price for p in cartpos)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fees = get_fees(
|
||||||
|
self.context["event"],
|
||||||
|
self.context["request"],
|
||||||
|
total,
|
||||||
|
(
|
||||||
|
checkout.invoice_address
|
||||||
|
if hasattr(checkout, "invoice_address")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
payments=[], # todo
|
||||||
|
positions=cartpos,
|
||||||
|
)
|
||||||
|
except TaxRule.SaleNotAllowed:
|
||||||
|
# ignore for now, will fail on order creation
|
||||||
|
fees = []
|
||||||
|
|
||||||
|
total += sum([f.value for f in fees])
|
||||||
|
d["cart_positions"] = CartPositionSerializer(
|
||||||
|
sorted(cartpos, key=lambda c: c.sort_key), many=True, context=self.context
|
||||||
|
).data
|
||||||
|
d["cart_fees"] = CartFeeSerializer(fees, many=True, context=self.context).data
|
||||||
|
d["total"] = str(total)
|
||||||
|
|
||||||
|
fields = get_checkout_fields(self.context["event"])
|
||||||
|
d["fields"] = FieldSerializer(
|
||||||
|
fields, many=True, context={**self.context, "checkout": checkout}
|
||||||
|
).data
|
||||||
|
d["fields_data"] = {
|
||||||
|
f.identifier: f.current_value(checkout.session_data) for f in fields
|
||||||
|
}
|
||||||
|
|
||||||
|
payments = current_selected_payments(
|
||||||
|
self.context["event"],
|
||||||
|
total,
|
||||||
|
checkout.session_data,
|
||||||
|
total_includes_payment_fees=False,
|
||||||
|
fail=False,
|
||||||
|
)
|
||||||
|
d["payments"] = [
|
||||||
|
{
|
||||||
|
"identifier": p["pprov"].identifier,
|
||||||
|
"label": str(p["pprov"].public_name),
|
||||||
|
"payment_amount": str(p["payment_amount"]),
|
||||||
|
}
|
||||||
|
for p in payments
|
||||||
|
]
|
||||||
|
|
||||||
|
d["steps"] = {}
|
||||||
|
if cartpos:
|
||||||
|
steps = get_steps(
|
||||||
|
self.context["event"],
|
||||||
|
cartpos,
|
||||||
|
getattr(checkout, "invoice_address", None),
|
||||||
|
checkout.session_data,
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
for step in steps:
|
||||||
|
applicable = step.is_applicable()
|
||||||
|
valid = not applicable or step.is_valid()
|
||||||
|
d["steps"][step.identifier] = {
|
||||||
|
"applicable": applicable,
|
||||||
|
"valid": valid,
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutViewSet(viewsets.ViewSet):
|
||||||
|
queryset = CheckoutSession.objects.none()
|
||||||
|
lookup_url_kwarg = "cart_id"
|
||||||
|
lookup_field = "cart_id"
|
||||||
|
permission_classes = [
|
||||||
|
StorefrontEventPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def _return_checkout_status(self, cs: CheckoutSession, status=200):
|
||||||
|
serializer = CheckoutSessionSerializer(
|
||||||
|
instance=cs,
|
||||||
|
context={
|
||||||
|
"event": self.request.event,
|
||||||
|
"request": self.request,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
serializer.data,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
if (
|
||||||
|
request.event.presale_start
|
||||||
|
and time_machine_now() < request.event.presale_start
|
||||||
|
):
|
||||||
|
raise ValidationError(error_messages["not_started"])
|
||||||
|
if request.event.presale_has_ended:
|
||||||
|
raise ValidationError(error_messages["ended"])
|
||||||
|
|
||||||
|
cs = CheckoutSession.objects.create(
|
||||||
|
event=request.event,
|
||||||
|
cart_id=generate_cart_id(),
|
||||||
|
sales_channel=request.sales_channel,
|
||||||
|
testmode=request.event.testmode,
|
||||||
|
session_data={},
|
||||||
|
)
|
||||||
|
return self._return_checkout_status(cs, status=201)
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
cs = get_object_or_404(
|
||||||
|
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
|
||||||
|
)
|
||||||
|
return self._return_checkout_status(cs, status=200)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["GET", "PUT"])
|
||||||
|
def addons(self, request, *args, **kwargs):
|
||||||
|
cs = get_object_or_404(
|
||||||
|
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
|
||||||
|
)
|
||||||
|
groups = get_addon_groups(
|
||||||
|
self.request.event,
|
||||||
|
self.request.sales_channel,
|
||||||
|
cs.customer,
|
||||||
|
CartPosition.objects.filter(cart_id=cs.cart_id),
|
||||||
|
)
|
||||||
|
ctx = {
|
||||||
|
"event": self.request.event,
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "PUT":
|
||||||
|
serializer = CartAddonLineSerializer(
|
||||||
|
data=request.data.get("lines", []),
|
||||||
|
many=True,
|
||||||
|
context={
|
||||||
|
"event": self.request.event,
|
||||||
|
"cart_id": cs.cart_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
# todo: early validation, validate_cart_addons?
|
||||||
|
return self._do_async(
|
||||||
|
cs,
|
||||||
|
set_cart_addons,
|
||||||
|
self.request.event.pk,
|
||||||
|
serializer.validated_data,
|
||||||
|
[],
|
||||||
|
cs.cart_id,
|
||||||
|
locale=translation.get_language(),
|
||||||
|
invoice_address=(
|
||||||
|
cs.invoice_address.pk if hasattr(cs, "invoice_address") else None
|
||||||
|
),
|
||||||
|
sales_channel=cs.sales_channel.identifier,
|
||||||
|
override_now_dt=time_machine_now(default=None),
|
||||||
|
)
|
||||||
|
elif request.method == "GET":
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"parent": MinimalCartPositionSerializer(
|
||||||
|
grp["pos"], context=ctx
|
||||||
|
).data,
|
||||||
|
"categories": [
|
||||||
|
{
|
||||||
|
"category": CategorySerializer(
|
||||||
|
cat["category"], context=ctx
|
||||||
|
).data,
|
||||||
|
"multi_allowed": cat["multi_allowed"],
|
||||||
|
"min_count": cat["min_count"],
|
||||||
|
"max_count": cat["max_count"],
|
||||||
|
"items": ItemSerializer(
|
||||||
|
cat["items"],
|
||||||
|
many=True,
|
||||||
|
context={
|
||||||
|
**ctx,
|
||||||
|
"price_included": cat["price_included"],
|
||||||
|
"max_count": (
|
||||||
|
cat["max_count"] if cat["multi_allowed"] else 1
|
||||||
|
),
|
||||||
|
},
|
||||||
|
).data,
|
||||||
|
}
|
||||||
|
for cat in grp["categories"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for grp in groups
|
||||||
|
]
|
||||||
|
return Response(
|
||||||
|
data={
|
||||||
|
"groups": data,
|
||||||
|
},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_total(self, cs, payments):
|
||||||
|
cartpos = cs.get_cart_positions(prefetch_questions=True)
|
||||||
|
total = sum(p.price for p in cartpos)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# TODO: do we need a different get_fees for storefrontapi?
|
||||||
|
fees = get_fees(
|
||||||
|
self.request.event,
|
||||||
|
self.request,
|
||||||
|
total,
|
||||||
|
(cs.invoice_address if hasattr(cs, "invoice_address") else None),
|
||||||
|
payments=payments,
|
||||||
|
positions=cartpos,
|
||||||
|
)
|
||||||
|
except TaxRule.SaleNotAllowed:
|
||||||
|
# ignore for now, will fail on order creation
|
||||||
|
fees = []
|
||||||
|
|
||||||
|
total += sum([f.value for f in fees])
|
||||||
|
return total
|
||||||
|
|
||||||
|
@action(detail=True, methods=["GET", "POST"])
|
||||||
|
def payment(self, request, *args, **kwargs):
|
||||||
|
cs = get_object_or_404(
|
||||||
|
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
|
||||||
|
)
|
||||||
|
if request.method == "POST":
|
||||||
|
# TODO: allow explicit removal
|
||||||
|
|
||||||
|
for provider in self.request.event.get_payment_providers().values():
|
||||||
|
if provider.identifier == request.data.get("identifier", ""):
|
||||||
|
if not provider.multi_use_supported:
|
||||||
|
# Providers with multi_use_supported will call this themselves
|
||||||
|
simulated_payments = cs.session_data.get("payments", {})
|
||||||
|
simulated_payments = [
|
||||||
|
p
|
||||||
|
for p in simulated_payments
|
||||||
|
if p.get("multi_use_supported")
|
||||||
|
]
|
||||||
|
simulated_payments.append(
|
||||||
|
{
|
||||||
|
"provider": provider.identifier,
|
||||||
|
"multi_use_supported": False,
|
||||||
|
"min_value": None,
|
||||||
|
"max_value": None,
|
||||||
|
"info_data": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
total = self._get_total(
|
||||||
|
cs,
|
||||||
|
simulated_payments,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
total = self._get_total(
|
||||||
|
cs,
|
||||||
|
[
|
||||||
|
p
|
||||||
|
for p in cs.session_data.get("payments", [])
|
||||||
|
if p.get("multi_use_supported")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = provider.storefrontapi_prepare(
|
||||||
|
cs.session_data,
|
||||||
|
total,
|
||||||
|
request.data.get("info"),
|
||||||
|
)
|
||||||
|
if provider.multi_use_supported:
|
||||||
|
if resp is True:
|
||||||
|
# Provider needs to call add_payment_to_cart itself, but we need to remove all previously
|
||||||
|
# selected ones that don't have multi_use supported. Otherwise, if you first select a credit
|
||||||
|
# card, then go back and switch to a gift card, you'll have both in the session and the credit
|
||||||
|
# card has preference, which is unexpected.
|
||||||
|
cs.session_data["payments"] = [
|
||||||
|
p
|
||||||
|
for p in cs.session_data.get("payments", [])
|
||||||
|
if p.get("multi_use_supported")
|
||||||
|
]
|
||||||
|
|
||||||
|
if provider.identifier not in [
|
||||||
|
p["provider"]
|
||||||
|
for p in cs.session_data.get("payments", [])
|
||||||
|
]:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
f"Payment provider {provider.identifier} set multi_use_supported "
|
||||||
|
f"and returned True from payment_prepare, but did not call "
|
||||||
|
f"add_payment_to_cart"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if resp is True or isinstance(resp, str):
|
||||||
|
# There can only be one payment method that does not have multi_use_supported, remove all
|
||||||
|
# previous ones.
|
||||||
|
cs.session_data["payments"] = [
|
||||||
|
p
|
||||||
|
for p in cs.session_data.get("payments", [])
|
||||||
|
if p.get("multi_use_supported")
|
||||||
|
]
|
||||||
|
add_payment_to_cart_session(
|
||||||
|
cs.session_data, provider, None, None, None
|
||||||
|
)
|
||||||
|
cs.save(update_fields=["session_data"])
|
||||||
|
return self._return_checkout_status(cs, 200)
|
||||||
|
elif request.method == "GET":
|
||||||
|
available_providers = []
|
||||||
|
total = self._get_total(
|
||||||
|
cs,
|
||||||
|
[
|
||||||
|
p
|
||||||
|
for p in cs.session_data.get("payments", [])
|
||||||
|
if p.get("multi_use_supported")
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
for provider in sorted(
|
||||||
|
self.request.event.get_payment_providers().values(),
|
||||||
|
key=lambda p: (-p.priority, str(p.public_name).title()),
|
||||||
|
):
|
||||||
|
# TODO: do we need a different is_allowed for storefrontapi?
|
||||||
|
if not provider.is_enabled or not provider.is_allowed(
|
||||||
|
self.request, total
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
fee = provider.calculate_fee(total)
|
||||||
|
available_providers.append(
|
||||||
|
{
|
||||||
|
"identifier": provider.identifier,
|
||||||
|
"label": provider.public_name,
|
||||||
|
"fee": str(fee),
|
||||||
|
"total": str(total + fee),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
data={
|
||||||
|
"available_providers": available_providers,
|
||||||
|
},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["PATCH"])
|
||||||
|
def fields(self, request, *args, **kwargs):
|
||||||
|
cs = get_object_or_404(
|
||||||
|
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
|
||||||
|
)
|
||||||
|
server_pos = {p.pk: p for p in cs.get_cart_positions(prefetch_questions=True)}
|
||||||
|
for req_pos in request.data.get("cart_positions", []):
|
||||||
|
pos = server_pos[req_pos["id"]]
|
||||||
|
fields = get_position_fields(self.request.event, pos)
|
||||||
|
fields_data = req_pos["fields_data"]
|
||||||
|
for f in fields:
|
||||||
|
if f.identifier in fields_data:
|
||||||
|
# todo: validation error handing
|
||||||
|
value = f.validate_input(fields_data[f.identifier])
|
||||||
|
f.save_input(pos, value)
|
||||||
|
|
||||||
|
fields = get_checkout_fields(self.request.event)
|
||||||
|
fields_data = request.data.get("fields_data", {})
|
||||||
|
session_data = cs.session_data
|
||||||
|
for f in fields:
|
||||||
|
if f.identifier in fields_data:
|
||||||
|
# todo: validation error handing
|
||||||
|
value = f.validate_input(fields_data[f.identifier])
|
||||||
|
f.save_input(session_data, value)
|
||||||
|
|
||||||
|
cs.session_data = session_data
|
||||||
|
cs.save(update_fields=["session_data"])
|
||||||
|
cs.refresh_from_db()
|
||||||
|
return self._return_checkout_status(cs, 200)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["POST"])
|
||||||
|
def add_to_cart(self, request, *args, **kwargs):
|
||||||
|
cs = get_object_or_404(
|
||||||
|
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
|
||||||
|
)
|
||||||
|
serializer = CartAddLineSerializer(
|
||||||
|
data=request.data.get("lines", []),
|
||||||
|
many=True,
|
||||||
|
context={
|
||||||
|
"event": self.request.event,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
return self._do_async(
|
||||||
|
cs,
|
||||||
|
add_items_to_cart,
|
||||||
|
self.request.event.pk,
|
||||||
|
serializer.validated_data,
|
||||||
|
cs.cart_id,
|
||||||
|
translation.get_language(),
|
||||||
|
cs.invoice_address.pk if hasattr(cs, "invoice_address") else None,
|
||||||
|
{},
|
||||||
|
cs.sales_channel.identifier,
|
||||||
|
time_machine_now(default=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["POST"])
|
||||||
|
def confirm(self, request, *args, **kwargs):
|
||||||
|
cs = get_object_or_404(
|
||||||
|
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
|
||||||
|
)
|
||||||
|
cartpos = cs.get_cart_positions(prefetch_questions=True)
|
||||||
|
total = sum(p.price for p in cartpos)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fees = get_fees(
|
||||||
|
self.request.event,
|
||||||
|
self.request,
|
||||||
|
total,
|
||||||
|
(cs.invoice_address if hasattr(cs, "invoice_address") else None),
|
||||||
|
payments=[], # todo
|
||||||
|
positions=cartpos,
|
||||||
|
)
|
||||||
|
except TaxRule.SaleNotAllowed as e:
|
||||||
|
raise ValidationError(str(e)) # todo: need better message?
|
||||||
|
|
||||||
|
total += sum([f.value for f in fees])
|
||||||
|
steps = get_steps(
|
||||||
|
request.event,
|
||||||
|
cartpos,
|
||||||
|
getattr(cs, "invoice_address", None),
|
||||||
|
cs.session_data,
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
for step in steps:
|
||||||
|
applicable = step.is_applicable()
|
||||||
|
valid = not applicable or step.is_valid()
|
||||||
|
if not valid:
|
||||||
|
raise ValidationError(f"Step {step.identifier} is not valid")
|
||||||
|
|
||||||
|
# todo: confirm messages, or integrate them as fields?
|
||||||
|
meta_info = {
|
||||||
|
"contact_form_data": cs.session_data.get("contact_form_data", {}),
|
||||||
|
}
|
||||||
|
api_meta = {}
|
||||||
|
for receiver, response in order_meta_from_request.send(
|
||||||
|
sender=request.event, request=request
|
||||||
|
):
|
||||||
|
meta_info.update(response)
|
||||||
|
for receiver, response in order_api_meta_from_request.send(
|
||||||
|
sender=request.event, request=request
|
||||||
|
):
|
||||||
|
api_meta.update(response)
|
||||||
|
|
||||||
|
# todo: delete checkout session
|
||||||
|
# todo: give info about order
|
||||||
|
return self._do_async(
|
||||||
|
cs,
|
||||||
|
perform_order,
|
||||||
|
self.request.event.id,
|
||||||
|
payments=cs.session_data.get("payments", []),
|
||||||
|
positions=[p.id for p in cartpos],
|
||||||
|
email=cs.session_data.get("email"),
|
||||||
|
locale=translation.get_language(),
|
||||||
|
address=cs.invoice_address.pk if hasattr(cs, "invoice_address") else None,
|
||||||
|
meta_info=meta_info,
|
||||||
|
sales_channel=request.sales_channel.identifier,
|
||||||
|
shown_total=None,
|
||||||
|
customer=cs.customer,
|
||||||
|
override_now_dt=time_machine_now(default=None),
|
||||||
|
api_meta=api_meta,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(
|
||||||
|
detail=True,
|
||||||
|
methods=["GET"],
|
||||||
|
url_name="task_status",
|
||||||
|
url_path="task/(?P<asyncid>[^/]+)",
|
||||||
|
)
|
||||||
|
def task_status(self, *args, **kwargs):
|
||||||
|
cs = get_object_or_404(
|
||||||
|
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
|
||||||
|
)
|
||||||
|
res = AsyncResult(kwargs["asyncid"])
|
||||||
|
if res.ready():
|
||||||
|
if res.successful() and not isinstance(res.info, Exception):
|
||||||
|
return self._async_success(res, cs)
|
||||||
|
else:
|
||||||
|
return self._async_error(res, cs)
|
||||||
|
return self._async_pending(res, cs)
|
||||||
|
|
||||||
|
def _do_async(self, cs, task, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
res = task.apply_async(args=args, kwargs=kwargs)
|
||||||
|
except ConnectionError:
|
||||||
|
# Task very likely not yet sent, due to redis restarting etc. Let's try once again
|
||||||
|
res = task.apply_async(args=args, kwargs=kwargs)
|
||||||
|
|
||||||
|
if res.ready():
|
||||||
|
if res.successful() and not isinstance(res.info, Exception):
|
||||||
|
return self._async_success(res, cs)
|
||||||
|
else:
|
||||||
|
return self._async_error(res, cs)
|
||||||
|
return self._async_pending(res, cs)
|
||||||
|
|
||||||
|
def _async_success(self, res, cs):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"checkout_session": self._return_checkout_status(cs).data,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _async_error(self, res, cs):
|
||||||
|
if isinstance(res.info, dict) and res.info["exc_type"] in [
|
||||||
|
"OrderError",
|
||||||
|
"CartError",
|
||||||
|
]:
|
||||||
|
message = res.info["exc_message"]
|
||||||
|
elif res.info.__class__.__name__ in ["OrderError", "CartError"]:
|
||||||
|
message = str(res.info)
|
||||||
|
else:
|
||||||
|
logger.error("Unexpected exception: %r" % res.info)
|
||||||
|
message = _("An unexpected error has occurred, please try again later.")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
status=status.HTTP_409_CONFLICT, # todo: find better status code
|
||||||
|
)
|
||||||
|
|
||||||
|
def _async_pending(self, res, cs):
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"status": "pending",
|
||||||
|
"check_url": reverse(
|
||||||
|
"storefrontapi-v1:checkoutsession-task_status",
|
||||||
|
kwargs={
|
||||||
|
"organizer": self.request.organizer.slug,
|
||||||
|
"event": self.request.event.slug,
|
||||||
|
"cart_id": cs.cart_id,
|
||||||
|
"asyncid": res.id,
|
||||||
|
},
|
||||||
|
request=self.request,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
status=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
424
src/pretix/storefrontapi/endpoints/event.py
Normal file
424
src/pretix/storefrontapi/endpoints/event.py
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from rest_framework import serializers, viewsets
|
||||||
|
from rest_framework.generics import get_object_or_404
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from pretix.base.models import (
|
||||||
|
Event, Item, ItemCategory, ItemVariation, Quota, SubEvent,
|
||||||
|
)
|
||||||
|
from pretix.base.models.tax import TaxedPrice
|
||||||
|
from pretix.base.storelogic.products import (
|
||||||
|
get_items_for_product_list, item_group_by_category,
|
||||||
|
)
|
||||||
|
from pretix.base.templatetags.rich_text import rich_text
|
||||||
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
|
from pretix.storefrontapi.permission import StorefrontEventPermission
|
||||||
|
from pretix.storefrontapi.serializers import I18nFlattenedModelSerializer
|
||||||
|
|
||||||
|
|
||||||
|
def opt_str(o):
|
||||||
|
if o is None:
|
||||||
|
return None
|
||||||
|
return str(o)
|
||||||
|
|
||||||
|
|
||||||
|
class RichTextField(serializers.Field):
|
||||||
|
def to_representation(self, value):
|
||||||
|
return rich_text(value)
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicAttrField(serializers.Field):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.attr = kwargs.pop("attr")
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def to_representation(self, value):
|
||||||
|
return getattr(value, self.attr)
|
||||||
|
|
||||||
|
|
||||||
|
class EventURLField(serializers.Field):
|
||||||
|
def to_representation(self, ev):
|
||||||
|
if isinstance(ev, SubEvent):
|
||||||
|
return build_absolute_uri(
|
||||||
|
ev.event, "presale:event.index", kwargs={"subevent": ev.pk}
|
||||||
|
)
|
||||||
|
return build_absolute_uri(ev, "presale:event.index")
|
||||||
|
|
||||||
|
|
||||||
|
class EventSettingsField(serializers.Field):
|
||||||
|
def to_representation(self, ev):
|
||||||
|
event = ev.event if isinstance(ev, SubEvent) else ev
|
||||||
|
return {
|
||||||
|
"display_net_prices": event.settings.display_net_prices,
|
||||||
|
"show_variations_expanded": event.settings.show_variations_expanded,
|
||||||
|
"show_times": event.settings.show_times,
|
||||||
|
"show_dates_on_frontpage": event.settings.show_dates_on_frontpage,
|
||||||
|
"voucher_explanation_text": str(
|
||||||
|
rich_text(event.settings.voucher_explanation_text, safelinks=False)
|
||||||
|
),
|
||||||
|
"frontpage_text": str(
|
||||||
|
rich_text(
|
||||||
|
(
|
||||||
|
ev.frontpage_text
|
||||||
|
if isinstance(ev, SubEvent)
|
||||||
|
else event.settings.frontpage_text
|
||||||
|
),
|
||||||
|
safelinks=False,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CategorySerializer(I18nFlattenedModelSerializer):
|
||||||
|
description = RichTextField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ItemCategory
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PricingField(serializers.Field):
|
||||||
|
def to_representation(self, item_or_var):
|
||||||
|
if isinstance(item_or_var, Item) and item_or_var.has_variations:
|
||||||
|
return None
|
||||||
|
|
||||||
|
item = item_or_var if isinstance(item_or_var, Item) else item_or_var.item
|
||||||
|
suggested_price = item_or_var.suggested_price
|
||||||
|
display_price = item_or_var.display_price
|
||||||
|
|
||||||
|
if self.context.get("price_included"):
|
||||||
|
display_price = TaxedPrice(
|
||||||
|
gross=Decimal("0.00"),
|
||||||
|
net=Decimal("0.00"),
|
||||||
|
tax=Decimal("0.00"),
|
||||||
|
rate=Decimal("0.00"),
|
||||||
|
name="",
|
||||||
|
code=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasattr(item, "initial_price"):
|
||||||
|
# Pre-select current price for add-ons
|
||||||
|
suggested_price = item_or_var.initial_price
|
||||||
|
|
||||||
|
return {
|
||||||
|
"display_price": {
|
||||||
|
"net": opt_str(display_price.net),
|
||||||
|
"gross": opt_str(display_price.gross),
|
||||||
|
"tax_rate": opt_str(
|
||||||
|
display_price.rate if not item.includes_mixed_tax_rate else None
|
||||||
|
),
|
||||||
|
"tax_name": opt_str(
|
||||||
|
display_price.name if not item.includes_mixed_tax_rate else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"original_price": (
|
||||||
|
{
|
||||||
|
"net": opt_str(item_or_var.original_price.net),
|
||||||
|
"gross": opt_str(item_or_var.original_price.gross),
|
||||||
|
"tax_rate": opt_str(
|
||||||
|
item_or_var.original_price.rate
|
||||||
|
if not item.includes_mixed_tax_rate
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"tax_name": opt_str(
|
||||||
|
item_or_var.original_price.name
|
||||||
|
if not item.includes_mixed_tax_rate
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if item_or_var.original_price
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"free_price": item.free_price,
|
||||||
|
"suggested_price": {
|
||||||
|
"net": opt_str(suggested_price.net),
|
||||||
|
"gross": opt_str(suggested_price.gross),
|
||||||
|
"tax_rate": opt_str(
|
||||||
|
suggested_price.rate if not item.includes_mixed_tax_rate else None
|
||||||
|
),
|
||||||
|
"tax_name": opt_str(
|
||||||
|
suggested_price.name if not item.includes_mixed_tax_rate else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"mandatory_priced_addons": getattr(item, "mandatory_priced_addons", False),
|
||||||
|
"includes_mixed_tax_rate": item.includes_mixed_tax_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityField(serializers.Field):
|
||||||
|
def to_representation(self, item_or_var):
|
||||||
|
if isinstance(item_or_var, Item) and item_or_var.has_variations:
|
||||||
|
return None
|
||||||
|
|
||||||
|
item = item_or_var if isinstance(item_or_var, Item) else item_or_var.item
|
||||||
|
|
||||||
|
if (
|
||||||
|
item_or_var.current_unavailability_reason == "require_voucher"
|
||||||
|
or item.current_unavailability_reason == "require_voucher"
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"code": "require_voucher",
|
||||||
|
"message": _("Enter a voucher code below to buy this product."),
|
||||||
|
"waiting_list": False,
|
||||||
|
"max_selection": 0,
|
||||||
|
"quota_left": None,
|
||||||
|
}
|
||||||
|
elif (
|
||||||
|
item_or_var.current_unavailability_reason == "available_from"
|
||||||
|
or item.current_unavailability_reason == "available_from"
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"code": "available_from",
|
||||||
|
"message": _("Not available yet."),
|
||||||
|
"waiting_list": False,
|
||||||
|
"max_selection": 0,
|
||||||
|
"quota_left": None,
|
||||||
|
}
|
||||||
|
elif (
|
||||||
|
item_or_var.current_unavailability_reason == "available_until"
|
||||||
|
or item.current_unavailability_reason == "available_until"
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"code": "available_until",
|
||||||
|
"message": _("Not available any more."),
|
||||||
|
"waiting_list": False,
|
||||||
|
"max_selection": 0,
|
||||||
|
"quota_left": None,
|
||||||
|
}
|
||||||
|
elif item_or_var.cached_availability[0] <= Quota.AVAILABILITY_ORDERED:
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"code": "sold_out",
|
||||||
|
"message": _("SOLD OUT"),
|
||||||
|
"waiting_list": self.context["allow_waitinglist"]
|
||||||
|
and item.allow_waitinglist,
|
||||||
|
"max_selection": 0,
|
||||||
|
"quota_left": 0,
|
||||||
|
}
|
||||||
|
elif item_or_var.cached_availability[0] < Quota.AVAILABILITY_OK:
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"code": "reserved",
|
||||||
|
"message": _(
|
||||||
|
"All remaining products are reserved but might become available again."
|
||||||
|
),
|
||||||
|
"waiting_list": self.context["allow_waitinglist"]
|
||||||
|
and item.allow_waitinglist,
|
||||||
|
"max_selection": 0,
|
||||||
|
"quota_left": 0,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"code": "ok",
|
||||||
|
"message": None,
|
||||||
|
"waiting_list": False,
|
||||||
|
"max_selection": self.context.get("max_count", item_or_var.order_max),
|
||||||
|
"quota_left": (
|
||||||
|
item_or_var.cached_availability[1]
|
||||||
|
if item.show_quota_left
|
||||||
|
and item_or_var.cached_availability[1] is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VariationSerializer(I18nFlattenedModelSerializer):
|
||||||
|
description = RichTextField()
|
||||||
|
pricing = PricingField(source="*")
|
||||||
|
availability = AvailabilityField(source="*")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = ItemVariation
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"value",
|
||||||
|
"description",
|
||||||
|
"pricing",
|
||||||
|
"availability",
|
||||||
|
]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
r = super().to_representation(instance)
|
||||||
|
if hasattr(instance, "initial"):
|
||||||
|
# Used for addons
|
||||||
|
r["initial_count"] = instance.initial
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class ItemSerializer(I18nFlattenedModelSerializer):
|
||||||
|
description = RichTextField()
|
||||||
|
available_variations = VariationSerializer(many=True, read_only=True)
|
||||||
|
pricing = PricingField(source="*")
|
||||||
|
availability = AvailabilityField(source="*")
|
||||||
|
has_variations = serializers.BooleanField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Item
|
||||||
|
fields = [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"has_variations",
|
||||||
|
"description",
|
||||||
|
"picture",
|
||||||
|
"min_per_order",
|
||||||
|
"available_variations",
|
||||||
|
"pricing",
|
||||||
|
"availability",
|
||||||
|
]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
r = super().to_representation(instance)
|
||||||
|
if hasattr(instance, "initial"):
|
||||||
|
# Used for addons
|
||||||
|
r["initial_count"] = instance.initial
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class ProductGroupField(serializers.Field):
|
||||||
|
def to_representation(self, ev):
|
||||||
|
event = ev.event if isinstance(ev, SubEvent) else ev
|
||||||
|
|
||||||
|
items, display_add_to_cart = get_items_for_product_list(
|
||||||
|
event,
|
||||||
|
subevent=ev if isinstance(ev, SubEvent) else None,
|
||||||
|
require_seat=False,
|
||||||
|
channel=self.context["sales_channel"],
|
||||||
|
voucher=None, # TODO
|
||||||
|
memberships=(
|
||||||
|
self.context["customer"].usable_memberships(
|
||||||
|
for_event=ev, testmode=event.testmode
|
||||||
|
)
|
||||||
|
if self.context.get("customer")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"category": (
|
||||||
|
CategorySerializer(cat, context=self.context).data if cat else None
|
||||||
|
),
|
||||||
|
"items": ItemSerializer(items, many=True, context=self.context).data,
|
||||||
|
}
|
||||||
|
for cat, items in item_group_by_category(items)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BaseEventDetailSerializer(I18nFlattenedModelSerializer):
|
||||||
|
public_url = EventURLField(source="*", read_only=True)
|
||||||
|
settings = EventSettingsField(source="*", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = [
|
||||||
|
"name",
|
||||||
|
"has_subevents",
|
||||||
|
"public_url",
|
||||||
|
"currency",
|
||||||
|
"settings",
|
||||||
|
]
|
||||||
|
|
||||||
|
def to_representation(self, ev):
|
||||||
|
r = super().to_representation(ev)
|
||||||
|
event = ev.event if isinstance(ev, SubEvent) else ev
|
||||||
|
|
||||||
|
if not event.settings.presale_start_show_date or event.presale_is_running:
|
||||||
|
r["effective_presale_start"] = None
|
||||||
|
if not event.settings.show_date_to:
|
||||||
|
r["date_to"] = None
|
||||||
|
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
class SubEventDetailSerializer(BaseEventDetailSerializer):
|
||||||
|
testmode = serializers.BooleanField(source="event.testmode")
|
||||||
|
has_subevents = serializers.BooleanField(source="event.has_subevents")
|
||||||
|
product_list = ProductGroupField(source="*")
|
||||||
|
|
||||||
|
# todo: vouchers_exist
|
||||||
|
# todo: date range
|
||||||
|
# todo: seating, seating waiting list
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SubEvent
|
||||||
|
fields = [
|
||||||
|
"name",
|
||||||
|
"testmode",
|
||||||
|
"has_subevents",
|
||||||
|
"public_url",
|
||||||
|
"currency",
|
||||||
|
"settings",
|
||||||
|
"location",
|
||||||
|
"date_from",
|
||||||
|
"date_to",
|
||||||
|
"date_admission",
|
||||||
|
"presale_is_running",
|
||||||
|
"effective_presale_start",
|
||||||
|
"product_list",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EventDetailSerializer(BaseEventDetailSerializer):
|
||||||
|
# todo: vouchers_exist
|
||||||
|
# todo: date range
|
||||||
|
# todo: seating, seating waiting list
|
||||||
|
product_list = ProductGroupField(source="*")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = [
|
||||||
|
"name",
|
||||||
|
"testmode",
|
||||||
|
"has_subevents",
|
||||||
|
"public_url",
|
||||||
|
"currency",
|
||||||
|
"settings",
|
||||||
|
"location",
|
||||||
|
"date_from",
|
||||||
|
"date_to",
|
||||||
|
"date_admission",
|
||||||
|
"presale_is_running",
|
||||||
|
"effective_presale_start",
|
||||||
|
"product_list",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EventViewSet(viewsets.ViewSet):
|
||||||
|
queryset = Event.objects.none()
|
||||||
|
lookup_url_kwarg = "event"
|
||||||
|
lookup_field = "slug"
|
||||||
|
permission_classes = [
|
||||||
|
StorefrontEventPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
event = request.event # Lookup is already done
|
||||||
|
# todo: prefetch related items
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"sales_channel": request.sales_channel,
|
||||||
|
"customer": None,
|
||||||
|
"event": event,
|
||||||
|
"allow_waitinglist": True,
|
||||||
|
}
|
||||||
|
if event.has_subevents:
|
||||||
|
if "subevent" in request.GET:
|
||||||
|
ctx["event"] = request.event
|
||||||
|
subevent = get_object_or_404(
|
||||||
|
request.event.subevents, pk=request.GET.get("subevent"), active=True
|
||||||
|
)
|
||||||
|
serializer = SubEventDetailSerializer(subevent, context=ctx)
|
||||||
|
else:
|
||||||
|
serializer = BaseEventDetailSerializer(event, context=ctx)
|
||||||
|
else:
|
||||||
|
serializer = EventDetailSerializer(event, context=ctx)
|
||||||
|
return Response(serializer.data)
|
||||||
153
src/pretix/storefrontapi/middleware.py
Normal file
153
src/pretix/storefrontapi/middleware.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from dateutil.parser import parse
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.urls import resolve
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from django_scopes import scope
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from pretix.base.middleware import LocaleMiddleware
|
||||||
|
from pretix.base.models import Event, Organizer
|
||||||
|
from pretix.base.timemachine import timemachine_now_var
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request: HttpRequest):
|
||||||
|
if not request.path.startswith("/storefrontapi/"):
|
||||||
|
return self.get_response(request)
|
||||||
|
|
||||||
|
url = resolve(request.path_info)
|
||||||
|
try:
|
||||||
|
request.organizer = Organizer.objects.filter(
|
||||||
|
slug=url.kwargs["organizer"],
|
||||||
|
).first()
|
||||||
|
except Organizer.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Organizer not found."},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
with scope(organizer=getattr(request, "organizer", None)):
|
||||||
|
# todo: Authorization
|
||||||
|
is_authorized_public = False # noqa
|
||||||
|
is_authorized_private = True
|
||||||
|
sales_channel_id = "web" # todo: get form authorization
|
||||||
|
|
||||||
|
if "event" in url.kwargs:
|
||||||
|
try:
|
||||||
|
request.event = request.organizer.events.get(
|
||||||
|
slug=url.kwargs["event"],
|
||||||
|
organizer=request.organizer,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not request.event.live and not is_authorized_private:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Event not live."},
|
||||||
|
status=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Event.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Event not found."},
|
||||||
|
status=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
request.sales_channel = request.organizer.sales_channels.get(
|
||||||
|
identifier=sales_channel_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"X-Storefront-Time-Machine-Date" in request.headers
|
||||||
|
and "event" in url.kwargs
|
||||||
|
):
|
||||||
|
if not request.event.testmode:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": "Time machine can only be used for events in test mode."
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
time_machine_date = parse(
|
||||||
|
request.headers["X-Storefront-Time-Machine-Date"]
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Invalid time machine header"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
request.now_dt = time_machine_date
|
||||||
|
request.now_dt_is_fake = True
|
||||||
|
timemachine_now_var.set(
|
||||||
|
request.now_dt if request.now_dt_is_fake else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
request.now_dt = now()
|
||||||
|
request.now_dt_is_fake = False
|
||||||
|
|
||||||
|
if (
|
||||||
|
not request.event.all_sales_channels
|
||||||
|
and request.sales_channel.identifier
|
||||||
|
not in (
|
||||||
|
s.identifier for s in request.event.limit_sales_channels.all()
|
||||||
|
)
|
||||||
|
):
|
||||||
|
return Response(
|
||||||
|
{"detail": "Event not available on this sales channel."},
|
||||||
|
status=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
LocaleMiddleware(NotImplementedError).process_request(request)
|
||||||
|
r = self.get_response(request)
|
||||||
|
r["Access-Control-Allow-Origin"] = "*" # todo: allow whitelist?
|
||||||
|
r["Access-Control-Allow-Methods"] = ", ".join(
|
||||||
|
[
|
||||||
|
"GET",
|
||||||
|
"POST",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS",
|
||||||
|
"PUT",
|
||||||
|
"DELETE",
|
||||||
|
"PATCH",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
r["Access-Control-Allow-Headers"] = ", ".join(
|
||||||
|
[
|
||||||
|
"Content-Type",
|
||||||
|
"X-Storefront-Time-Machine-Date",
|
||||||
|
"Accept",
|
||||||
|
"Accept-Language",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return r
|
||||||
|
finally:
|
||||||
|
timemachine_now_var.set(None)
|
||||||
30
src/pretix/storefrontapi/permission.py
Normal file
30
src/pretix/storefrontapi/permission.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
|
||||||
|
from rest_framework.permissions import BasePermission
|
||||||
|
|
||||||
|
|
||||||
|
class StorefrontEventPermission(BasePermission):
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
# TODO: Check middleware results
|
||||||
|
return True
|
||||||
30
src/pretix/storefrontapi/serializers.py
Normal file
30
src/pretix/storefrontapi/serializers.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from i18nfield.fields import I18nCharField, I18nTextField
|
||||||
|
from rest_framework.fields import Field
|
||||||
|
from rest_framework.serializers import ModelSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class I18nFlattenedField(Field):
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.allow_blank = kwargs.pop("allow_blank", False)
|
||||||
|
self.trim_whitespace = kwargs.pop("trim_whitespace", True)
|
||||||
|
self.max_length = kwargs.pop("max_length", None)
|
||||||
|
self.min_length = kwargs.pop("min_length", None)
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
def to_representation(self, value):
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
raise TypeError("Input not supported.")
|
||||||
|
|
||||||
|
|
||||||
|
class I18nFlattenedModelSerializer(ModelSerializer):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
I18nFlattenedModelSerializer.serializer_field_mapping[I18nCharField] = (
|
||||||
|
I18nFlattenedField
|
||||||
|
)
|
||||||
|
I18nFlattenedModelSerializer.serializer_field_mapping[I18nTextField] = (
|
||||||
|
I18nFlattenedField
|
||||||
|
)
|
||||||
0
src/pretix/storefrontapi/signals.py
Normal file
0
src/pretix/storefrontapi/signals.py
Normal file
122
src/pretix/storefrontapi/steps.py
Normal file
122
src/pretix/storefrontapi/steps.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
from collections import UserDict
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.test import RequestFactory
|
||||||
|
|
||||||
|
from pretix.base.storelogic import IncompleteError
|
||||||
|
from pretix.base.storelogic.addons import (
|
||||||
|
addons_is_applicable, addons_is_completed,
|
||||||
|
)
|
||||||
|
from pretix.base.storelogic.fields import ensure_fields_are_completed
|
||||||
|
from pretix.base.storelogic.payment import (
|
||||||
|
ensure_payment_is_completed, payment_is_applicable,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutStep:
|
||||||
|
def __init__(self, event, cart_positions, invoice_address, cart_session, total):
|
||||||
|
self.event = event
|
||||||
|
self.cart_positions = cart_positions
|
||||||
|
self.cart_session = cart_session
|
||||||
|
self.invoice_address = invoice_address
|
||||||
|
self.total = total
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identifier(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def is_applicable(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
|
class AddonStep(CheckoutStep):
|
||||||
|
identifier = "addons"
|
||||||
|
|
||||||
|
def is_applicable(self):
|
||||||
|
return addons_is_applicable(self.cart_positions)
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
return addons_is_completed(self.cart_positions)
|
||||||
|
|
||||||
|
|
||||||
|
class FieldsStep(CheckoutStep):
|
||||||
|
identifier = "fields"
|
||||||
|
|
||||||
|
def is_applicable(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
try:
|
||||||
|
ensure_fields_are_completed(
|
||||||
|
self.event,
|
||||||
|
self.cart_positions,
|
||||||
|
self.cart_session,
|
||||||
|
self.invoice_address,
|
||||||
|
False,
|
||||||
|
cart_is_free=self.total == Decimal("0.00"),
|
||||||
|
)
|
||||||
|
except IncompleteError:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentStep(CheckoutStep):
|
||||||
|
identifier = "payment"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def request(self):
|
||||||
|
# TODO: find a better way to avoid this
|
||||||
|
rf = RequestFactory()
|
||||||
|
r = rf.get("/")
|
||||||
|
r.event = self.event
|
||||||
|
r.organizer = self.event.organizer
|
||||||
|
self.cart_session.setdefault("fake_request", {})
|
||||||
|
cart_id = self.cart_positions[0].cart_id if self.cart_positions else None
|
||||||
|
r.session = UserDict(
|
||||||
|
{
|
||||||
|
f"current_cart_event_{self.event.pk}": cart_id,
|
||||||
|
"carts": {cart_id: self.cart_session},
|
||||||
|
} if cart_id else {}
|
||||||
|
)
|
||||||
|
r.session.session_key = cart_id
|
||||||
|
return r
|
||||||
|
|
||||||
|
def is_applicable(self):
|
||||||
|
return payment_is_applicable(
|
||||||
|
self.event,
|
||||||
|
self.total,
|
||||||
|
self.cart_positions,
|
||||||
|
self.invoice_address,
|
||||||
|
self.cart_session,
|
||||||
|
self.request,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
try:
|
||||||
|
ensure_payment_is_completed(
|
||||||
|
self.event,
|
||||||
|
self.total,
|
||||||
|
self.cart_session,
|
||||||
|
self.request,
|
||||||
|
)
|
||||||
|
except IncompleteError:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_steps(event, cart_positions, invoice_address, cart_session, total):
|
||||||
|
return [
|
||||||
|
AddonStep(event, cart_positions, invoice_address, cart_session, total),
|
||||||
|
FieldsStep(event, cart_positions, invoice_address, cart_session, total),
|
||||||
|
PaymentStep(event, cart_positions, invoice_address, cart_session, total),
|
||||||
|
# todo: cross-selling
|
||||||
|
# todo: customers
|
||||||
|
# todo: memberships
|
||||||
|
# todo: plugin signals
|
||||||
|
# todo: confirmations
|
||||||
|
]
|
||||||
48
src/pretix/storefrontapi/urls.py
Normal file
48
src/pretix/storefrontapi/urls.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
from django.apps import apps
|
||||||
|
from django.urls import include, re_path
|
||||||
|
from rest_framework import routers
|
||||||
|
|
||||||
|
from .endpoints import checkout, event
|
||||||
|
|
||||||
|
storefront_orga_router = routers.DefaultRouter()
|
||||||
|
storefront_orga_router.register(r"events", event.EventViewSet)
|
||||||
|
|
||||||
|
storefront_event_router = routers.DefaultRouter()
|
||||||
|
storefront_event_router.register(r"checkouts", checkout.CheckoutViewSet)
|
||||||
|
|
||||||
|
# Force import of all plugins to give them a chance to register URLs with the router
|
||||||
|
for app in apps.get_app_configs():
|
||||||
|
if hasattr(app, "PretixPluginMeta"):
|
||||||
|
if importlib.util.find_spec(app.name + ".urls"):
|
||||||
|
importlib.import_module(app.name + ".urls")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
re_path(r"^organizers/(?P<organizer>[^/]+)/", include(storefront_orga_router.urls)),
|
||||||
|
re_path(
|
||||||
|
r"^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/",
|
||||||
|
include(storefront_event_router.urls),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -57,6 +57,7 @@ base_patterns = [
|
|||||||
re_path(r'^csp_report/$', csp.csp_report, name='csp.report'),
|
re_path(r'^csp_report/$', csp.csp_report, name='csp.report'),
|
||||||
re_path(r'^agpl_source$', source.get_source, name='source'),
|
re_path(r'^agpl_source$', source.get_source, name='source'),
|
||||||
re_path(r'^js_helpers/states/$', js_helpers.states, name='js_helpers.states'),
|
re_path(r'^js_helpers/states/$', js_helpers.states, name='js_helpers.states'),
|
||||||
|
re_path(r'^storefrontapi/v1/', include(('pretix.storefrontapi.urls', 'pretixstorefrontapi'), namespace='storefrontapi-v1')),
|
||||||
re_path(r'^api/v1/', include(('pretix.api.urls', 'pretixapi'), namespace='api-v1')),
|
re_path(r'^api/v1/', include(('pretix.api.urls', 'pretixapi'), namespace='api-v1')),
|
||||||
re_path(r'^api/$', RedirectView.as_view(url='/api/v1/'), name='redirect-api-version'),
|
re_path(r'^api/$', RedirectView.as_view(url='/api/v1/'), name='redirect-api-version'),
|
||||||
re_path(r'^.well-known/apple-developer-merchantid-domain-association$',
|
re_path(r'^.well-known/apple-developer-merchantid-domain-association$',
|
||||||
|
|||||||
Reference in New Issue
Block a user