diff --git a/src/pretix/base/storelogic/__init__.py b/src/pretix/base/storelogic/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/pretix/base/storelogic/products.py b/src/pretix/base/storelogic/products.py
new file mode 100644
index 0000000000..d391374dec
--- /dev/null
+++ b/src/pretix/base/storelogic/products.py
@@ -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 += ("
" 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 += ("
" 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
diff --git a/src/pretix/storefrontapi/__init__.py b/src/pretix/storefrontapi/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/pretix/storefrontapi/apps.py b/src/pretix/storefrontapi/apps.py
new file mode 100644
index 0000000000..2a3ba7d912
--- /dev/null
+++ b/src/pretix/storefrontapi/apps.py
@@ -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 .
+#
+# 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
+# .
+#
+from django.apps import AppConfig
+
+
+class PretixStorefrontApiConfig(AppConfig):
+ name = "pretix.storefrontapi"
+ label = "pretixstorefrontapi"
+
+ def ready(self):
+ from . import signals # noqa
diff --git a/src/pretix/storefrontapi/endpoints/__init__.py b/src/pretix/storefrontapi/endpoints/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/pretix/storefrontapi/endpoints/event.py b/src/pretix/storefrontapi/endpoints/event.py
new file mode 100644
index 0000000000..05324df644
--- /dev/null
+++ b/src/pretix/storefrontapi/endpoints/event.py
@@ -0,0 +1,400 @@
+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.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 RichtTextField(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 = RichtTextField()
+
+ 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
+
+ return {
+ "display_price": {
+ "net": opt_str(item_or_var.display_price.net),
+ "gross": opt_str(item_or_var.display_price.gross),
+ "tax_rate": opt_str(
+ item_or_var.display_price.rate
+ if not item.includes_mixed_tax_rate
+ else None
+ ),
+ "tax_name": opt_str(
+ item_or_var.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(item_or_var.suggested_price.net),
+ "gross": opt_str(item_or_var.suggested_price.gross),
+ "tax_rate": opt_str(
+ item_or_var.suggested_price.rate
+ if not item.includes_mixed_tax_rate
+ else None
+ ),
+ "tax_name": opt_str(
+ item_or_var.suggested_price.name
+ if not item.includes_mixed_tax_rate
+ else None
+ ),
+ },
+ "mandatory_priced_addons": item.mandatory_priced_addons,
+ "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": 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 = RichtTextField()
+ pricing = PricingField(source="*")
+ availability = AvailabilityField(source="*")
+
+ class Meta:
+ model = ItemVariation
+ fields = [
+ "id",
+ "value",
+ "description",
+ "pricing",
+ "availability",
+ ]
+
+
+class ItemSerializer(I18nFlattenedModelSerializer):
+ description = RichtTextField()
+ 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",
+ "free_price",
+ "available_variations",
+ "pricing",
+ "availability",
+ ]
+
+
+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"],
+ 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: waiting list info
+ # todo: has seating
+
+ 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: waiting list info
+ # todo: has seating
+ 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
+
+ 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)
diff --git a/src/pretix/storefrontapi/middleware.py b/src/pretix/storefrontapi/middleware.py
new file mode 100644
index 0000000000..2de595ce18
--- /dev/null
+++ b/src/pretix/storefrontapi/middleware.py
@@ -0,0 +1,134 @@
+#
+# 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 .
+#
+# 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
+# .
+#
+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?
+ return r
+ finally:
+ timemachine_now_var.set(None)
diff --git a/src/pretix/storefrontapi/permission.py b/src/pretix/storefrontapi/permission.py
new file mode 100644
index 0000000000..c51dd68a89
--- /dev/null
+++ b/src/pretix/storefrontapi/permission.py
@@ -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 .
+#
+# 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
+# .
+#
+
+from rest_framework.permissions import BasePermission
+
+
+class StorefrontEventPermission(BasePermission):
+
+ def has_permission(self, request, view):
+ # TODO: Check middleware results
+ return True
diff --git a/src/pretix/storefrontapi/serializers.py b/src/pretix/storefrontapi/serializers.py
new file mode 100644
index 0000000000..12e7396c05
--- /dev/null
+++ b/src/pretix/storefrontapi/serializers.py
@@ -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
+)
diff --git a/src/pretix/storefrontapi/signals.py b/src/pretix/storefrontapi/signals.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/pretix/storefrontapi/urls.py b/src/pretix/storefrontapi/urls.py
new file mode 100644
index 0000000000..7f38d687db
--- /dev/null
+++ b/src/pretix/storefrontapi/urls.py
@@ -0,0 +1,47 @@
+#
+# 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 .
+#
+# 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
+# .
+#
+import importlib
+
+from django.apps import apps
+from django.urls import include, re_path
+from rest_framework import routers
+
+from .endpoints import event
+
+storefront_orga_router = routers.DefaultRouter()
+storefront_orga_router.register(r"events", event.EventViewSet)
+
+storefront_event_router = routers.DefaultRouter()
+
+# 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[^/]+)/", include(storefront_orga_router.urls)),
+ re_path(
+ r"^organizers/(?P[^/]+)/events/(?P[^/]+)/",
+ include(storefront_event_router.urls),
+ ),
+]