diff --git a/doc/user/events/widget.rst b/doc/user/events/widget.rst index d5c0d1c4b8..ee7ce082ee 100644 --- a/doc/user/events/widget.rst +++ b/doc/user/events/widget.rst @@ -136,10 +136,15 @@ If you want to include all your public events, you can just reference your organ -There is an optional ``style`` parameter that let's you choose between a calendar view and a list view. If you do not set it, the choice will be taken from your organizer settings:: +There is an optional ``style`` parameter that let's you choose between a monthly calendar view, a week view and a list +view. If you do not set it, the choice will be taken from your organizer settings:: + + +If you have more than 100 events, the system might refuse to show a list view and always show a calendar for performance +reasons instead. You can see an example here: diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 0beeb6ef57..512a60d75e 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -886,14 +886,16 @@ DEFAULTS = { 'serializer_kwargs': dict( choices=( ('list', _('List')), - ('calendar', _('Calendar')) + ('week', _('Week calendar')), + ('calendar', _('Month calendar')), ) ), 'form_kwargs': dict( label=_('Default overview style'), choices=( ('list', _('List')), - ('calendar', _('Calendar')) + ('week', _('Week calendar')), + ('calendar', _('Month calendar')), ) ), }, diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 879af6091a..4e36bf8a98 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -290,7 +290,8 @@ class OrganizerSettingsForm(SettingsForm): label=_('Default overview style'), choices=( ('list', _('List')), - ('calendar', _('Calendar')) + ('week', _('Week calendar')), + ('calendar', _('Month calendar')), ) ) event_list_availability = forms.BooleanField( diff --git a/src/pretix/helpers/formats/de/__init__.py b/src/pretix/helpers/formats/de/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/pretix/helpers/formats/de/formats.py b/src/pretix/helpers/formats/de/formats.py new file mode 100644 index 0000000000..1e70e3235e --- /dev/null +++ b/src/pretix/helpers/formats/de/formats.py @@ -0,0 +1,3 @@ +# Date according to https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date +WEEK_FORMAT = '\\K\\W W/o' +WEEK_DAY_FORMAT = 'D, j.n.' diff --git a/src/pretix/helpers/formats/en/formats.py b/src/pretix/helpers/formats/en/formats.py index 28f35153b5..3d5fc61a93 100644 --- a/src/pretix/helpers/formats/en/formats.py +++ b/src/pretix/helpers/formats/en/formats.py @@ -2,3 +2,5 @@ SHORT_DATE_FORMAT = 'Y-m-d' SHORT_DATETIME_FORMAT = 'Y-m-d H:i' TIME_FORMAT = 'H:i' +WEEK_FORMAT = '\\W W, o' +WEEK_DAY_FORMAT = 'D, M jS' diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_calendar_week.html b/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_calendar_week.html new file mode 100644 index 0000000000..11fe51b344 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_subevent_calendar_week.html @@ -0,0 +1,42 @@ +{% load i18n %} +{% load eventurl %} +{% load urlreplace %} +
+ {% for f, v in request.GET.items %} + {% if f != "week" and f != "year" %} + + {% endif %} + {% endfor %} +
+ +
+ + + +
+ +
+
+{% include "pretixpresale/fragment_week_calendar.html" with show_avail=event.settings.event_list_availability %} diff --git a/src/pretix/presale/templates/pretixpresale/event/index.html b/src/pretix/presale/templates/pretixpresale/event/index.html index d82b2298aa..b63f7816c2 100644 --- a/src/pretix/presale/templates/pretixpresale/event/index.html +++ b/src/pretix/presale/templates/pretixpresale/event/index.html @@ -117,6 +117,8 @@
{% if list_type == "calendar" %} {% include "pretixpresale/event/fragment_subevent_calendar.html" %} + {% elif list_type == "week" %} + {% include "pretixpresale/event/fragment_subevent_calendar_week.html" %} {% else %} {% include "pretixpresale/event/fragment_subevent_list.html" %} {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/fragment_calendar.html b/src/pretix/presale/templates/pretixpresale/fragment_calendar.html index 3b54aaa0ab..30d591d1c3 100644 --- a/src/pretix/presale/templates/pretixpresale/fragment_calendar.html +++ b/src/pretix/presale/templates/pretixpresale/fragment_calendar.html @@ -22,16 +22,44 @@

{{ day.day }}

{% for event in day.events %} - - - {{ event.event.name }} - + {% if show_names|default_if_none:True %} + + {{ event.event.name }} + + {% endif %} {% if not event.continued %} {% if event.time %} - - {{ event.time|date:"TIME_FORMAT" }} + + {% if not show_names|default_if_none:True %} + + {% endif %} + {{ event.time|date:"TIME_FORMAT" }} + {% if not show_names|default_if_none:True %} + + {% endif %} {% if multiple_timezones %} {{ event.timezone }} {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/fragment_week_calendar.html b/src/pretix/presale/templates/pretixpresale/fragment_week_calendar.html new file mode 100644 index 0000000000..c10a5385b3 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/fragment_week_calendar.html @@ -0,0 +1,82 @@ +{% load i18n %} + diff --git a/src/pretix/presale/templates/pretixpresale/organizers/calendar.html b/src/pretix/presale/templates/pretixpresale/organizers/calendar.html index 3c8467b0e1..872cbf732a 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/calendar.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/calendar.html @@ -24,11 +24,16 @@ {% trans "List" %} + + + {% trans "Week" %} + - {% trans "Calendar" %} + {% trans "Month" %}
+ {{ organizer_homepage_text | rich_text }} +
+ {% endif %} +

{{ date|date:"F Y" }}

+
+ {% for f, v in request.GET.items %} + {% if f != "week" and f != "year" %} + + {% endif %} + {% endfor %} +
+ +
+ + + +
+ +
+ {% for f, v in request.GET.items %} + {% if f != "month" and f != "year" %} + + {% endif %} + {% endfor %} +
+ {% include "pretixpresale/fragment_week_calendar.html" with show_avail=request.organizer.settings.event_list_availability %} + + {% if multiple_timezones %} +
+ {% blocktrans trimmed %} + Note that the events in this view are in different timezones. + {% endblocktrans %} +
+ {% endif %} +{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/organizers/index.html b/src/pretix/presale/templates/pretixpresale/organizers/index.html index c34dff1f76..b4bbcd842f 100644 --- a/src/pretix/presale/templates/pretixpresale/organizers/index.html +++ b/src/pretix/presale/templates/pretixpresale/organizers/index.html @@ -33,10 +33,15 @@ {% trans "List" %} + + + {% trans "Week" %} + - {% trans "Calendar" %} + {% trans "Month" %} 100: + if self.request.event.settings.event_list_type not in ("calendar", "week"): + self.request.event.settings.event_list_type = "calendar" + context['list_type'] = "calendar" if context['list_type'] == "calendar" and self.request.event.has_subevents: self._set_month_year() @@ -389,9 +396,44 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): kwargs.get('cart_namespace') ) + context['show_names'] = ebd.get('_subevents_different_names', False) or sum( + len(i) for i in ebd.values() if isinstance(i, list) + ) < 2 context['weeks'] = weeks_for_template(ebd, self.year, self.month) context['months'] = [date(self.year, i + 1, 1) for i in range(12)] context['years'] = range(now().year - 2, now().year + 3) + elif context['list_type'] == "week" and self.request.event.has_subevents: + self._set_week_year() + tz = pytz.timezone(self.request.event.settings.timezone) + week = isoweek.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) + + context['date'] = week.monday() + context['before'] = before + context['after'] = after + + ebd = defaultdict(list) + add_subevents_for_days( + filter_qs_by_attr(self.request.event.subevents_annotated(self.request.sales_channel.identifier).using(settings.DATABASE_REPLICA), self.request), + before, after, ebd, set(), self.request.event, + kwargs.get('cart_namespace') + ) + + context['show_names'] = ebd.get('_subevents_different_names', False) or sum( + len(i) for i in ebd.values() if isinstance(i, list) + ) < 2 + context['days'] = days_for_template(ebd, week) + context['weeks'] = [date(self.year, i + 1, 1) for i in range(12)] + context['weeks'] = [i + 1 for i in range(53)] + context['years'] = range(now().year - 2, now().year + 3) + context['week_format'] = get_format('WEEK_FORMAT') + if context['week_format'] == 'WEEK_FORMAT': + context['week_format'] = WEEK_FORMAT elif self.request.event.has_subevents: context['subevent_list'] = self.request.event.subevents_sorted( filter_qs_by_attr(self.request.event.subevents_annotated(self.request.sales_channel.identifier).using(settings.DATABASE_REPLICA), self.request) diff --git a/src/pretix/presale/views/organizer.py b/src/pretix/presale/views/organizer.py index f8bf377e66..cfd2c34869 100644 --- a/src/pretix/presale/views/organizer.py +++ b/src/pretix/presale/views/organizer.py @@ -2,12 +2,14 @@ import calendar from collections import defaultdict from datetime import date, datetime, timedelta +import isoweek import pytz from django.conf import settings from django.db.models import Exists, Max, Min, OuterRef, Q from django.db.models.functions import Coalesce, Greatest from django.http import Http404, HttpResponse from django.utils.decorators import method_decorator +from django.utils.formats import date_format, get_format from django.utils.timezone import now from django.views import View from django.views.decorators.cache import cache_page @@ -20,6 +22,7 @@ from pretix.base.models import ( ) from pretix.base.services.quotas import QuotaAvailability from pretix.helpers.daterange import daterange +from pretix.helpers.formats.de.formats import WEEK_FORMAT from pretix.multidomain.urlreverse import eventreverse from pretix.presale.ical import get_ical from pretix.presale.views import OrganizerViewMixin @@ -193,6 +196,72 @@ class EventListMixin: else: self._set_month_to_next_event() + def _set_week_to_next_subevent(self): + tz = pytz.timezone(self.request.event.settings.timezone) + next_sev = self.request.event.subevents.using(settings.DATABASE_REPLICA).filter( + active=True, + is_public=True, + date_from__gte=now() + ).select_related('event').order_by('date_from').first() + + if next_sev: + datetime_from = next_sev.date_from + self.year = datetime_from.astimezone(tz).isocalendar()[0] + self.week = datetime_from.astimezone(tz).isocalendar()[1] + else: + self.year = now().isocalendar()[0] + self.week = now().isocalendar()[1] + + def _set_week_to_next_event(self): + next_ev = filter_qs_by_attr(Event.objects.using(settings.DATABASE_REPLICA).filter( + organizer=self.request.organizer, + live=True, + is_public=True, + date_from__gte=now(), + has_subevents=False + ), self.request).order_by('date_from').first() + next_sev = filter_qs_by_attr(SubEvent.objects.using(settings.DATABASE_REPLICA).filter( + event__organizer=self.request.organizer, + event__is_public=True, + event__live=True, + active=True, + is_public=True, + date_from__gte=now() + ), self.request).select_related('event').order_by('date_from').first() + + datetime_from = None + if (next_ev and next_sev and next_sev.date_from < next_ev.date_from) or (next_sev and not next_ev): + datetime_from = next_sev.date_from + next_ev = next_sev.event + elif next_ev: + datetime_from = next_ev.date_from + + if datetime_from: + tz = pytz.timezone(next_ev.settings.timezone) + self.year = datetime_from.astimezone(tz).isocalendar()[0] + self.week = datetime_from.astimezone(tz).isocalendar()[1] + else: + self.year = now().isocalendar()[0] + self.week = now().isocalendar()[1] + + def _set_week_year(self): + if hasattr(self.request, 'event') and self.subevent: + tz = pytz.timezone(self.request.event.settings.timezone) + self.year = self.subevent.date_from.astimezone(tz).year + self.month = self.subevent.date_from.astimezone(tz).month + if 'year' in self.request.GET and 'week' in self.request.GET: + try: + self.year = int(self.request.GET.get('year')) + self.week = int(self.request.GET.get('week')) + except ValueError: + self.year = now().isocalendar()[0] + self.week = now().isocalendar()[1] + else: + if hasattr(self.request, 'event'): + self._set_week_to_next_subevent() + else: + self._set_week_to_next_event() + class OrganizerIndex(OrganizerViewMixin, EventListMixin, ListView): model = Event @@ -206,6 +275,10 @@ class OrganizerIndex(OrganizerViewMixin, EventListMixin, ListView): cv = CalendarView() cv.request = request return cv.get(request, *args, **kwargs) + elif style == "week": + cv = WeekCalendarView() + cv.request = request + return cv.get(request, *args, **kwargs) else: return super().get(request, *args, **kwargs) @@ -281,6 +354,7 @@ def add_subevents_for_days(qs, before, after, ebd, timezones, event=None, cart_n if not q.cache_is_hot(now() + timedelta(seconds=5)) ] + name = None if quotas_to_compute: qa = QuotaAvailability() qa.queue(*quotas_to_compute) @@ -297,6 +371,10 @@ def add_subevents_for_days(qs, before, after, ebd, timezones, event=None, cart_n tz = pytz.timezone(settings.timezone) datetime_from = se.date_from.astimezone(tz) date_from = datetime_from.date() + if name is None: + name = str(se.name) + elif str(se.name) != name: + ebd['_subevents_different_names'] = True if se.event.settings.show_date_to and se.date_to: date_to = se.date_to.astimezone(tz).date() d = max(date_from, before.date()) @@ -321,6 +399,20 @@ def add_subevents_for_days(qs, before, after, ebd, timezones, event=None, cart_n }) +def days_for_template(ebd, week): + day_format = get_format('WEEK_DAY_FORMAT') + if day_format == 'WEEK_DAY_FORMAT': + day_format = 'SHORT_DATE_FORMAT' + return [ + { + 'day_formatted': date_format(day, day_format), + 'date': day, + 'events': ebd.get(day) + } + for day in week.days() + ] + + def weeks_for_template(ebd, year, month): calendar.setfirstweekday(0) # TODO: Configurable return [ @@ -382,6 +474,56 @@ class CalendarView(OrganizerViewMixin, EventListMixin, TemplateView): return ebd +class WeekCalendarView(OrganizerViewMixin, EventListMixin, TemplateView): + template_name = 'pretixpresale/organizers/calendar_week.html' + + def get(self, request, *args, **kwargs): + self._set_week_year() + return super().get(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data() + + week = isoweek.Week(self.year, self.week) + before = datetime( + week.monday().year, week.monday().month, week.monday().day, 0, 0, 0, tzinfo=UTC + ) - timedelta(days=1) + after = datetime( + week.sunday().year, week.sunday().month, week.sunday().day, 0, 0, 0, tzinfo=UTC + ) + timedelta(days=1) + + ctx['date'] = week.monday() + ctx['before'] = before + ctx['after'] = after + + ebd = self._events_by_day(before, after) + + ctx['days'] = days_for_template(ebd, week) + ctx['weeks'] = [date(self.year, i + 1, 1) for i in range(12)] + ctx['weeks'] = [i + 1 for i in range(53)] + ctx['years'] = range(now().year - 2, now().year + 3) + ctx['week_format'] = get_format('WEEK_FORMAT') + if ctx['week_format'] == 'WEEK_FORMAT': + ctx['week_format'] = WEEK_FORMAT + ctx['multiple_timezones'] = self._multiple_timezones + + return ctx + + def _events_by_day(self, before, after): + ebd = defaultdict(list) + timezones = set() + add_events_for_days(self.request, Event.annotated(self.request.organizer.events, 'web').using(settings.DATABASE_REPLICA), 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).using(settings.DATABASE_REPLICA), before, after, ebd, timezones) + self._multiple_timezones = len(timezones) > 1 + return ebd + + @method_decorator(cache_page(300), name='dispatch') class OrganizerIcalDownload(OrganizerViewMixin, View): def get(self, request, *args, **kwargs): diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index 9dee7c4845..5b73df578e 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -6,6 +6,7 @@ from collections import defaultdict from datetime import date, datetime, timedelta from urllib.parse import urljoin +import isoweek import pytz from django.conf import settings from django.contrib.staticfiles import finders @@ -43,7 +44,7 @@ from pretix.presale.views.event import ( ) from pretix.presale.views.organizer import ( EventListMixin, add_events_for_days, add_subevents_for_days, - filter_qs_by_attr, weeks_for_template, + days_for_template, filter_qs_by_attr, weeks_for_template, ) logger = logging.getLogger(__name__) @@ -360,6 +361,12 @@ class WidgetAPIProductList(EventListMixin, View): list_type = self.request.GET.get("style", o.settings.event_list_type) data['list_type'] = list_type + if hasattr(self.request, 'event') and data['list_type'] not in ("calendar", "week"): + if self.request.event.subevents.count() > 100: + if self.request.event.settings.event_list_type not in ("calendar", "week"): + self.request.event.settings.event_list_type = "calendar" + data['list_type'] = list_type = 'calendar' + cache_key = ':'.join([ 'widget.py', 'eventlist', @@ -414,6 +421,48 @@ class WidgetAPIProductList(EventListMixin, View): 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 = pytz.timezone(self.request.event.settings.timezone) + else: + tz = pytz.UTC + + 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) + + ebd = defaultdict(list) + if hasattr(self.request, 'event'): + add_subevents_for_days( + filter_qs_by_attr(self.request.event.subevents_annotated('web'), self.request), + before, after, ebd, set(), self.request.event, + kwargs.get('cart_namespace') + ) + else: + timezones = set() + add_events_for_days( + self.request, + filter_qs_by_attr(Event.annotated(self.request.organizer.events, 'web'), self.request), + 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), before, after, ebd, timezones) + + data['days'] = days_for_template(ebd, week) + for d in data['days']: + d['events'] = self._serialize_events(d['events'] or []) else: if hasattr(self.request, 'event'): evs = self.request.event.subevents_sorted( diff --git a/src/pretix/static/pretixpresale/js/widget/widget.js b/src/pretix/static/pretixpresale/js/widget/widget.js index 8e49250c98..b7667f3ab1 100644 --- a/src/pretix/static/pretixpresale/js/widget/widget.js +++ b/src/pretix/static/pretixpresale/js/widget/widget.js @@ -45,6 +45,8 @@ var strings = { 'back': django.pgettext('widget', 'Back'), 'next_month': django.pgettext('widget', 'Next month'), 'previous_month': django.pgettext('widget', 'Previous month'), + 'next_week': django.pgettext('widget', 'Next week'), + 'previous_week': django.pgettext('widget', 'Previous week'), 'show_seating': django.pgettext('widget', 'Open seat selection'), 'days': { 'MO': django.gettext('Mo'), @@ -90,6 +92,17 @@ var padNumber = function(number, size) { return s; }; +var getISOWeeks = function (y) { + var d, isLeap; + + d = new Date(y, 0, 1); + isLeap = new Date(y, 1, 29).getMonth() === 1; + + //check for a Jan 1 that's a Thursday or a leap year that has a + //Wednesday jan 1. Otherwise it's 52 + return d.getDay() === 4 || isLeap && d.getDay() === 3 ? 53 : 52 +}; + /* HTTP API Call helpers */ var api = { '_getXHR': function () { @@ -679,7 +692,7 @@ Vue.component('pretix-overlay', { Vue.component('pretix-widget-event-form', { template: ('
' - + '
' + + '
' + '‹ ' + strings['back_to_list'] + '' @@ -687,10 +700,10 @@ Vue.component('pretix-widget-event-form', { + strings['back_to_dates'] + '' + '
' - + '
' + + '
' + '{{ $root.name }}' + '
' - + '
' + + '
' + '{{ $root.date_range }}' + '
' + '
' @@ -772,6 +785,8 @@ Vue.component('pretix-widget-event-form', { this.$root.trigger_load_callback(); if (this.$root.events !== undefined) { this.$root.view = "events"; + } else if (this.$root.days !== undefined) { + this.$root.view = "days"; } else { this.$root.view = "weeks"; } @@ -872,7 +887,7 @@ Vue.component('pretix-widget-event-calendar-event', { Vue.component('pretix-widget-event-calendar-cell', { template: ('' - + '
' + + '
' + '{{ daynum }}' + '
' + '
' @@ -880,7 +895,8 @@ Vue.component('pretix-widget-event-calendar-cell', { + '
' + ''), props: { - day: Object + day: Object, + show_day: Boolean }, methods: { selectDay: function () { @@ -930,7 +946,7 @@ Vue.component('pretix-widget-event-calendar-cell', { Vue.component('pretix-widget-event-calendar-row', { template: ('' - + '' + + '' + ''), props: { week: Array @@ -1007,6 +1023,75 @@ Vue.component('pretix-widget-event-calendar', { }, }); +Vue.component('pretix-widget-event-week-calendar', { + template: ('
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
{{ d.day_formatted }}
' + + '
'), + computed: { + weekname: function () { + var curWeek = this.$root.week[1]; + var curYear = this.$root.week[0]; + return curWeek + ' / ' + curYear; + } + }, + methods: { + back_to_list: function () { + this.$root.weeks = undefined; + this.$root.view = "events"; + }, + prevweek: function () { + var curWeek = this.$root.week[1]; + var curYear = this.$root.week[0]; + curWeek--; + if (curWeek < 1) { + curYear--; + curWeek = getISOWeeks(curYear); + } + this.$root.week = [curYear, curWeek]; + this.$root.loading++; + this.$root.reload(); + }, + nextweek: function () { + var curWeek = this.$root.week[1]; + var curYear = this.$root.week[0]; + curWeek++; + if (curWeek > getISOWeeks(curYear)) { + curWeek = 1; + curYear++; + } + this.$root.week = [curYear, curWeek]; + this.$root.loading++; + this.$root.reload(); + } + }, +}); + Vue.component('pretix-widget', { template: ('
' + '
' @@ -1016,6 +1101,7 @@ Vue.component('pretix-widget', { + '' + '' + '' + + '' + '
' + '
' + '
' @@ -1113,6 +1199,8 @@ var shared_root_methods = { } if (this.$root.date !== null) { url += "&year=" + this.$root.date.substr(0, 4) + "&month=" + this.$root.date.substr(5, 2); + } else if (this.$root.week !== null) { + url += "&year=" + this.$root.week[0] + "&week=" + this.$root.week[1]; } if (this.$root.style !== null) { url = url + '&style=' + this.$root.style; @@ -1131,8 +1219,15 @@ var shared_root_methods = { if (data.weeks !== undefined) { root.weeks = data.weeks; root.date = data.date; + root.week = null; root.events = undefined; root.view = "weeks"; + } else if (data.days !== undefined) { + root.days = data.days; + root.date = null; + root.week = data.week; + root.events = undefined; + root.view = "days"; } else if (data.events !== undefined) { root.events = data.events; root.weeks = undefined; @@ -1355,7 +1450,9 @@ var create_widget = function (element) { style: style, error: null, weeks: null, + days: null, date: null, + week: null, frame_dismissed: false, events: null, view: null, diff --git a/src/pretix/static/pretixpresale/scss/_calendar.scss b/src/pretix/static/pretixpresale/scss/_calendar.scss index 68fa7e6752..e3a226728d 100644 --- a/src/pretix/static/pretixpresale/scss/_calendar.scss +++ b/src/pretix/static/pretixpresale/scss/_calendar.scss @@ -1,4 +1,4 @@ -.table-calendar { +.table-calendar, .week-calendar { td, th { width: 14.29%; } @@ -16,8 +16,44 @@ font-size: 12px; &.continued { - background: #888888; + background: #888; opacity: 0.8; + &:hover { + background: darken(#888, 15%); + opacity: 1; + } + } + + &.soon { + opacity: 0.8; + &:hover { + opacity: 1; + } + } + + &.over { + opacity: 0.8; + background: #888; + &:hover { + opacity: 1; + background: darken(#888, 15%); + } + } + + &.available, { + background: $brand-success; + + &:hover { + background: darken($brand-success, 15%); + } + } + + &.reserved, &.soldout, { + background: $brand-danger; + + &:hover { + background: darken($brand-danger, 15%); + } } .event-name { @@ -37,6 +73,38 @@ display: none; } } +.week-calendar { + .weekday { + margin-bottom: 15px; + } + h3 { + margin-bottom: 5px; + font-weight: bold; + } + .no-events { + display: none; + } +} +@media (min-width: $screen-md-min) { + .week-calendar { + display: flex; + flex-direction: row; + + .weekday { + flex: 1; + margin: 0 5px; + } + .weekday:first-child { + margin-left: 0; + } + .weekday:last-child { + margin-right: 0; + } + .no-events { + display: block; + } + } +} @media (max-width: $screen-xs-max) { .table-calendar .day .events { display: none; @@ -50,6 +118,9 @@ background: darken($brand-primary, 15%); } } +#monthselform .row { + margin: 0 -15px; +} #monthselform .row > div { margin-bottom: 15px; } diff --git a/src/requirements/production.txt b/src/requirements/production.txt index 7f6b46a75d..34919c2b8d 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -2,6 +2,7 @@ Django==3.0.* djangorestframework==3.11.* python-dateutil==2.8.* +isoweek requests==2.22.0 pytz django-bootstrap3==12.0.* @@ -65,4 +66,4 @@ python-bidi==0.4.* # Support for arabic in reportlab arabic-reshaper==2.0.15 # Support for Aabic in reportlab packaging tlds>=2020041600 -text-unidecode==1.* \ No newline at end of file +text-unidecode==1.* diff --git a/src/setup.py b/src/setup.py index 6461632ba3..49437e68df 100644 --- a/src/setup.py +++ b/src/setup.py @@ -91,6 +91,7 @@ setup( 'Django==3.0.*', 'djangorestframework==3.11.*', 'python-dateutil==2.8.*', + 'isoweek', 'requests==2.22.*', 'pytz', 'django-bootstrap3==12.0.*', diff --git a/src/tests/api/test_cart.py b/src/tests/api/test_cart.py index e12ee2956e..69c8a27bf3 100644 --- a/src/tests/api/test_cart.py +++ b/src/tests/api/test_cart.py @@ -4,15 +4,12 @@ from decimal import Decimal from unittest import mock import pytest -from django.dispatch import receiver from django.utils.timezone import now from django_scopes import scopes_disabled from pytz import UTC -from pretix.base.channels import SalesChannel from pretix.base.models import Question, SeatingPlan from pretix.base.models.orders import CartPosition -from pretix.base.signals import register_sales_channels @pytest.fixture @@ -52,21 +49,6 @@ def quota(event, item): return q -class FoobarSalesChannel(SalesChannel): - identifier = "bar" - verbose_name = "Foobar" - icon = "home" - testmode_supported = False - unlimited_items_per_order = True - - -@receiver(register_sales_channels, dispatch_uid="test_cart_register_sales_channels") -def base_sales_channels(sender, **kwargs): - return ( - FoobarSalesChannel(), - ) - - TEST_CARTPOSITION_RES = { 'id': 1, 'cart_id': 'aaa@api', diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 77e9eef19a..b479a930b6 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -6,7 +6,6 @@ from unittest import mock import pytest from django.core import mail as djmail -from django.dispatch import receiver from django.utils.timezone import now from django_countries.fields import Country from django_scopes import scopes_disabled @@ -14,7 +13,6 @@ from pytz import UTC from stripe.error import APIConnectionError from tests.plugins.stripe.test_provider import MockedCharge -from pretix.base.channels import SalesChannel from pretix.base.models import ( InvoiceAddress, Order, OrderPosition, Question, SeatingPlan, ) @@ -24,21 +22,6 @@ from pretix.base.models.orders import ( from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, ) -from pretix.base.signals import register_sales_channels - - -class FoobarSalesChannel(SalesChannel): - identifier = "bar" - verbose_name = "Foobar" - icon = "home" - testmode_supported = False - - -@receiver(register_sales_channels, dispatch_uid="test_orders_register_sales_channels") -def base_sales_channels(sender, **kwargs): - return ( - FoobarSalesChannel(), - ) @pytest.fixture @@ -1785,7 +1768,7 @@ def test_order_create_in_test_mode_saleschannel_limited(token_client, organizer, res['positions'][0]['item'] = item.pk res['positions'][0]['answers'][0]['question'] = question.pk res['testmode'] = True - res['sales_channel'] = 'bar' + res['sales_channel'] = 'baz' resp = token_client.post( '/api/v1/organizers/{}/events/{}/orders/'.format( organizer.slug, event.slug diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index b4ca533e7d..55304b6646 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -4,13 +4,12 @@ from decimal import Decimal import pytest import pytz from django.core import mail as djmail -from django.dispatch import receiver from django.test import TestCase from django.utils.timezone import make_aware, now from django_countries.fields import Country from django_scopes import scope +from tests.testdummy.signals import FoobazSalesChannel -from pretix.base.channels import SalesChannel from pretix.base.decimal import round_decimal from pretix.base.models import ( CartPosition, Event, InvoiceAddress, Item, Order, OrderPosition, Organizer, @@ -26,25 +25,10 @@ from pretix.base.services.orders import ( deny_order, expire_orders, reactivate_order, send_download_reminders, send_expiry_warnings, ) -from pretix.base.signals import register_sales_channels from pretix.plugins.banktransfer.payment import BankTransfer from pretix.testutils.scope import classscope -class FoobarSalesChannel(SalesChannel): - identifier = "bar" - verbose_name = "Foobar" - icon = "home" - testmode_supported = False - - -@receiver(register_sales_channels, dispatch_uid="test_orders_register_sales_channels") -def base_sales_channels(sender, **kwargs): - return ( - FoobarSalesChannel(), - ) - - @pytest.fixture(scope='function') def event(): o = Organizer.objects.create(name='Dummy', slug='dummy') @@ -2321,7 +2305,7 @@ def test_saleschannel_testmode_restriction(event): order = _create_order(event, email='dummy@example.org', positions=[cp1], now_dt=today, payment_provider=FreeOrderProvider(event), - locale='de', sales_channel=FoobarSalesChannel.identifier)[0] + locale='de', sales_channel=FoobazSalesChannel.identifier)[0] assert not order.testmode event.testmode = True @@ -2332,7 +2316,7 @@ def test_saleschannel_testmode_restriction(event): order = _create_order(event, email='dummy@example.org', positions=[cp1], now_dt=today, payment_provider=FreeOrderProvider(event), - locale='de', sales_channel=FoobarSalesChannel.identifier)[0] + locale='de', sales_channel=FoobazSalesChannel.identifier)[0] assert not order.testmode diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index d4c4bc414d..b1f95d00f2 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -4,13 +4,12 @@ from datetime import timedelta from decimal import Decimal from bs4 import BeautifulSoup -from django.dispatch import receiver from django.test import TestCase from django.utils.timezone import now from django_countries.fields import Country from django_scopes import scopes_disabled +from tests.testdummy.signals import FoobarSalesChannel -from pretix.base.channels import SalesChannel from pretix.base.decimal import round_decimal from pretix.base.models import ( CartPosition, Event, InvoiceAddress, Item, ItemCategory, ItemVariation, @@ -22,26 +21,10 @@ from pretix.base.models.items import ( from pretix.base.services.cart import ( CartError, CartManager, error_messages, update_tax_rates, ) -from pretix.base.signals import register_sales_channels from pretix.testutils.scope import classscope from pretix.testutils.sessions import get_cart_session_key -class FoobarSalesChannel(SalesChannel): - identifier = "bar" - verbose_name = "Foobar" - icon = "home" - testmode_supported = False - unlimited_items_per_order = True - - -@receiver(register_sales_channels, dispatch_uid="test_cart_register_sales_channels") -def base_sales_channels(sender, **kwargs): - return ( - FoobarSalesChannel(), - ) - - class CartTestMixin: @scopes_disabled() def setUp(self): diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 5bcc6696cd..3809b06509 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -11,8 +11,8 @@ from django.utils.timezone import now from django_scopes import scopes_disabled from pytz import timezone from tests.base import SoupTest +from tests.testdummy.signals import FoobarSalesChannel -from pretix.base.channels import SalesChannel from pretix.base.models import ( Event, Item, ItemCategory, ItemVariation, Order, Organizer, Quota, Team, User, WaitingListEntry, @@ -20,13 +20,6 @@ from pretix.base.models import ( from pretix.base.models.items import SubEventItem, SubEventItemVariation -class FoobarSalesChannel(SalesChannel): - identifier = "bar" - verbose_name = "Foobar" - icon = "home" - testmode_supported = True - - class EventTestMixin: @scopes_disabled() def setUp(self): @@ -228,6 +221,25 @@ class ItemDisplayTest(EventTestMixin, SoupTest): self.assertIn("Foo SE1", resp.rendered_content) self.assertNotIn("Foo SE2", resp.rendered_content) + def test_subevent_week_calendar(self): + self.event.settings.event_list_type = 'week' + self.event.has_subevents = True + self.event.save() + with scopes_disabled(): + se1 = self.event.subevents.create(name='Foo SE1', date_from=now() + datetime.timedelta(days=24), + active=True) + self.event.subevents.create(name='Foo SE2', date_from=now() + datetime.timedelta(days=12), + active=True) + resp = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + print(resp.rendered_content) + self.assertIn("Foo SE2", resp.rendered_content) + self.assertNotIn("Foo SE1", resp.rendered_content) + resp = self.client.get('/%s/%s/?year=%d&week=%d' % (self.orga.slug, self.event.slug, + se1.date_from.isocalendar()[0], + se1.date_from.isocalendar()[1])) + self.assertIn("Foo SE1", resp.rendered_content) + self.assertNotIn("Foo SE2", resp.rendered_content) + def test_subevents(self): self.event.has_subevents = True self.event.save() diff --git a/src/tests/presale/test_organizer_page.py b/src/tests/presale/test_organizer_page.py index 721a487d5a..99279b384a 100644 --- a/src/tests/presale/test_organizer_page.py +++ b/src/tests/presale/test_organizer_page.py @@ -138,6 +138,24 @@ def test_calendar(env, client): assert 'October 2017' in r.rendered_content +@pytest.mark.django_db +def test_week_calendar(env, client): + env[0].settings.event_list_type = 'calendar' + e = Event.objects.create( + organizer=env[0], name='MRMCD2017', slug='2017', + date_from=datetime(now().year + 1, 9, 1, tzinfo=UTC), + live=True, is_public=False + ) + r = client.get('/mrmcd/?style=week') + assert 'MRMCD2017' not in r.rendered_content + e.is_public = True + e.save() + r = client.get('/mrmcd/?style=week') + assert 'MRMCD2017' in r.rendered_content + r = client.get('/mrmcd/?style=week&week=2&year=2017') + assert 'MRMCD2017' not in r.rendered_content + + @pytest.mark.django_db def test_attributes_in_calendar(env, client): env[0].settings.event_list_type = 'calendar' diff --git a/src/tests/presale/test_widget.py b/src/tests/presale/test_widget.py index 22855acf08..cf99e34b56 100644 --- a/src/tests/presale/test_widget.py +++ b/src/tests/presale/test_widget.py @@ -555,6 +555,44 @@ class WidgetCartTest(CartTestMixin, TestCase): ] } + def test_subevent_week_calendar(self): + self.event.has_subevents = True + self.event.settings.timezone = 'Europe/Berlin' + self.event.save() + with freeze_time("2019-01-01 10:00:00"): + with scopes_disabled(): + self.event.subevents.create(name="Past", active=True, date_from=now() - datetime.timedelta(days=3)) + se1 = self.event.subevents.create(name="Present", active=True, date_from=now()) + se2 = self.event.subevents.create(name="Future", active=True, date_from=now() + datetime.timedelta(days=3)) + self.event.subevents.create(name="Disabled", active=False, date_from=now() + datetime.timedelta(days=3)) + self.event.subevents.create(name="Hidden", active=True, is_public=False, date_from=now() + datetime.timedelta(days=3)) + + response = self.client.get('/%s/%s/widget/product_list?style=week' % (self.orga.slug, self.event.slug)) + settings.SITE_URL = 'http://example.com' + data = json.loads(response.content.decode()) + assert data == { + 'list_type': 'week', + 'week': [2019, 1], + 'poweredby': 'event ticketing powered by pretix', + 'days': [ + {'day_formatted': 'Mon, Dec 31st', 'date': '2018-12-31', 'events': []}, + {'day_formatted': 'Tue, Jan 1st', 'date': '2019-01-01', 'events': [ + {'name': 'Present', 'time': '11:00', 'continued': False, 'date_range': 'Jan. 1, 2019 11:00', + 'location': '', + 'availability': {'color': 'green', 'text': 'Book now'}, + 'event_url': 'http://example.com/ccc/30c3/', 'subevent': se1.pk}]}, + {'day_formatted': 'Wed, Jan 2nd', 'date': '2019-01-02', 'events': []}, + {'day_formatted': 'Thu, Jan 3rd', 'date': '2019-01-03', 'events': []}, + {'day_formatted': 'Fri, Jan 4th', 'date': '2019-01-04', 'events': [ + {'name': 'Future', 'time': '11:00', 'continued': False, 'date_range': 'Jan. 4, 2019 11:00', + 'location': '', + 'availability': {'color': 'green', 'text': 'Book now'}, + 'event_url': 'http://example.com/ccc/30c3/', 'subevent': se2.pk}]}, + {'day_formatted': 'Sat, Jan 5th', 'date': '2019-01-05', 'events': []}, + {'day_formatted': 'Sun, Jan 6th', 'date': '2019-01-06', 'events': []} + ], + } + def test_event_list(self): self.event.has_subevents = True self.event.settings.timezone = 'Europe/Berlin' diff --git a/src/tests/testdummy/signals.py b/src/tests/testdummy/signals.py index 0554a345b4..838818e964 100644 --- a/src/tests/testdummy/signals.py +++ b/src/tests/testdummy/signals.py @@ -1,7 +1,9 @@ from django.dispatch import receiver +from pretix.base.channels import SalesChannel from pretix.base.signals import ( - register_payment_providers, register_ticket_outputs, + register_payment_providers, register_sales_channels, + register_ticket_outputs, ) @@ -15,3 +17,23 @@ def register_ticket_outputs(sender, **kwargs): def register_payment_provider(sender, **kwargs): from .payment import DummyPaymentProvider, DummyFullRefundablePaymentProvider, DummyPartialRefundablePaymentProvider return [DummyPaymentProvider, DummyFullRefundablePaymentProvider, DummyPartialRefundablePaymentProvider] + + +class FoobazSalesChannel(SalesChannel): + identifier = "baz" + verbose_name = "Foobar" + icon = "home" + testmode_supported = False + + +class FoobarSalesChannel(SalesChannel): + identifier = "bar" + verbose_name = "Foobar" + icon = "home" + testmode_supported = True + unlimited_items_per_order = True + + +@receiver(register_sales_channels, dispatch_uid="sc_dummy") +def register_sc(sender, **kwargs): + return [FoobarSalesChannel, FoobazSalesChannel]