mirror of
https://github.com/pretix/pretix.git
synced 2026-05-14 16:44:06 +00:00
* setup vite and integrate fully with django - vite starts with `python manage.py runserver` - add templatetags to simply load vite hmr and entry points - add eslint (recheck rules) - enable non-strict ts * better syntax for cors header setting * migrate checkin rules editor to vue3 - move constants to a module - move reading from and writing to non-vue html to django interop module - switch to composition api and script setup sfc with pug - use optional chaining operators a lot to simplify code * migrate webcheckin plugin to vite+vue3 - migrate vue sfcs to script setup and pug - move fetch calls into a api.ts module - move common formatting and i18n strings into module * fix migration error * first draft migrating widget to vue3/vite * first couple widget e2e tests courtesy of claude most of the tests don't work yet * test file is not actually used * drop widget_ prefix from e2e test fixtures * add test for complete widget journey for simple event * switch timezone in e2e tests to Europe/Berlin * make dates in e2e tests relative * migrate widget bugfix #5886 * start testing event series widget * working vite widget setup for prod (untested), local dev (with or without dev server) and pytests, with flags for running the old version or the vite version * simplify e2e test iframe check * less flaky e2e tests * top level await in iife build mode is not supported, so let's do import.meta.glob instead (we just need the build step not to see await, the code doesn't actually ever get loaded because it's DEV only) * fix inconsistencies from automatic migration * Allow gradual rollout of new vite-based widget by adding urls to an allowlist that gets checked against the "Origin" http header of request fetching the widget js * add e2e tests for widget button, testing empty cart, adding specific items, and subevents * remove janky claude testts again * resolve migration TODOs: properly refocus parent on navigations * use `npm run dev:control` for the vite dev server for admin components * upgrade npm dependencies * fix js linter errors * fix python linter errors * build all control vue components * add new js config files to check-manifest ignore * working prod build acutal serving of built assets not tested yet * fix templatetag paths to match what's in the vite mantifest * add missing quotes around 'unsafe-eval' cors value * remove now unused old vue2 tooling * try fixing e2e test ci * fix flake8 error * check if vite build artefacts are in the wheel * add license headers * remove dom manipilation code necessary for `div.pretix-widget-compat` to work. No longer needed for vue3 * remove superfluous `createElement` calls They might have been there because of IE, which is no longer relevant * make widget dev mode parametizable through query params and document the usage and those params * fix rst syntax * remove migration todos file Co-authored-by: luelista <mira@teamwiki.de> * rearrange dockerfile commands for smaller image, thanks @luelista * Update .gitignore, adding .vite Co-authored-by: luelista <mira@teamwiki.de> * add eslint CI * make vue dev work in plugins * fix docker build * rebuild vite setup to support static prod plugins and dynamic hmr plugin development * use toml for vite plugin config instead of standalone json file * Add widget changes from #6047, #6149 * Allow buttons to reuse cart (Z#23226853) * Always keep cart of buttons with items set * widget: handle cart if not same-site (#6149) --------- Co-authored-by: luelista <mira@teamwiki.de> Co-authored-by: Kara Engelhardt <engelhardt@pretix.eu>
907 lines
41 KiB
Python
907 lines
41 KiB
Python
#
|
||
# This file is part of pretix (Community Edition).
|
||
#
|
||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||
# Copyright (C) 2020-today pretix 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 calendar
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import time
|
||
from collections import defaultdict
|
||
from datetime import date, datetime, timedelta
|
||
from urllib.parse import urljoin
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import isoweek
|
||
from compressor.filters.jsmin import rJSMinFilter
|
||
from django.conf import settings
|
||
from django.contrib.staticfiles import finders
|
||
from django.core.cache import cache
|
||
from django.core.exceptions import BadRequest
|
||
from django.core.files.base import ContentFile, File
|
||
from django.core.files.storage import default_storage
|
||
from django.db.models import Q
|
||
from django.http import FileResponse, Http404, HttpResponse, JsonResponse
|
||
from django.template import Context, Engine
|
||
from django.template.loader import get_template
|
||
from django.utils.formats import date_format
|
||
from django.utils.timezone import now
|
||
from django.utils.translation import get_language, gettext, pgettext
|
||
from django.utils.translation.trans_real import DjangoTranslation
|
||
from django.views import View
|
||
from django.views.decorators.cache import cache_page
|
||
from django.views.decorators.gzip import gzip_page
|
||
from django.views.decorators.http import condition
|
||
from django.views.i18n import (
|
||
JavaScriptCatalog, builtin_template_path, get_formats,
|
||
)
|
||
from lxml import html
|
||
|
||
from pretix.base.context import get_powered_by
|
||
from pretix.base.i18n import language
|
||
from pretix.base.models import (
|
||
CartPosition, Event, ItemVariation, Quota, SubEvent, Voucher,
|
||
)
|
||
from pretix.base.services.cart import error_messages
|
||
from pretix.base.services.placeholders import PlaceholderContext
|
||
from pretix.base.settings import GlobalSettingsObject
|
||
from pretix.base.templatetags.rich_text import rich_text
|
||
from pretix.helpers.daterange import daterange
|
||
from pretix.helpers.thumb import get_thumbnail
|
||
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, filter_subevents_with_plugins,
|
||
weeks_for_template,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# we never change static source without restart, so we can cache this thread-wise
|
||
_source_cache_key = None
|
||
|
||
version_min = 2
|
||
version_max = 2
|
||
version_default = 2 # used for output in widget-embed-code
|
||
|
||
|
||
def _get_source_cache_key(version):
|
||
global _source_cache_key
|
||
checksum = hashlib.sha256()
|
||
if not _source_cache_key:
|
||
with open(finders.find("pretixbase/scss/_theme_variables.scss"), "r") as f:
|
||
checksum.update(f.read().encode())
|
||
|
||
template_path = 'pretixpresale/widget_dummy.html' if version == version_max else 'pretixpresale/widget_dummy.v{}.html'.format(version)
|
||
|
||
tpl = get_template(template_path)
|
||
et = html.fromstring(tpl.render()).xpath('/html/head/link')[0].attrib['href'].replace(settings.STATIC_URL, '')
|
||
checksum.update(et.encode())
|
||
_source_cache_key = checksum.hexdigest()[:12]
|
||
return _source_cache_key
|
||
|
||
|
||
def indent(s):
|
||
return s.replace('\n', '\n ')
|
||
|
||
|
||
def widget_css_etag(request, version, **kwargs):
|
||
if version > version_max:
|
||
return None
|
||
if version < version_min:
|
||
version = version_min
|
||
# This makes sure a new version of the theme is loaded whenever settings or the source files have changed
|
||
if hasattr(request, 'event'):
|
||
return (f'{_get_source_cache_key(version)}-'
|
||
f'{request.organizer.cache.get_or_set("css_version", default=lambda: int(time.time()))}-'
|
||
f'{request.event.cache.get_or_set("css_version", default=lambda: int(time.time()))}')
|
||
else:
|
||
return f'{_get_source_cache_key(version)}-{request.organizer.cache.get_or_set("css_version", default=lambda: int(time.time()))}'
|
||
|
||
|
||
def _use_vite(request):
|
||
if getattr(settings, 'PRETIX_WIDGET_VITE', False):
|
||
return True
|
||
origin = request.META.get('HTTP_ORIGIN', '')
|
||
gs = GlobalSettingsObject()
|
||
vite_origins = gs.settings.get('widget_vite_origins', as_type=str, default='')
|
||
if origin and vite_origins:
|
||
origins_list = [o.strip() for o in vite_origins.strip().splitlines() if o.strip()]
|
||
return origin in origins_list
|
||
return False
|
||
|
||
|
||
def widget_js_etag(request, version, lang, **kwargs):
|
||
gs = GlobalSettingsObject()
|
||
variant = 'vite' if _use_vite(request) else 'legacy'
|
||
return gs.settings.get('widget_checksum_{}_{}_{}'.format(version, lang, variant))
|
||
|
||
|
||
@gzip_page
|
||
@condition(etag_func=widget_css_etag)
|
||
@cache_page(60)
|
||
def widget_css(request, version, **kwargs):
|
||
if version > version_max:
|
||
raise Http404()
|
||
if version < version_min:
|
||
version = version_min
|
||
o = getattr(request, 'event', request.organizer)
|
||
|
||
template_path = 'pretixpresale/widget_dummy.html' if version == version_max else 'pretixpresale/widget_dummy.v{}.html'.format(version)
|
||
|
||
tpl = get_template(template_path)
|
||
et = html.fromstring(tpl.render()).xpath('/html/head/link')[0].attrib['href'].replace(settings.STATIC_URL, '')
|
||
with open(finders.find(et), 'r') as f:
|
||
widget_css = f.read()
|
||
|
||
theme_css = get_theme_vars_css(o, widget=True)
|
||
css = f"/* v{version} */\n" + theme_css + widget_css
|
||
|
||
resp = FileResponse(css, content_type='text/css')
|
||
resp._csp_ignore = True
|
||
resp['Access-Control-Allow-Origin'] = '*'
|
||
return resp
|
||
|
||
|
||
def generate_widget_js(version, lang, use_vite=False):
|
||
code = []
|
||
with language(lang):
|
||
# Provide isolation
|
||
code.append('(function (siteglobals) {\n')
|
||
code.append('var module = {}, exports = {};\n')
|
||
if use_vite:
|
||
code.append('const LANG = "%s";\n' % lang)
|
||
else:
|
||
code.append('var lang = "%s";\n' % lang)
|
||
|
||
c = JavaScriptCatalog()
|
||
c.translation = DjangoTranslation(lang, domain='djangojs')
|
||
catalog, plural = c.get_catalog(), c.get_plural()
|
||
|
||
str_wl = (
|
||
'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su',
|
||
'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
|
||
'September', 'October', 'November', 'December'
|
||
)
|
||
catalog = dict((k, v) for k, v in catalog.items() if k.startswith('widget\u0004') or k in str_wl)
|
||
with builtin_template_path("i18n_catalog.js").open(encoding="utf-8") as fh:
|
||
template = Engine().from_string(fh.read())
|
||
context = Context({
|
||
'catalog_str': indent(json.dumps(
|
||
catalog, sort_keys=True, indent=2)) if catalog else None,
|
||
'formats_str': indent(json.dumps(
|
||
get_formats(), sort_keys=True, indent=2)),
|
||
'plural': plural,
|
||
})
|
||
i18n_js = template.render(context)
|
||
code.append(i18n_js)
|
||
|
||
if use_vite:
|
||
vite_js = finders.find('vite/widget/widget.js')
|
||
if not vite_js:
|
||
raise FileNotFoundError('Vite widget build not found. Run: npm run build:widget')
|
||
with open(vite_js, 'r', encoding='utf-8') as fp:
|
||
code.append(fp.read())
|
||
else:
|
||
files = [
|
||
'vuejs/vue.js' if settings.DEBUG else 'vuejs/vue.min.js',
|
||
'pretixpresale/js/widget/docready.js',
|
||
'pretixpresale/js/widget/floatformat.js',
|
||
'pretixpresale/js/widget/widget.js' if version == version_max else 'pretixpresale/js/widget/widget.v{}.js'.format(version),
|
||
]
|
||
for fname in files:
|
||
f = finders.find(fname)
|
||
with open(f, 'r', encoding='utf-8') as fp:
|
||
code.append(fp.read())
|
||
|
||
if settings.DEBUG:
|
||
code.append('})(this);\n')
|
||
else:
|
||
# Do not expose debugging variables
|
||
code.append('})({});\n')
|
||
code = ''.join(code)
|
||
code = rJSMinFilter(content=code).output()
|
||
return f"/* v{version} */\n" + code
|
||
|
||
|
||
@gzip_page
|
||
@condition(etag_func=widget_js_etag)
|
||
def widget_js(request, version, lang, **kwargs):
|
||
if version > version_max or lang not in [lc for lc, ll in settings.LANGUAGES]:
|
||
raise Http404()
|
||
|
||
if version < version_min:
|
||
version = version_min
|
||
|
||
use_vite = _use_vite(request)
|
||
variant = 'vite' if use_vite else 'legacy'
|
||
cache_prefix = 'widget_js_data_v{}_{}_{}'.format(version, lang, variant)
|
||
|
||
cached_js = cache.get(cache_prefix)
|
||
if cached_js and not settings.DEBUG:
|
||
resp = HttpResponse(cached_js, content_type='text/javascript')
|
||
resp._csp_ignore = True
|
||
resp['Access-Control-Allow-Origin'] = '*'
|
||
return resp
|
||
|
||
settings_key = 'widget_file_v{}_{}_{}'.format(version, lang, variant)
|
||
checksum_key = 'widget_checksum_v{}_{}_{}'.format(version, lang, variant)
|
||
|
||
gs = GlobalSettingsObject()
|
||
fname = gs.settings.get(settings_key)
|
||
resp = None
|
||
if fname and not settings.DEBUG:
|
||
if isinstance(fname, File):
|
||
fname = fname.name
|
||
try:
|
||
data = default_storage.open(fname).read()
|
||
resp = HttpResponse(data, content_type='text/javascript')
|
||
cache.set(cache_prefix, data, 3600 * 4)
|
||
except:
|
||
logger.exception('Failed to open widget.js')
|
||
|
||
if not resp:
|
||
data = generate_widget_js(version, lang, use_vite=use_vite).encode()
|
||
checksum = hashlib.sha1(data).hexdigest()
|
||
if not settings.DEBUG:
|
||
newname = default_storage.save(
|
||
'widget/widget.{}.{}.{}.{}.js'.format(version, lang, variant, checksum),
|
||
ContentFile(data)
|
||
)
|
||
gs.settings.set(settings_key, 'file://' + newname)
|
||
gs.settings.set(checksum_key, checksum)
|
||
cache.set(cache_prefix, data, 3600 * 4)
|
||
resp = HttpResponse(data, content_type='text/javascript')
|
||
resp._csp_ignore = True
|
||
resp['Access-Control-Allow-Origin'] = '*'
|
||
return resp
|
||
|
||
|
||
def price_dict(item, price):
|
||
return {
|
||
'gross': price.gross,
|
||
'net': price.net,
|
||
'tax': price.tax,
|
||
'rate': price.rate,
|
||
'name': str(price.name),
|
||
'includes_mixed_tax_rate': item.includes_mixed_tax_rate,
|
||
}
|
||
|
||
|
||
def get_picture(event, picture, size=None):
|
||
thumb = None
|
||
if size:
|
||
try:
|
||
thumb = get_thumbnail(picture.name, size).thumb.url
|
||
except:
|
||
logger.exception(f'Failed to create thumbnail of {picture.name}')
|
||
if not thumb:
|
||
thumb = default_storage.url(picture.name)
|
||
return urljoin(build_absolute_uri(event, 'presale:event.index'), thumb)
|
||
|
||
|
||
class WidgetAPIProductList(EventListMixin, View):
|
||
|
||
def _get_items(self):
|
||
qs = self.request.event.items
|
||
if 'items' in self.request.GET:
|
||
qs = qs.filter(pk__in=[pk.strip() for pk in self.request.GET.get('items').split(",") if pk.strip().isdigit()])
|
||
if 'categories' in self.request.GET:
|
||
qs = qs.filter(category__pk__in=[pk.strip() for pk in self.request.GET.get('categories').split(",") if pk.strip().isdigit()])
|
||
variation_filter = None
|
||
if 'variations' in self.request.GET:
|
||
variation_filter = [int(pk.strip()) for pk in self.request.GET.get('variations').split(",") if pk.strip().isdigit()]
|
||
qs = qs.filter(
|
||
pk__in=ItemVariation.objects.filter(
|
||
item__event=self.request.event,
|
||
pk__in=variation_filter,
|
||
).values_list('item_id', flat=True)
|
||
)
|
||
|
||
items, display_add_to_cart = get_grouped_items(
|
||
self.request.event,
|
||
subevent=self.subevent,
|
||
voucher=self.voucher,
|
||
channel=self.request.sales_channel,
|
||
base_qs=qs,
|
||
require_seat=None,
|
||
memberships=(
|
||
self.request.customer.usable_memberships(
|
||
for_event=self.subevent or self.request.event,
|
||
testmode=self.request.event.testmode
|
||
) if getattr(self.request, 'customer', None) else None
|
||
),
|
||
)
|
||
|
||
grps = []
|
||
for cat, g in item_group_by_category([i for i in items if not i.requires_seat]):
|
||
grps.append({
|
||
'id': cat.pk if cat else None,
|
||
'name': str(cat.name) if cat else None,
|
||
'description': str(rich_text(cat.description, safelinks=False)) if cat and cat.description else None,
|
||
'items': [
|
||
{
|
||
'id': item.pk,
|
||
'name': str(item.name),
|
||
'picture': get_picture(self.request.event, item.picture, '60x60^') if item.picture else None,
|
||
'picture_fullsize': get_picture(self.request.event, item.picture) if item.picture else None,
|
||
'description': str(rich_text(item.description, safelinks=False)) if item.description else None,
|
||
'has_variations': item.has_variations,
|
||
'current_unavailability_reason': item.current_unavailability_reason,
|
||
'order_min': item.min_per_order,
|
||
'order_max': item.order_max if not item.has_variations else None,
|
||
'price': price_dict(item, item.display_price) if not item.has_variations else None,
|
||
'suggested_price': price_dict(item, item.suggested_price) if not item.has_variations else None,
|
||
'min_price': item.min_price if item.has_variations else None,
|
||
'max_price': item.max_price if item.has_variations else None,
|
||
'allow_waitinglist': item.allow_waitinglist,
|
||
'mandatory_priced_addons': item.mandatory_priced_addons,
|
||
'free_price': item.free_price,
|
||
'avail': [
|
||
item.cached_availability[0],
|
||
item.cached_availability[1] if item.do_show_quota_left else None
|
||
] if not item.has_variations else None,
|
||
'original_price': (
|
||
(item.original_price.net
|
||
if self.request.event.settings.display_net_prices
|
||
else item.original_price.gross)
|
||
if item.original_price else None
|
||
),
|
||
'variations': [
|
||
{
|
||
'id': var.id,
|
||
'value': str(var.value),
|
||
'order_max': var.order_max,
|
||
'description': str(rich_text(var.description, safelinks=False)) if var.description else None,
|
||
'price': price_dict(item, var.display_price),
|
||
'suggested_price': price_dict(item, var.suggested_price),
|
||
'original_price': (
|
||
(
|
||
var.original_price.net
|
||
if self.request.event.settings.display_net_prices
|
||
else var.original_price.gross
|
||
) if var.original_price else None
|
||
) or (
|
||
(
|
||
item.original_price.net
|
||
if self.request.event.settings.display_net_prices
|
||
else item.original_price.gross
|
||
) if item.original_price else None
|
||
),
|
||
'avail': [
|
||
var.cached_availability[0],
|
||
var.cached_availability[1] if item.do_show_quota_left else None
|
||
],
|
||
'current_unavailability_reason': var.current_unavailability_reason,
|
||
} for var in item.available_variations if (not variation_filter or var.id in variation_filter)
|
||
]
|
||
|
||
} for item in g
|
||
]
|
||
})
|
||
return grps, display_add_to_cart, len(items), items
|
||
|
||
def post_process(self, data):
|
||
data['poweredby'] = get_powered_by(self.request, safelink=False)
|
||
|
||
def response(self, data):
|
||
self.post_process(data)
|
||
resp = JsonResponse(data)
|
||
resp['Access-Control-Allow-Origin'] = '*'
|
||
resp._csp_ignore = True
|
||
return resp
|
||
|
||
def get(self, request, *args, **kwargs):
|
||
if not hasattr(request, 'event'):
|
||
return self._get_event_list(request, **kwargs)
|
||
|
||
if not request.event.live:
|
||
return self.response({
|
||
'error': gettext('This ticket shop is currently disabled.')
|
||
})
|
||
|
||
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 self.response({
|
||
'error': gettext('Tickets for this event cannot be purchased on this sales channel.')
|
||
})
|
||
|
||
self.subevent = None
|
||
if request.event.has_subevents:
|
||
if 'subevent' in kwargs:
|
||
self.subevent = request.event.subevents.filter(pk=kwargs['subevent'], active=True).first()
|
||
if not self.subevent:
|
||
return self.response({
|
||
'error': gettext('The selected date does not exist in this event series.')
|
||
})
|
||
|
||
# Prevent direct access to subevents that are hidden by a plugin
|
||
subevents = filter_subevents_with_plugins([self.subevent], request.sales_channel)
|
||
if self.subevent not in subevents:
|
||
return self.response({
|
||
'error': gettext('The selected date is not available.')
|
||
})
|
||
|
||
else:
|
||
return self._get_event_list(request, **kwargs)
|
||
else:
|
||
if 'subevent' in kwargs:
|
||
return self.response({
|
||
'error': gettext('This is not an event series.')
|
||
})
|
||
return self._get_event_view(request, **kwargs)
|
||
|
||
def dispatch(self, request, *args, **kwargs):
|
||
o = getattr(request, 'event', request.organizer)
|
||
if 'lang' in request.GET and request.GET.get('lang') in [lc for lc, ll in settings.LANGUAGES]:
|
||
with language(request.GET.get('lang'), o.settings.region):
|
||
return self.get(request, **kwargs)
|
||
else:
|
||
return self.get(request, **kwargs)
|
||
|
||
def _get_availability(self, ev, event, tz=None):
|
||
availability = {}
|
||
if ev.presale_is_running and event.settings.event_list_availability:
|
||
if ev.best_availability_state == Quota.AVAILABILITY_OK:
|
||
if ev.best_availability_is_low:
|
||
availability['color'] = 'green'
|
||
availability['text'] = gettext('Few tickets left')
|
||
availability['reason'] = 'low'
|
||
else:
|
||
availability['color'] = 'green'
|
||
if ev.has_paid_item:
|
||
availability['text'] = pgettext('available_event_in_list', 'Buy now')
|
||
else:
|
||
availability['text'] = gettext('Book now')
|
||
availability['reason'] = 'ok'
|
||
elif ev.waiting_list_active and (ev.best_availability_state is not None and ev.best_availability_state >= 0):
|
||
availability['color'] = 'orange'
|
||
availability['text'] = gettext('Waiting list')
|
||
availability['reason'] = 'waitinglist'
|
||
elif ev.best_availability_state == Quota.AVAILABILITY_RESERVED:
|
||
availability['color'] = 'red'
|
||
availability['text'] = gettext('Reserved')
|
||
availability['reason'] = 'reserved'
|
||
elif ev.best_availability_state is not None and ev.best_availability_state < Quota.AVAILABILITY_RESERVED:
|
||
availability['color'] = 'red'
|
||
if ev.has_paid_item:
|
||
availability['text'] = gettext('Sold out')
|
||
else:
|
||
availability['text'] = gettext('Fully booked')
|
||
availability['reason'] = 'full'
|
||
else: # unknown / no product
|
||
availability['color'] = 'none'
|
||
availability['text'] = gettext('More info')
|
||
availability['reason'] = 'unknown'
|
||
elif ev.presale_is_running:
|
||
availability['color'] = 'green'
|
||
availability['text'] = gettext('Book now')
|
||
availability['reason'] = 'ok'
|
||
elif ev.presale_has_ended:
|
||
availability['color'] = 'red'
|
||
availability['text'] = gettext('Sale over')
|
||
availability['reason'] = 'over'
|
||
elif event.settings.presale_start_show_date and ev.effective_presale_start:
|
||
availability['color'] = 'orange'
|
||
availability['text'] = gettext('from %(start_date)s') % {
|
||
'start_date': date_format(ev.effective_presale_start.astimezone(tz or event.timezone),
|
||
"SHORT_DATE_FORMAT")
|
||
}
|
||
availability['reason'] = 'soon'
|
||
else:
|
||
availability['color'] = 'orange'
|
||
availability['text'] = gettext('Soon')
|
||
availability['reason'] = 'soon'
|
||
return availability
|
||
|
||
def _get_date_range(self, ev, event, tz=None):
|
||
tz = tz or event.timezone
|
||
dr = ev.get_date_range_display(tz)
|
||
if event.settings.show_times:
|
||
dr += " " + date_format(ev.date_from.astimezone(tz), "TIME_FORMAT")
|
||
if event.settings.show_date_to and ev.date_to and ev.date_from.astimezone(tz).date() == ev.date_to.astimezone(tz).date():
|
||
dr += " – " + date_format(ev.date_to.astimezone(tz), "TIME_FORMAT")
|
||
return dr
|
||
|
||
def _serialize_events(self, ebd):
|
||
events = []
|
||
for e in ebd:
|
||
ev = e['event']
|
||
if isinstance(ev, SubEvent):
|
||
event = ev.event
|
||
else:
|
||
event = ev
|
||
tz = ZoneInfo(e['timezone'])
|
||
time = date_format(ev.date_from.astimezone(tz), 'TIME_FORMAT') if e.get('time') and event.settings.show_times else None
|
||
if time and ev.date_to and ev.date_from.astimezone(tz).date() == ev.date_to.astimezone(tz).date() and event.settings.show_date_to:
|
||
time += ' – ' + date_format(ev.date_to.astimezone(tz), 'TIME_FORMAT')
|
||
events.append({
|
||
'name': str(ev.name),
|
||
'time': time,
|
||
'continued': e['continued'],
|
||
'location': str(ev.location),
|
||
'date_range': self._get_date_range(ev, event, tz=tz),
|
||
'availability': self._get_availability(ev, event, tz=tz),
|
||
'event_url': build_absolute_uri(event, 'presale:event.index'),
|
||
'subevent': ev.pk if isinstance(ev, SubEvent) else None,
|
||
})
|
||
return events
|
||
|
||
def _get_event_list(self, request, **kwargs):
|
||
data = {}
|
||
o = getattr(request, 'event', request.organizer)
|
||
list_type = self.request.GET.get("style", o.settings.event_list_type)
|
||
data['list_type'] = list_type
|
||
data['meta_filter_fields'] = [
|
||
{**v, "key": k} for k, v in meta_filtersets(request.organizer, getattr(request, 'event', None)).items()
|
||
]
|
||
|
||
if hasattr(self.request, 'event') and data['list_type'] not in ("calendar", "week"):
|
||
# only allow list-view of more than 50 subevents if ordering is by date as this can be done in the database
|
||
# ordering by name is currently not supported in database due to I18NField-JSON
|
||
ordering = self.request.event.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
|
||
if ordering not in ("date_ascending", "date_descending") and self.request.event.subevents.filter(date_from__gt=now()).count() > 50:
|
||
data['list_type'] = list_type = 'calendar'
|
||
|
||
if hasattr(self.request, 'event'):
|
||
data['name'] = str(request.event.name)
|
||
data['frontpage_text'] = str(rich_text(request.event.settings.frontpage_text, safelinks=False))
|
||
|
||
cache_key = ':'.join([
|
||
'widget.py',
|
||
'eventlist',
|
||
request.organizer.slug,
|
||
request.event.slug if hasattr(request, 'event') else '-',
|
||
list_type,
|
||
request.GET.urlencode(),
|
||
get_language(),
|
||
])
|
||
cached_data = cache.get(cache_key)
|
||
if cached_data:
|
||
return self.response(cached_data)
|
||
|
||
if list_type == "calendar":
|
||
self._set_month_year()
|
||
_, ndays = calendar.monthrange(self.year, self.month)
|
||
|
||
data['date'] = date(self.year, self.month, 1)
|
||
if hasattr(self.request, 'event'):
|
||
tz = self.request.event.timezone
|
||
else:
|
||
tz = self.request.organizer.timezone
|
||
before = datetime(self.year, self.month, 1, 0, 0, 0, tzinfo=tz) - timedelta(days=1)
|
||
after = datetime(self.year, self.month, ndays, 0, 0, 0, tzinfo=tz) + timedelta(days=1)
|
||
if hasattr(self.request, 'event') and self.request.event.settings.event_calendar_future_only:
|
||
limit_before = min(after, now().astimezone(tz))
|
||
else:
|
||
limit_before = before
|
||
|
||
ebd = defaultdict(list)
|
||
|
||
if hasattr(self.request, 'event'):
|
||
add_subevents_for_days(
|
||
filter_qs_by_attr(
|
||
self.request.event.subevents_annotated(self.request.sales_channel).filter(
|
||
Q(event__all_sales_channels=True) |
|
||
Q(event__limit_sales_channels=self.request.sales_channel),
|
||
), self.request
|
||
),
|
||
before=limit_before, after=after, ebd=ebd, timezones=set(), event=self.request.event,
|
||
cart_namespace=kwargs.get('cart_namespace'),
|
||
sales_channel=self.request.sales_channel,
|
||
)
|
||
else:
|
||
timezones = set()
|
||
add_events_for_days(
|
||
self.request,
|
||
filter_qs_by_attr(
|
||
Event.annotated(self.request.organizer.events, self.request.sales_channel).filter(
|
||
Q(all_sales_channels=True) | Q(limit_sales_channels=self.request.sales_channel),
|
||
), self.request
|
||
),
|
||
before=limit_before,
|
||
after=after,
|
||
ebd=ebd,
|
||
timezones=timezones,
|
||
)
|
||
add_subevents_for_days(
|
||
filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||
Q(event__all_sales_channels=True) |
|
||
Q(event__limit_sales_channels__identifier=self.request.sales_channel.identifier),
|
||
event__organizer=self.request.organizer,
|
||
event__is_public=True,
|
||
event__live=True,
|
||
).prefetch_related(
|
||
'event___settings_objects', 'event__organizer___settings_objects'
|
||
), self.request.sales_channel), self.request),
|
||
before=limit_before,
|
||
after=after,
|
||
ebd=ebd,
|
||
timezones=timezones,
|
||
sales_channel=self.request.sales_channel,
|
||
)
|
||
|
||
data['weeks'] = weeks_for_template(ebd, self.year, self.month)
|
||
for w in data['weeks']:
|
||
for d in w:
|
||
if not d:
|
||
continue
|
||
d['events'] = self._serialize_events(d['events'] or [])
|
||
elif list_type == "week":
|
||
self._set_week_year()
|
||
|
||
if hasattr(self.request, 'event'):
|
||
tz = self.request.event.timezone
|
||
else:
|
||
tz = self.request.organizer.timezone
|
||
|
||
week = isoweek.Week(self.year, self.week)
|
||
data['week'] = [self.year, self.week]
|
||
before = datetime(
|
||
week.monday().year, week.monday().month, week.monday().day, 0, 0, 0, tzinfo=tz
|
||
) - timedelta(days=1)
|
||
after = datetime(
|
||
week.sunday().year, week.sunday().month, week.sunday().day, 0, 0, 0, tzinfo=tz
|
||
) + timedelta(days=1)
|
||
|
||
if hasattr(self.request, 'event') and self.request.event.settings.event_calendar_future_only:
|
||
limit_before = now().astimezone(tz)
|
||
else:
|
||
limit_before = before
|
||
|
||
ebd = defaultdict(list)
|
||
if hasattr(self.request, 'event'):
|
||
add_subevents_for_days(
|
||
filter_qs_by_attr(self.request.event.subevents_annotated(self.request.sales_channel), self.request),
|
||
before=limit_before, after=after, ebd=ebd, timezones=set(), event=self.request.event,
|
||
cart_namespace=kwargs.get('cart_namespace'),
|
||
sales_channel=self.request.sales_channel,
|
||
)
|
||
else:
|
||
timezones = set()
|
||
add_events_for_days(
|
||
self.request,
|
||
filter_qs_by_attr(Event.annotated(self.request.organizer.events, self.request.sales_channel), self.request),
|
||
limit_before, after, ebd, timezones
|
||
)
|
||
add_subevents_for_days(
|
||
filter_qs_by_attr(SubEvent.annotated(SubEvent.objects.filter(
|
||
event__organizer=self.request.organizer,
|
||
event__is_public=True,
|
||
event__live=True,
|
||
).prefetch_related(
|
||
'event___settings_objects', 'event__organizer___settings_objects'
|
||
), self.request.sales_channel), self.request),
|
||
before=limit_before,
|
||
after=after,
|
||
ebd=ebd,
|
||
timezones=timezones,
|
||
sales_channel=self.request.sales_channel,
|
||
)
|
||
|
||
data['days'] = days_for_template(ebd, week)
|
||
for d in data['days']:
|
||
d['events'] = self._serialize_events(d['events'] or [])
|
||
else:
|
||
try:
|
||
offset = int(self.request.GET.get("offset", 0))
|
||
except ValueError:
|
||
raise BadRequest('GET parameter "offset" must be an integer.')
|
||
limit = 50
|
||
if hasattr(self.request, 'event'):
|
||
evs = filter_qs_by_attr(
|
||
self.request.event.subevents_annotated(self.request.sales_channel),
|
||
self.request,
|
||
match_subevents_with_conditions=(
|
||
Q(Q(date_to__isnull=True) & Q(date_from__gte=now() - timedelta(hours=24)))
|
||
| Q(date_to__gte=now() - timedelta(hours=24))
|
||
),
|
||
)
|
||
evs = self.request.event.subevents_sorted(evs)
|
||
ordering = self.request.event.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
|
||
data['has_more_events'] = False
|
||
if ordering in ("date_ascending", "date_descending"):
|
||
# fetch one more result than needed to check if more events exist
|
||
evs = list(evs[offset:offset + limit + 1])
|
||
if len(evs) > limit:
|
||
data['has_more_events'] = True
|
||
evs = evs[:limit]
|
||
|
||
tz = request.event.timezone
|
||
if self.request.event.settings.event_list_available_only:
|
||
evs = [
|
||
se for se in evs
|
||
if not se.presale_has_ended and (
|
||
se.best_availability_state is not None and
|
||
se.best_availability_state >= Quota.AVAILABILITY_RESERVED
|
||
)
|
||
]
|
||
|
||
data['events'] = [
|
||
{
|
||
'name': str(ev.name),
|
||
'location': str(ev.location),
|
||
'date_range': self._get_date_range(ev, ev.event, tz),
|
||
'availability': self._get_availability(ev, ev.event, tz=tz),
|
||
'event_url': build_absolute_uri(ev.event, 'presale:event.index'),
|
||
'subevent': ev.pk,
|
||
} for ev in evs
|
||
]
|
||
else:
|
||
data['events'] = []
|
||
qs = self._get_event_list_queryset()
|
||
for event in qs:
|
||
tz = ZoneInfo(event.cache.get_or_set('timezone', lambda: event.settings.timezone))
|
||
if event.has_subevents:
|
||
dr = daterange(
|
||
event.min_from.astimezone(tz),
|
||
(event.max_fromto or event.max_to or event.max_from).astimezone(tz)
|
||
)
|
||
avail = {'color': 'none', 'text': gettext('Event series')}
|
||
else:
|
||
dr = self._get_date_range(event, event, tz)
|
||
avail = self._get_availability(event, event, tz=tz)
|
||
data['events'].append({
|
||
'name': str(event.name),
|
||
'location': str(event.location),
|
||
'date_range': dr,
|
||
'availability': avail,
|
||
'event_url': build_absolute_uri(event, 'presale:event.index'),
|
||
})
|
||
|
||
cache.set(cache_key, data, 30)
|
||
# These pages are cached for a really short duration – this should make them pretty accurate, while still
|
||
# providing some protection against burst traffic.
|
||
return self.response(data)
|
||
|
||
def _get_event_view(self, request, **kwargs):
|
||
cache_key = ':'.join([
|
||
'widget.py',
|
||
'event',
|
||
request.organizer.slug,
|
||
request.event.slug,
|
||
str(self.subevent.pk) if self.subevent else "",
|
||
request.GET.urlencode(),
|
||
get_language(),
|
||
request.sales_channel.identifier,
|
||
])
|
||
if "cart_id" not in request.GET:
|
||
cached_data = cache.get(cache_key)
|
||
if cached_data:
|
||
return self.response(cached_data)
|
||
|
||
data = {
|
||
'target_url': build_absolute_uri(request.event, 'presale:event.index'),
|
||
'subevent': self.subevent.pk if self.subevent else None,
|
||
'currency': request.event.currency,
|
||
'display_net_prices': request.event.settings.display_net_prices,
|
||
'use_native_spinners': request.event.settings.widget_use_native_spinners,
|
||
'show_variations_expanded': request.event.settings.show_variations_expanded,
|
||
'waiting_list_enabled': (self.subevent or request.event).waiting_list_active,
|
||
'voucher_explanation_text': str(rich_text(request.event.settings.voucher_explanation_text, safelinks=False)),
|
||
'error': None,
|
||
'cart_exists': False
|
||
}
|
||
|
||
if 'cart_id' in request.GET and CartPosition.objects.filter(event=request.event, cart_id=request.GET.get('cart_id')).exists():
|
||
data['cart_exists'] = True
|
||
|
||
ev = self.subevent or request.event
|
||
data['name'] = str(ev.name)
|
||
|
||
templating_context = PlaceholderContext(event_or_subevent=ev, event=request.event)
|
||
if self.subevent:
|
||
data['frontpage_text'] = str(rich_text(templating_context.format(str(self.subevent.frontpage_text)), safelinks=False))
|
||
data['location'] = str(rich_text(self.subevent.location, safelinks=False))
|
||
else:
|
||
data['frontpage_text'] = str(rich_text(templating_context.format(str(request.event.settings.frontpage_text)), safelinks=False))
|
||
data['location'] = str(rich_text(request.event.location, safelinks=False))
|
||
data['date_range'] = self._get_date_range(ev, request.event)
|
||
fail = False
|
||
|
||
vouchers_exist = self.request.event.get_cache().get('vouchers_exist')
|
||
if vouchers_exist is None:
|
||
vouchers_exist = self.request.event.vouchers.exists()
|
||
self.request.event.get_cache().set('vouchers_exist', vouchers_exist)
|
||
data['vouchers_exist'] = vouchers_exist
|
||
|
||
if not ev.presale_is_running:
|
||
data['vouchers_exist'] = False
|
||
if ev.presale_has_ended:
|
||
if request.event.settings.presale_has_ended_text:
|
||
data['error'] = str(request.event.settings.presale_has_ended_text)
|
||
else:
|
||
data['error'] = gettext('The booking period for this event is over.')
|
||
elif request.event.settings.presale_start_show_date:
|
||
data['error'] = gettext('The booking period for this event will start on %(date)s at %(time)s.') % {
|
||
'date': date_format(ev.effective_presale_start.astimezone(request.event.timezone), "SHORT_DATE_FORMAT"),
|
||
'time': date_format(ev.effective_presale_start.astimezone(request.event.timezone), "TIME_FORMAT"),
|
||
}
|
||
else:
|
||
data['error'] = gettext('The booking period for this event has not yet started.')
|
||
|
||
self.voucher = None
|
||
if 'voucher' in request.GET:
|
||
try:
|
||
self.voucher = request.event.vouchers.get(code__iexact=request.GET.get('voucher').strip())
|
||
if self.voucher.redeemed >= self.voucher.max_usages:
|
||
data['error'] = error_messages['voucher_redeemed']
|
||
fail = True
|
||
if self.voucher.valid_until is not None and self.voucher.valid_until < now():
|
||
data['error'] = error_messages['voucher_expired']
|
||
fail = True
|
||
|
||
cart_id = get_or_create_cart_id(request, create=False)
|
||
if cart_id:
|
||
redeemed_in_carts = CartPosition.objects.filter(
|
||
Q(voucher=self.voucher) & Q(event=request.event) &
|
||
(Q(expires__gte=now()) | Q(cart_id=get_or_create_cart_id(request)))
|
||
)
|
||
else:
|
||
redeemed_in_carts = CartPosition.objects.filter(
|
||
Q(voucher=self.voucher) & Q(event=request.event) & Q(expires__gte=now())
|
||
)
|
||
v_avail = self.voucher.max_usages - self.voucher.redeemed - redeemed_in_carts.count()
|
||
|
||
if v_avail < 1:
|
||
data['error'] = error_messages['voucher_redeemed']
|
||
fail = True
|
||
except Voucher.DoesNotExist:
|
||
data['error'] = error_messages['voucher_invalid']
|
||
fail = True
|
||
|
||
if not fail and (ev.presale_is_running or request.event.settings.show_items_outside_presale_period):
|
||
data['items_by_category'], data['display_add_to_cart'], data['itemnum'], items = self._get_items()
|
||
data['display_add_to_cart'] = data['display_add_to_cart'] and ev.presale_is_running
|
||
else:
|
||
items = []
|
||
data['items_by_category'] = []
|
||
data['display_add_to_cart'] = False
|
||
data['itemnum'] = 0
|
||
data['vouchers_exist'] = False
|
||
|
||
data['has_seating_plan'] = ev.seating_plan is not None
|
||
data['has_seating_plan_waitinglist'] = False
|
||
if ev.waiting_list_active and ev.presale_is_running:
|
||
for i in items:
|
||
if not i.allow_waitinglist or not i.requires_seat:
|
||
continue
|
||
|
||
if i.has_variations:
|
||
for v in i.available_variations:
|
||
if v.cached_availability[0] != Quota.AVAILABILITY_OK:
|
||
data['has_seating_plan_waitinglist'] = True
|
||
break
|
||
else:
|
||
if i.cached_availability[0] != Quota.AVAILABILITY_OK:
|
||
data['has_seating_plan_waitinglist'] = True
|
||
break
|
||
|
||
if "cart_id" not in request.GET:
|
||
cache.set(cache_key, data, 10)
|
||
# These pages are cached for a really short duration – this should make them pretty accurate with
|
||
# regards to availability display, while still providing some protection against burst traffic.
|
||
return self.response(data)
|