Compare commits

...

8 Commits

Author SHA1 Message Date
Raphael Michel
fdece7a782 Fix handling of session create 2025-02-17 23:12:55 +01:00
Raphael Michel
ab4ecd0b6b Payment and confirm 2025-01-05 13:51:58 +01:00
Raphael Michel
8f13c03245 Add fields 2025-01-04 17:18:37 +01:00
Raphael Michel
3b664f8b76 .. 2025-01-04 17:18:37 +01:00
Raphael Michel
92eb5e3ece cart add 2025-01-04 17:18:37 +01:00
Raphael Michel
ad38a7a407 Settings 2025-01-04 17:18:37 +01:00
Raphael Michel
51bdb274bd add missing packages 2025-01-04 17:18:37 +01:00
Raphael Michel
8cba60dd93 Initial steps 2025-01-04 17:18:37 +01:00
41 changed files with 2871 additions and 733 deletions

View File

@@ -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>

View File

@@ -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
----------

View File

@@ -7,6 +7,7 @@ Table of contents
user/index
admin/index
api/index
storefrontapi/index
development/index
plugins/index
license/faq

View 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.

View 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

View File

@@ -0,0 +1,7 @@
API Reference
=============
.. toctree::
:maxdepth: 2
foo

View File

@@ -24,7 +24,6 @@ from pathlib import Path
import setuptools
sys.path.append(str(Path.cwd() / 'src'))

View File

@@ -44,6 +44,7 @@ INSTALLED_APPS = [
'pretix.presale',
'pretix.multidomain',
'pretix.api',
'pretix.storefrontapi',
'pretix.helpers',
'rest_framework',
'djangoformsetjs',

View File

@@ -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",
),
),
]

View File

@@ -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,

View File

@@ -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:

View File

@@ -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()

View File

@@ -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,

View File

@@ -0,0 +1,2 @@
class IncompleteError(Exception):
pass

View 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

View 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.'))

View 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

View 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

View File

@@ -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',

View File

@@ -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

View File

@@ -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():

View File

@@ -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

View File

@@ -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',

View File

@@ -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,

View File

@@ -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'),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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:

View File

View 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

View 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,
)

View 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)

View 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)

View 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

View 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
)

View File

View 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
]

View 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),
),
]

View File

@@ -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$',