mirror of
https://github.com/pretix/pretix.git
synced 2025-12-05 21:32:28 +00:00
Compare commits
8 Commits
missing-re
...
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>
|
||||
</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="icon">
|
||||
<a href="development/index.html">
|
||||
@@ -68,7 +85,6 @@
|
||||
pretix.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div class="sectionbox">
|
||||
<div class="icon">
|
||||
<a href="plugins/index.html">
|
||||
@@ -82,19 +98,6 @@
|
||||
<p>Documentation and details on plugins that ship with pretix or are officially supported.</p>
|
||||
</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>
|
||||
|
||||
<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`.
|
||||
|
||||
.. _`rest-types`:
|
||||
|
||||
Data types
|
||||
----------
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ Table of contents
|
||||
user/index
|
||||
admin/index
|
||||
api/index
|
||||
storefrontapi/index
|
||||
development/index
|
||||
plugins/index
|
||||
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
|
||||
|
||||
|
||||
sys.path.append(str(Path.cwd() / 'src'))
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ INSTALLED_APPS = [
|
||||
'pretix.presale',
|
||||
'pretix.multidomain',
|
||||
'pretix.api',
|
||||
'pretix.storefrontapi',
|
||||
'pretix.helpers',
|
||||
'rest_framework',
|
||||
'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.db import models, transaction
|
||||
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.signals import post_delete
|
||||
@@ -3063,6 +3063,64 @@ class Transaction(models.Model):
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
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)
|
||||
customer = models.ForeignKey(
|
||||
Customer,
|
||||
|
||||
@@ -722,6 +722,10 @@ class BasePaymentProvider:
|
||||
"""
|
||||
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]:
|
||||
"""
|
||||
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]:
|
||||
for p in get_cart(request):
|
||||
if p.item.issue_giftcard:
|
||||
|
||||
@@ -23,6 +23,7 @@ from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from django.db.models import Exists, OuterRef
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import now
|
||||
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.auth import UserKnownLoginSource
|
||||
from ..models.orders import CheckoutSession
|
||||
from ..signals import periodic_task
|
||||
|
||||
|
||||
@@ -42,6 +44,10 @@ def clean_cart_positions(sender, **kwargs):
|
||||
cp.delete()
|
||||
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
|
||||
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)):
|
||||
ia.delete()
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ from typing import List
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
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:
|
||||
@@ -161,7 +161,7 @@ class CrossSellingService:
|
||||
]
|
||||
|
||||
def _prepare_items(self, subevent, items_qs, discount_info):
|
||||
items, _btn = get_grouped_items(
|
||||
items, _btn = get_items_for_product_list(
|
||||
self.event,
|
||||
subevent=subevent,
|
||||
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',
|
||||
'__debug__',
|
||||
'api',
|
||||
'storefrontapi',
|
||||
'events',
|
||||
'csp_report',
|
||||
'widget',
|
||||
@@ -91,6 +92,7 @@ class OrganizerSlugBanlistValidator(BanlistValidator):
|
||||
'__debug__',
|
||||
'about',
|
||||
'api',
|
||||
'storefrontapi',
|
||||
'csp_report',
|
||||
'widget',
|
||||
'lead',
|
||||
|
||||
@@ -40,5 +40,5 @@ class PretixControlConfig(AppConfig):
|
||||
label = 'pretixcontrol'
|
||||
|
||||
def ready(self):
|
||||
from .views import dashboards # noqa
|
||||
from . import logdisplay # noqa
|
||||
from .views import dashboards # noqa
|
||||
|
||||
@@ -360,6 +360,9 @@ class BankTransfer(BasePaymentProvider):
|
||||
}
|
||||
return template.render(ctx)
|
||||
|
||||
def storefrontapi_prepare(self, session_data, total, info):
|
||||
return True
|
||||
|
||||
def checkout_prepare(self, request, total):
|
||||
form = self.payment_form(request)
|
||||
if form.is_valid():
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
import copy
|
||||
import inspect
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
@@ -42,7 +40,6 @@ from django.contrib import messages
|
||||
from django.core.cache import caches
|
||||
from django.core.exceptions import ImproperlyConfigured, ValidationError
|
||||
from django.core.signing import BadSignature, loads
|
||||
from django.core.validators import EmailValidator
|
||||
from django.db import models
|
||||
from django.db.models import Count, F, Q, Sum
|
||||
from django.db.models.functions import Cast
|
||||
@@ -61,7 +58,7 @@ from pretix.base.models.items import Question
|
||||
from pretix.base.models.orders import (
|
||||
InvoiceAddress, OrderPayment, QuestionAnswer,
|
||||
)
|
||||
from pretix.base.models.tax import TaxedPrice, TaxRule
|
||||
from pretix.base.models.tax import TaxRule
|
||||
from pretix.base.services.cart import (
|
||||
CartError, CartManager, add_payment_to_cart, error_messages, get_fees,
|
||||
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.settings import PERSON_NAME_SCHEMES
|
||||
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.phone_format import phone_format
|
||||
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 (
|
||||
checkout_all_optional, checkout_confirm_messages, checkout_flow_steps,
|
||||
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,
|
||||
)
|
||||
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,
|
||||
get_or_create_cart_id,
|
||||
)
|
||||
from pretix.presale.views.event import get_grouped_items
|
||||
from pretix.presale.views.questions import QuestionsViewMixin
|
||||
|
||||
|
||||
@@ -493,7 +497,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
|
||||
self.request = request
|
||||
|
||||
# check whether addons are applicable
|
||||
if get_cart(request).filter(item__addons__isnull=False).exists():
|
||||
if addons_is_applicable(get_cart(request)):
|
||||
return True
|
||||
|
||||
# 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
|
||||
|
||||
def is_completed(self, request, warn=False):
|
||||
if getattr(self, '_completed', None) is not None:
|
||||
return self._completed
|
||||
for cartpos in get_cart(request).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:
|
||||
self._completed = False
|
||||
return False
|
||||
self._completed = True
|
||||
return True
|
||||
if getattr(self, '_completed', None) is None:
|
||||
self._completed = addons_is_completed(get_cart(request))
|
||||
return self._completed
|
||||
|
||||
@cached_property
|
||||
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.
|
||||
All forms have a custom prefix, so that they can all be submitted at once.
|
||||
"""
|
||||
formset = []
|
||||
quota_cache = {}
|
||||
item_cache = {}
|
||||
for cartpos in sorted(get_cart(self.request).filter(addon_to__isnull=True).prefetch_related(
|
||||
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation',
|
||||
), 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
|
||||
return get_addon_groups(
|
||||
self.request.event,
|
||||
self.request.sales_channel,
|
||||
getattr(self.request, 'customer', None),
|
||||
get_cart(self.request),
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def cross_selling_is_applicable(self):
|
||||
@@ -1006,91 +912,20 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
|
||||
def is_completed(self, request, warn=False):
|
||||
self.request = request
|
||||
try:
|
||||
emailval = EmailValidator()
|
||||
if not self.cart_session.get('email') and not self.all_optional:
|
||||
if warn:
|
||||
messages.warning(request, _('Please enter a valid email address.'))
|
||||
return False
|
||||
if self.cart_session.get('email'):
|
||||
emailval(self.cart_session.get('email'))
|
||||
except ValidationError:
|
||||
ensure_fields_are_completed(
|
||||
self.event,
|
||||
self._positions_for_questions,
|
||||
self.cart_session,
|
||||
self.invoice_address,
|
||||
self.all_optional,
|
||||
get_cart_is_free(request),
|
||||
)
|
||||
except IncompleteError as e:
|
||||
if warn:
|
||||
messages.warning(request, _('Please enter a valid email address.'))
|
||||
messages.warning(request, e)
|
||||
return False
|
||||
|
||||
if not self.all_optional:
|
||||
|
||||
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
|
||||
else:
|
||||
return True
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
@@ -1289,20 +1124,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
||||
return singleton_payments[0]
|
||||
|
||||
def current_payments_valid(self, amount):
|
||||
singleton_payments = [p for p in self.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 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
|
||||
return current_payments_valid(self.cart_session, amount)
|
||||
|
||||
def post(self, request):
|
||||
self.request = request
|
||||
@@ -1406,6 +1228,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
||||
|
||||
def is_completed(self, request, warn=False):
|
||||
if not self.cart_session.get('payments'):
|
||||
# Is also in ensure_payment_is_completed, but saves us performance of cart evaluation
|
||||
if warn:
|
||||
messages.error(request, _('Please select a payment method to proceed.'))
|
||||
return False
|
||||
@@ -1418,58 +1241,30 @@ class PaymentStep(CartMixin, TemplateFlowStep):
|
||||
except TaxRule.SaleNotAllowed:
|
||||
# ignore for now, will fail on order creation
|
||||
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:
|
||||
messages.error(request, _('Please select a payment method to proceed.'))
|
||||
messages.warning(self.request, str(e))
|
||||
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
|
||||
|
||||
def is_applicable(self, request):
|
||||
self.request = request
|
||||
|
||||
for cartpos in get_cart(self.request):
|
||||
if cartpos.requires_approval(invoice_address=self.invoice_address):
|
||||
if 'payments' in self.cart_session:
|
||||
del self.cart_session['payments']
|
||||
return False
|
||||
|
||||
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
|
||||
return payment_is_applicable(
|
||||
self.event,
|
||||
self._total_order_value,
|
||||
get_cart(request),
|
||||
self.invoice_address,
|
||||
self.cart_session,
|
||||
request,
|
||||
)
|
||||
|
||||
def get(self, request):
|
||||
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
|
||||
# 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.
|
||||
import copy
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
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.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.base.i18n import get_language_without_region
|
||||
@@ -54,7 +52,8 @@ from pretix.base.models import (
|
||||
QuestionAnswer, QuestionOption, TaxRule,
|
||||
)
|
||||
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.multidomain.urlreverse import eventreverse
|
||||
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):
|
||||
raw_payments = copy.deepcopy(self.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.views.CartMixin.current_selected_payments
|
||||
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
|
||||
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
|
||||
try:
|
||||
return current_selected_payments(
|
||||
self.request.event,
|
||||
total,
|
||||
self.cart_session,
|
||||
total_includes_payment_fees=total_includes_payment_fees,
|
||||
fail=warn
|
||||
)
|
||||
except IncompleteError as e:
|
||||
messages.warning(self.request, str(e))
|
||||
|
||||
def _remove_payment(self, 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'
|
||||
).select_related(
|
||||
'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer',
|
||||
'item__tax_rule', 'item__category', 'used_membership', 'used_membership__membership_type'
|
||||
).select_related(
|
||||
'item__tax_rule', 'item__category', 'used_membership', 'used_membership__membership_type',
|
||||
'addon_to'
|
||||
).prefetch_related(
|
||||
'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,
|
||||
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.views.tasks import AsyncAction
|
||||
from pretix.helpers.http import redirect_to_url
|
||||
@@ -72,9 +75,6 @@ from pretix.presale.views import (
|
||||
CartMixin, EventViewMixin, allow_cors_if_namespaced,
|
||||
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
|
||||
|
||||
try:
|
||||
@@ -613,7 +613,7 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, CartMixin, TemplateView
|
||||
context['max_times'] = self.voucher.max_usages - self.voucher.redeemed
|
||||
|
||||
# Fetch all items
|
||||
items, display_add_to_cart = get_grouped_items(
|
||||
items, display_add_to_cart = get_items_for_product_list(
|
||||
self.request.event,
|
||||
subevent=self.subevent,
|
||||
voucher=self.voucher,
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
|
||||
import calendar
|
||||
import hashlib
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
@@ -47,10 +46,7 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import (
|
||||
Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value,
|
||||
)
|
||||
from django.db.models.lookups import Exact
|
||||
from django.db.models import Count
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
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 pretix.base.forms.widgets import SplitDateTimePickerWidget
|
||||
from pretix.base.models import (
|
||||
ItemVariation, Quota, SalesChannel, SeatCategoryMapping, Voucher,
|
||||
)
|
||||
from pretix.base.models import Quota, Voucher
|
||||
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.quotas import QuotaAvailability
|
||||
from pretix.base.timemachine import (
|
||||
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.multidomain.urlreverse import eventreverse
|
||||
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 (
|
||||
EventListMixin, add_subevents_for_days, days_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 (
|
||||
CartMixin, EventViewMixin, allow_frame_if_namespaced, get_cart,
|
||||
iframe_entry_view_wrapper,
|
||||
@@ -97,386 +90,6 @@ from . import (
|
||||
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(iframe_entry_view_wrapper, 'dispatch')
|
||||
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:
|
||||
# Fetch all items
|
||||
items, display_add_to_cart = get_grouped_items(
|
||||
items, display_add_to_cart = get_items_for_product_list(
|
||||
self.request.event,
|
||||
subevent=self.subevent,
|
||||
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.tickets import generate, invalidate_cache
|
||||
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.views.mixins import OrderQuestionsViewMixin
|
||||
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 (
|
||||
CartMixin, EventViewMixin, iframe_entry_view_wrapper,
|
||||
)
|
||||
from pretix.presale.views.event import get_grouped_items
|
||||
from pretix.presale.views.robots import NoSearchIndexViewMixin
|
||||
|
||||
|
||||
@@ -1372,7 +1372,7 @@ class OrderChangeMixin:
|
||||
|
||||
if ckey not in item_cache:
|
||||
# Get all items to possibly show
|
||||
items, _btn = get_grouped_items(
|
||||
items, _btn = get_items_for_product_list(
|
||||
self.request.event,
|
||||
subevent=p.subevent,
|
||||
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.models import Voucher, WaitingListEntry
|
||||
from ...base.storelogic.products import get_items_for_product_list
|
||||
from ..forms.waitinglist import WaitingListForm
|
||||
from . import allow_frame_if_namespaced
|
||||
from .event import get_grouped_items
|
||||
|
||||
|
||||
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
||||
@@ -53,7 +53,7 @@ class WaitingView(EventViewMixin, FormView):
|
||||
@cached_property
|
||||
def itemvars(self):
|
||||
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,
|
||||
subevent=self.subevent,
|
||||
require_seat=None,
|
||||
|
||||
@@ -61,6 +61,9 @@ from pretix.base.models import (
|
||||
from pretix.base.services.cart import error_messages
|
||||
from pretix.base.services.placeholders import PlaceholderContext
|
||||
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.helpers.daterange import daterange
|
||||
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.style import get_theme_vars_css
|
||||
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 (
|
||||
EventListMixin, add_events_for_days, add_subevents_for_days,
|
||||
days_for_template, filter_qs_by_attr, weeks_for_template,
|
||||
@@ -270,7 +270,7 @@ class WidgetAPIProductList(EventListMixin, View):
|
||||
).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,
|
||||
subevent=self.subevent,
|
||||
voucher=self.voucher,
|
||||
|
||||
@@ -439,6 +439,7 @@ CORE_MODULES = {
|
||||
"pretix.base",
|
||||
"pretix.presale",
|
||||
"pretix.control",
|
||||
"pretix.storefrontapi",
|
||||
"pretix.plugins.checkinlists",
|
||||
"pretix.plugins.reports",
|
||||
}
|
||||
@@ -460,6 +461,7 @@ MIDDLEWARE = [
|
||||
'pretix.base.middleware.SecurityMiddleware',
|
||||
'pretix.presale.middleware.EventMiddleware',
|
||||
'pretix.api.middleware.ApiScopeMiddleware',
|
||||
'pretix.storefrontapi.middleware.ApiMiddleware',
|
||||
]
|
||||
|
||||
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'^agpl_source$', source.get_source, name='source'),
|
||||
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/$', RedirectView.as_view(url='/api/v1/'), name='redirect-api-version'),
|
||||
re_path(r'^.well-known/apple-developer-merchantid-domain-association$',
|
||||
|
||||
Reference in New Issue
Block a user