Display a timeline on the dashboard (#1290)

* Timeline data model

* Display timeline

* …

* More events

* Plugin support

* Fix docs typo
This commit is contained in:
Raphael Michel
2019-05-17 17:32:38 +02:00
committed by GitHub
parent ecc9c7f39f
commit c6b18b31a1
8 changed files with 331 additions and 2 deletions

View File

@@ -49,7 +49,7 @@ Backend
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: logentry_display, logentry_object_link, requiredaction_display :members: logentry_display, logentry_object_link, requiredaction_display, timeline_events
Vouchers Vouchers
"""""""" """"""""

View File

@@ -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 The evaluate member will be called with the order position, order and event as arguments. The event might
also be a subevent, if applicable. 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``.
"""

189
src/pretix/base/timeline.py Normal file
View File

@@ -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)

View File

@@ -0,0 +1,36 @@
{% load i18n %}
<div class="panel panel-default items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Your timeline" %}
</h3>
</div>
<div class="panel-body timeline">
{% regroup timeline by date as tl_list %}
{% for day in tl_list %}
<div class="row {% if day.grouper < today %}text-muted{% endif %}">
<div class="col-date">
<strong>{{ day.grouper|date:"SHORT_DATE_FORMAT" }}</strong>
</div>
<div class="col-event">
{% for e in day.list %}
<strong class="">{{ e.time|date:"TIME_FORMAT" }}</strong>
&nbsp;
<span class="{% if e.time < nearly_now %}text-muted{% endif %}">
{{ e.entry.description }}
</span>
{% if e.entry.edit_url %}
&nbsp;
<a href="{{ e.entry.edit_url }}" class="text-muted">
<span class="fa fa-edit"></span>
</a>
{% endif %}
{% if forloop.revcounter > 1 %}
<br/>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>

View File

@@ -90,6 +90,9 @@
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %} {% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
</form> </form>
{% endif %} {% endif %}
{% if not request.event.has_subevents or subevent %}
{% include "pretixcontrol/event/fragment_timeline.html" %}
{% endif %}
<div class="dashboard"> <div class="dashboard">
{% for w in widgets %} {% for w in widgets %}
<div class="widget-container widget-{{ w.display_size|default:"small" }}"> <div class="widget-container widget-{{ w.display_size|default:"small" }}">

View File

@@ -1,3 +1,4 @@
from datetime import timedelta
from decimal import Decimal from decimal import Decimal
import pytz import pytz
@@ -22,6 +23,7 @@ from pretix.base.models import (
WaitingListEntry, WaitingListEntry,
) )
from pretix.base.models.checkin import CheckinList 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.forms.event import CommentForm
from pretix.control.signals import ( from pretix.control.signals import (
event_dashboard_widgets, user_dashboard_widgets, event_dashboard_widgets, user_dashboard_widgets,
@@ -279,6 +281,7 @@ def event_index(request, organizer, event):
ctx = { ctx = {
'widgets': rearrange(widgets), 'widgets': rearrange(widgets),
'logs': qs[:5], 'logs': qs[:5],
'subevent': subevent,
'actions': a_qs[:5] if can_change_orders else [], 'actions': a_qs[:5] if can_change_orders else [],
'comment_form': CommentForm(initial={'comment': request.event.comment}) 'comment_form': CommentForm(initial={'comment': request.event.comment})
} }
@@ -302,7 +305,19 @@ def event_index(request, organizer, event):
for a in ctx['actions']: for a in ctx['actions']:
a.display = a.display(request) 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): def annotated_event_query(request):

View File

@@ -618,3 +618,30 @@ h1 .label {
border-radius: $border-radius-small; 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); 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;
}
}
}

View File

@@ -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'
])