diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index b80df1a668..ea453734f4 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -49,7 +49,7 @@ Backend .. automodule:: pretix.base.signals - :members: logentry_display, logentry_object_link, requiredaction_display + :members: logentry_display, logentry_object_link, requiredaction_display, timeline_events Vouchers """""""" diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index da564554a9..7adfdfb905 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -515,3 +515,12 @@ dictionaries as values that contain keys like in the following example:: The evaluate member will be called with the order position, order and event as arguments. The event might also be a subevent, if applicable. """ + + +timeline_events = EventPluginSignal() +""" +This signal is sent out to collect events for the time line shown on event dashboards. You are passed +a ``subevent`` argument which might be none and you are expected to return a list of instances of +``pretix.base.timeline.TimelineEvent``, which is a ``namedtuple`` with the fields ``event``, ``subevent``, +``datetime``, ``description`` and ``edit_url``. +""" diff --git a/src/pretix/base/timeline.py b/src/pretix/base/timeline.py new file mode 100644 index 0000000000..2f2a0f8327 --- /dev/null +++ b/src/pretix/base/timeline.py @@ -0,0 +1,189 @@ +from collections import namedtuple +from datetime import timedelta + +from django.db.models import Q +from django.urls import reverse +from django.utils.translation import pgettext_lazy + +from pretix.base.reldate import RelativeDateWrapper +from pretix.base.signals import timeline_events + +TimelineEvent = namedtuple('TimelineEvent', ('event', 'subevent', 'datetime', 'description', 'edit_url')) + + +def timeline_for_event(event, subevent=None): + tl = [] + ev = subevent or event + if subevent: + ev_edit_url = reverse( + 'control:event.subevent', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + 'subevent': subevent.pk + } + ) + else: + ev_edit_url = reverse( + 'control:event.settings', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug + } + ) + + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=ev.date_from, + description=pgettext_lazy('timeline', 'Your event starts'), + edit_url=ev_edit_url + )) + + if ev.date_to: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=ev.date_to, + description=pgettext_lazy('timeline', 'Your event ends'), + edit_url=ev_edit_url + )) + + if ev.date_admission: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=ev.date_admission, + description=pgettext_lazy('timeline', 'Admissions for your event start'), + edit_url=ev_edit_url + )) + + if ev.presale_start: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=ev.presale_start, + description=pgettext_lazy('timeline', 'Start of ticket sales'), + edit_url=ev_edit_url + )) + + if ev.presale_end: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=ev.presale_end, + description=pgettext_lazy('timeline', 'End of ticket sales'), + edit_url=ev_edit_url + )) + + rd = event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper) + if rd: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=rd.datetime(ev), + description=pgettext_lazy('timeline', 'Customers can no longer modify their orders'), + edit_url=ev_edit_url + )) + + rd = event.settings.get('payment_term_last', as_type=RelativeDateWrapper) + if rd: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=rd.datetime(ev), + description=pgettext_lazy('timeline', 'No more payments can be completed'), + edit_url=reverse('control:event.settings.payment', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug + }) + )) + + rd = event.settings.get('ticket_download_date', as_type=RelativeDateWrapper) + if rd and event.settings.ticket_download: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=rd.datetime(ev), + description=pgettext_lazy('timeline', 'Tickets can be downloaded'), + edit_url=reverse('control:event.settings.tickets', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug + }) + )) + + rd = event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper) + if rd and event.settings.cancel_allow_user: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=rd.datetime(ev), + description=pgettext_lazy('timeline', 'Customers can no longer cancel free or unpaid orders'), + edit_url=reverse('control:event.settings.tickets', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug + }) + )) + + rd = event.settings.get('cancel_allow_user_paid_until', as_type=RelativeDateWrapper) + if rd and event.settings.cancel_allow_user_paid: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=rd.datetime(ev), + description=pgettext_lazy('timeline', 'Customers can no longer cancel paid orders'), + edit_url=reverse('control:event.settings.tickets', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug + }) + )) + + if not event.has_subevents: + days = event.settings.get('mail_days_download_reminder', as_type=int) + if days is not None: + reminder_date = (ev.date_from - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0) + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=reminder_date, + description=pgettext_lazy('timeline', 'Download reminders are being sent out'), + edit_url=reverse('control:event.settings.mail', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug + }) + )) + + for p in event.items.filter(Q(available_from__isnull=False) | Q(available_until__isnull=False)): + if p.available_from: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=p.available_from, + description=pgettext_lazy('timeline', 'Product "{name}" becomes available').format(name=str(p)), + edit_url=reverse('control:event.item', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + 'item': p.pk, + }) + )) + if p.available_until: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=p.available_until, + description=pgettext_lazy('timeline', 'Product "{name}" becomes unavailable').format(name=str(p)), + edit_url=reverse('control:event.item', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + 'item': p.pk, + }) + )) + + pprovs = event.get_payment_providers() + # This is a special case, depending on payment providers not overriding BasePaymentProvider by too much, but it's + # preferrable to having all plugins implement this spearately. + for pprov in pprovs.values(): + availability_date = pprov.settings.get('_availability_date', as_type=RelativeDateWrapper) + if availability_date: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=availability_date.datetime(ev), + description=pgettext_lazy('timeline', 'Payment provider "{name}" can no longer be selected').format( + name=str(pprov.verbose_name) + ), + edit_url=reverse('control:event.settings.payment.provider', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + 'provider': pprov.identifier, + }) + )) + + for recv, resp in timeline_events.send(sender=event, subevent=subevent): + tl += resp + + return sorted(tl, key=lambda e: e.datetime) diff --git a/src/pretix/control/templates/pretixcontrol/event/fragment_timeline.html b/src/pretix/control/templates/pretixcontrol/event/fragment_timeline.html new file mode 100644 index 0000000000..3877578097 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/event/fragment_timeline.html @@ -0,0 +1,36 @@ +{% load i18n %} +
+
+

+ {% trans "Your timeline" %} +

+
+
+ {% regroup timeline by date as tl_list %} + {% for day in tl_list %} +
+
+ {{ day.grouper|date:"SHORT_DATE_FORMAT" }} +
+
+ {% for e in day.list %} + {{ e.time|date:"TIME_FORMAT" }} +   + + {{ e.entry.description }} + + {% if e.entry.edit_url %} +   + + + + {% endif %} + {% if forloop.revcounter > 1 %} +
+ {% endif %} + {% endfor %} +
+
+ {% endfor %} +
+
diff --git a/src/pretix/control/templates/pretixcontrol/event/index.html b/src/pretix/control/templates/pretixcontrol/event/index.html index b4239163f1..bb84603c22 100644 --- a/src/pretix/control/templates/pretixcontrol/event/index.html +++ b/src/pretix/control/templates/pretixcontrol/event/index.html @@ -90,6 +90,9 @@ {% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %} {% endif %} + {% if not request.event.has_subevents or subevent %} + {% include "pretixcontrol/event/fragment_timeline.html" %} + {% endif %}
{% for w in widgets %}
diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py index 6865503a26..d1a19ac34c 100644 --- a/src/pretix/control/views/dashboards.py +++ b/src/pretix/control/views/dashboards.py @@ -1,3 +1,4 @@ +from datetime import timedelta from decimal import Decimal import pytz @@ -22,6 +23,7 @@ from pretix.base.models import ( WaitingListEntry, ) from pretix.base.models.checkin import CheckinList +from pretix.base.timeline import timeline_for_event from pretix.control.forms.event import CommentForm from pretix.control.signals import ( event_dashboard_widgets, user_dashboard_widgets, @@ -279,6 +281,7 @@ def event_index(request, organizer, event): ctx = { 'widgets': rearrange(widgets), 'logs': qs[:5], + 'subevent': subevent, 'actions': a_qs[:5] if can_change_orders else [], 'comment_form': CommentForm(initial={'comment': request.event.comment}) } @@ -302,7 +305,19 @@ def event_index(request, organizer, event): for a in ctx['actions']: a.display = a.display(request) - return render(request, 'pretixcontrol/event/index.html', ctx) + ctx['timeline'] = [ + { + 'date': t.datetime.astimezone(request.event.timezone).date(), + 'entry': t, + 'time': t.datetime.astimezone(request.event.timezone) + } + for t in timeline_for_event(request.event, subevent) + ] + ctx['today'] = now().astimezone(request.event.timezone).date() + ctx['nearly_now'] = now().astimezone(request.event.timezone) - timedelta(seconds=20) + resp = render(request, 'pretixcontrol/event/index.html', ctx) + # resp['Content-Security-Policy'] = "style-src 'unsafe-inline'" + return resp def annotated_event_query(request): diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index 7ead3e5b6a..9ea2988ebc 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -618,3 +618,30 @@ h1 .label { border-radius: $border-radius-small; box-shadow: 0 7px 14px 0 rgba(78, 50, 92, 0.1),0 3px 6px 0 rgba(0,0,0,.07); } + +.timeline { + .col-date { + position: relative; + min-height: 1px; + padding-left: 15px; + width: 120px; + float: left; + } + .col-event { + position: relative; + min-height: 1px; + padding-right: 15px; + margin-left: 120px; + } +} +@media(max-width: $screen-sm-max) { + .timeline { + .col-date { + width: 100%; + float: none; + } + .col-event { + margin-left: 0; + } + } +} diff --git a/src/tests/base/test_timeline.py b/src/tests/base/test_timeline.py new file mode 100644 index 0000000000..ab8f409ccf --- /dev/null +++ b/src/tests/base/test_timeline.py @@ -0,0 +1,50 @@ +from datetime import datetime +from decimal import Decimal + +import pytest +import pytz + +from pretix.base.models import Event, Organizer +from pretix.base.timeline import timeline_for_event + +tz = pytz.timezone('Europe/Berlin') + + +def one(iterable): + found = False + for it in iterable: + if it: + if found: + return False + else: + found = True + return found + + +@pytest.fixture +def event(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=datetime(2017, 10, 22, 12, 0, 0, tzinfo=tz), + date_to=datetime(2017, 10, 23, 23, 0, 0, tzinfo=tz), + ) + return event + + +@pytest.fixture +def item(event): + return event.items.create(name='Ticket', default_price=Decimal('23.00')) + + +@pytest.mark.django_db +def test_event_dates(event): + tl = timeline_for_event(event) + assert one([ + e for e in tl + if e.event == event and e.datetime == event.date_from and e.description == 'Your event starts' + ]) + assert one([ + e for e in tl + if e.event == event and e.datetime == event.date_to and e.description == 'Your event ends' + ])