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