forked from CGM_Public/pretix_original
Display a timeline on the dashboard (#1290)
* Timeline data model * Display timeline * … * More events * Plugin support * Fix docs typo
This commit is contained in:
@@ -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
|
||||||
""""""""
|
""""""""
|
||||||
|
|||||||
@@ -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
189
src/pretix/base/timeline.py
Normal 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)
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
<span class="{% if e.time < nearly_now %}text-muted{% endif %}">
|
||||||
|
{{ e.entry.description }}
|
||||||
|
</span>
|
||||||
|
{% if e.entry.edit_url %}
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -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" }}">
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
50
src/tests/base/test_timeline.py
Normal file
50
src/tests/base/test_timeline.py
Normal 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'
|
||||||
|
])
|
||||||
Reference in New Issue
Block a user