diff --git a/src/pretix/control/templates/pretixcontrol/event/new_index.html b/src/pretix/control/templates/pretixcontrol/event/new_index.html new file mode 100644 index 0000000000..9a2bfe6a41 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/event/new_index.html @@ -0,0 +1,300 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load bootstrap3 %} +{% load static %} +{% load escapejson %} +{% load eventsignal %} +{% block title %}{{ request.event.name }}{% endblock %} +{% block content %} +

+ {{ request.event.name }} + + {% if request.event.has_subevents %} + {% trans "Event series" %} + {% else %} + {{ request.event.get_date_range_display }} + {% endif %} + +

+
+ {% trans "Shop URL:" %} + {% abseventurl request.event "presale:event.index" %} + +
+ + {% include "pretixcontrol/event/fragment_qr_dropdown.html" with url=0 %} +
+ + + {{ shop_state.content|safe }} + + +
+
+ + + {% if has_overpaid_orders %} +
+ {% blocktrans trimmed %} + This event contains overpaid orders, for example due to duplicate payment attempts. + You should review the cases and consider refunding the overpaid amount to the user. + {% endblocktrans %} + {% trans "Show overpaid orders" %} +
+ {% endif %} + {% if has_pending_refunds %} +
+ {% blocktrans trimmed %} + This event contains pending refunds that you should take care of. + {% endblocktrans %} + {% trans "Show pending refunds" %} +
+ {% endif %} + {% if has_cancellation_requests %} +
+ {% blocktrans trimmed %} + This event contains requested cancellations that you should take care of. + {% endblocktrans %} + {% trans "Show orders requesting cancellation" %} +
+ {% endif %} + {% if has_pending_approvals %} +
+ {% blocktrans trimmed %} + This event contains pending approvals that you should take care of. + {% endblocktrans %} + {% trans "Show orders pending approval" %} +
+ {% endif %} + {% if has_pending_orders_with_full_payment %} +
+ {% blocktrans trimmed %} + This event contains fully paid orders that are not marked as paid, probably + because no quota was left at the time their payment arrived. You should review the cases and consider + either refunding the customer or creating more space. + {% endblocktrans %} + {% trans "Show affected orders" %} +
+ {% endif %} + + + {% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %} + + {% if request.event.has_subevents %} +
+ {% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %} +
+ {% endif %} + {% if not request.event.has_subevents or subevent %} + {% include "pretixcontrol/event/fragment_timeline.html" %} + {% endif %} + + +
+
+ + {{ attendees_ordered.content|safe }} + +
+
+ + {{ attendees_paid.content|safe }} + +
+
+ + {{ total_revenue.content|safe }} + +
+
+ + +
+
+

{% trans "Revenue over time" %}

+
+
+
+ {% if request.GET.subevent %} +
+ {% blocktrans trimmed context "subevent" %} + If you select a single date, payment method fees will not be listed here as it might not be clear which + date they belong to. + {% endblocktrans %} +
+ {% endif %} +

+ + {% blocktrans trimmed %} + Only fully paid orders are counted. + Orders paid in multiple payments are shown with the date of their last payment. + {% endblocktrans %} + +

+
+
+ + +
+
+

{% trans "Check-In" %}

+
+
+ + + + + + {% if request.event.has_subevents %} + + {% endif %} + + + + + + + {% for cl in checkinlists %} + + + + {% if request.event.has_subevents %} + {% if cl.subevent %} + + {% else %} + + {% endif %} + {% endif %} + + + + + {% endfor %} + +
+ {% trans "Name" %} + {% trans "Checked in" %} + {% trans "Date" context "subevent" %} + {% trans "Automated check-in" %}{% trans "Products" %}
+ {{ cl.name }} + +
+
+
+
+
+
+ {{ cl.checkin_count|default_if_none:"0" }} / + {{ cl.position_count|default_if_none:"0" }} +
+
+
+ {{ cl.subevent.name }} – {{ cl.subevent.get_date_range_display }} + {{ cl.subevent.date_from|date:"TIME_FORMAT" }} + + {% trans "All" %} + + {% for channel in cl.auto_checkin_sales_channels %} + + {% endfor %} + + {% if cl.all_products %} + {% trans "All" %} + {% else %} +
    + {% for item in cl.limit_products.all %} +
  • + {{ item }} +
  • + {% endfor %} +
+ {% endif %} +
+ + {% if "can_change_event_settings" in request.eventpermset %} + + + + + + + {% endif %} +
+
+
+ + + +
+ +
+
+
+

+ {% trans "Internal comment" %} +

+
+
+
+ {% csrf_token %} +
+ {% bootstrap_field comment_form.comment layout="horizontal" show_help=True show_label=False horizontal_field_class="col-md-12" %} +
+ {% if not comment_form.readonly %} +

+
+ +

+ {% endif %} +
+
+
+
+ + +
+
+
+

+ {% trans "Event logs" %} +

+
+
    +
    + +
    +
+ +
+
+ +
+ + +{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 2e4b760ffe..38fe91c5d9 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -38,8 +38,8 @@ from django.views.generic.base import RedirectView from pretix.control.views import ( auth, checkin, dashboards, discounts, event, geo, global_settings, item, - main, oauth, orderimport, orders, organizer, pdf, search, shredder, - subevents, typeahead, user, users, vouchers, waitinglist, + main, new_dashboard, oauth, orderimport, orders, organizer, pdf, search, + shredder, subevents, typeahead, user, users, vouchers, waitinglist, ) urlpatterns = [ @@ -239,7 +239,7 @@ urlpatterns = [ re_path(r'^search/orders/$', search.OrderSearch.as_view(), name='search.orders'), re_path(r'^search/payments/$', search.PaymentSearch.as_view(), name='search.payments'), re_path(r'^event/(?P[^/]+)/(?P[^/]+)/', include([ - re_path(r'^$', dashboards.event_index, name='event.index'), + re_path(r'^$', new_dashboard.IndexView.as_view(), name='event.index'), re_path(r'^qrcode.(?P(png|jpeg|gif|svg))$', event.EventQRCode.as_view(), name='event.qrcode'), re_path(r'^widgets.json$', dashboards.event_index_widgets_lazy, name='event.index.widgets'), re_path(r'^logs/embed$', dashboards.event_index_log_lazy, name='event.index.logs'), diff --git a/src/pretix/control/views/new_dashboard.py b/src/pretix/control/views/new_dashboard.py new file mode 100644 index 0000000000..018155a537 --- /dev/null +++ b/src/pretix/control/views/new_dashboard.py @@ -0,0 +1,233 @@ +import json +from datetime import datetime, timedelta +from decimal import Decimal + +import dateutil +from django.db.models import DateTimeField, Max, OuterRef, Q, Subquery, Sum +from django.urls import reverse +from django.utils import formats, timezone +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ +from django.views.generic import TemplateView + +from pretix.base.channels import get_all_sales_channels +from pretix.base.decimal import round_decimal +from pretix.base.models import SubEvent +from pretix.base.models.orders import ( + CancellationRequest, Order, OrderPayment, OrderPosition, OrderRefund, +) +from pretix.base.timeline import timeline_for_event +from pretix.control.forms.event import CommentForm +from pretix.control.permissions import EventPermissionRequiredMixin +from pretix.control.views import ChartContainingView +from pretix.control.views.dashboards import NUM_WIDGET + + +class IndexView(EventPermissionRequiredMixin, ChartContainingView, TemplateView): + template_name = 'pretixcontrol/event/new_index.html' + permission = 'can_view_orders' + + def get_context_data(self, **kwargs): + subevent = None + if self.request.GET.get("subevent", "") != "" and self.request.event.has_subevents: + i = self.request.GET.get("subevent", "") + try: + subevent = self.request.event.subevents.get(pk=i) + except SubEvent.DoesNotExist: + pass + + can_view_orders = self.request.user.has_event_permission(self.request.organizer, self.request.event, + 'can_view_orders', + request=self.request) + can_change_event_settings = self.request.user.has_event_permission(self.request.organizer, self.request.event, + 'can_change_event_settings', + request=self.request) + + ctx = { + 'subevent': subevent, + 'comment_form': CommentForm(initial={'comment': self.request.event.comment}, + readonly=not can_change_event_settings), + } + + if subevent: + opqs = OrderPosition.objects.filter(subevent=subevent) + else: + opqs = OrderPosition.objects + + tickc = opqs.filter( + order__event=self.request.event, item__admission=True, + order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING), + ).count() + + ctx['shop_state'] = { + 'display_size': 'small', + 'priority': 1000, + 'content': '
{state}
'.format( + state=_('live') if self.request.event.live and not self.request.event.testmode else ( + _('live and in test mode') if self.request.event.live else ( + _('not yet public') if not self.request.event.testmode else ( + _('in private test mode') + ) + ) + ), + icon='fa-check-circle' if self.request.event.live and not self.request.event.testmode else ( + 'fa-warning' if self.request.event.live else ( + 'fa-times-circle' if not self.request.event.testmode else ( + 'fa-times-circle' + ) + ) + ), + cls='live' if self.request.event.live else 'off' + ), + 'url': reverse('control:event.live', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug + }) + } + + qs = self.request.event.checkin_lists.filter(subevent=subevent) + sales_channels = get_all_sales_channels() + for cl in qs: + if cl.subevent: + cl.subevent.event = self.request.event # re-use same event object to make sure settings are cached + cl.auto_checkin_sales_channels = [sales_channels[channel] for channel in cl.auto_checkin_sales_channels] + ctx['checkinlists'] = qs + + ctx['attendees_ordered'] = { + 'content': NUM_WIDGET.format(num=tickc, text=_('Attendees (ordered)')), + 'priority': 100, + 'url': reverse('control:event.orders', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug + }) + ('?subevent={}'.format(subevent.pk) if subevent else '') + } + + paidc = opqs.filter( + order__event=self.request.event, item__admission=True, + order__status=Order.STATUS_PAID, + ).count() + + ctx['attendees_paid'] = { + 'content': NUM_WIDGET.format(num=paidc, text=_('Attendees (paid)')), + 'priority': 100, + 'url': reverse('control:event.orders.overview', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug + }) + ('?subevent={}'.format(subevent.pk) if subevent else '') + } + + if subevent: + rev = opqs.filter( + order__event=self.request.event, order__status=Order.STATUS_PAID + ).aggregate( + sum=Sum('price') + )['sum'] or Decimal('0.00') + else: + rev = Order.objects.filter( + event=self.request.event, + status=Order.STATUS_PAID + ).aggregate(sum=Sum('total'))['sum'] or Decimal('0.00') + + ctx['total_revenue'] = { + 'content': NUM_WIDGET.format( + num=formats.localize(round_decimal(rev, self.request.event.currency)), + text=_('Total revenue ({currency})').format(currency=self.request.event.currency) + ), + 'priority': 100, + 'url': reverse('control:event.orders.overview', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug + }) + ('?subevent={}'.format(subevent.pk) if subevent else '') + } + + cache = self.request.event.cache + ckey = str(subevent.pk) if subevent else 'all' + tz = timezone.get_current_timezone() + # op_date = OrderPayment.objects.filter( + # order=OuterRef('order'), + # state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), + # payment_date__isnull=False + # ).values('order').annotate( + # m=Max('payment_date') + # ).values( + # 'm' + # ).order_by() + p_date = OrderPayment.objects.filter( + order=OuterRef('pk'), + state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED), + payment_date__isnull=False + ).values('order').annotate( + m=Max('payment_date') + ).values( + 'm' + ).order_by() + ctx['rev_data'] = cache.get('statistics_rev_data' + ckey) + if not ctx['rev_data']: + rev_by_day = {} + # if subevent: + # for o in OrderPosition.objects.annotate( + # payment_date=Subquery(op_date, output_field=DateTimeField()) + # ).filter(order__event=self.request.event, + # subevent=subevent, + # order__status=Order.STATUS_PAID, + # payment_date__isnull=False).values('payment_date', 'price'): + # day = o['payment_date'].astimezone(tz).date() + # rev_by_day[day] = rev_by_day.get(day, 0) + o['price'] + # else: + for o in Order.objects.annotate( + payment_date=Subquery(p_date, output_field=DateTimeField()) + ).filter(event=self.request.event, + status=Order.STATUS_PAID, + payment_date__isnull=False).values('payment_date', 'total'): + day = o['payment_date'].astimezone(tz).date() + rev_by_day[day] = rev_by_day.get(day, 0) + o['total'] + + data = [] + total = 0 + for d in dateutil.rrule.rrule( + dateutil.rrule.DAILY, + dtstart=min(rev_by_day.keys() if rev_by_day else [datetime.date.today()]), + until=max(rev_by_day.keys() if rev_by_day else [datetime.date.today()])): + d = d.date() + total += float(rev_by_day.get(d, 0)) + data.append({ + 'date': d.strftime('%Y-%m-%d'), + 'revenue': round(total, 2), + }) + ctx['rev_data'] = json.dumps(data) + cache.set('statistics_rev_data' + ckey, ctx['rev_data']) + # ctx = ctx | statistics.get_context_data(IndexView(request=request)) + + ctx['has_overpaid_orders'] = can_view_orders and Order.annotate_overpayments(self.request.event.orders).filter( + Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0)) + | Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0)) + ).exists() + ctx['has_pending_orders_with_full_payment'] = can_view_orders and Order.annotate_overpayments( + self.request.event.orders).filter( + Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0) & Q( + require_approval=False) + ).exists() + ctx['has_pending_refunds'] = can_view_orders and OrderRefund.objects.filter( + order__event=self.request.event, + state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_EXTERNAL) + ).exists() + ctx['has_pending_approvals'] = can_view_orders and self.request.event.orders.filter( + status=Order.STATUS_PENDING, + require_approval=True + ).exists() + ctx['has_cancellation_requests'] = can_view_orders and CancellationRequest.objects.filter( + order__event=self.request.event + ).exists() + + ctx['timeline'] = [ + { + 'date': t.datetime.astimezone(self.request.event.timezone).date(), + 'entry': t, + 'time': t.datetime.astimezone(self.request.event.timezone) + } + for t in timeline_for_event(self.request.event, subevent) + ] + ctx['today'] = now().astimezone(self.request.event.timezone).date() + ctx['nearly_now'] = now().astimezone(self.request.event.timezone) - timedelta(seconds=20) + # resp['Content-Security-Policy'] = "style-src 'unsafe-inline'" + return ctx diff --git a/src/pretix/static/pretixcontrol/js/ui/dashboard.js b/src/pretix/static/pretixcontrol/js/ui/dashboard.js index 22c1b95931..9945a0c569 100644 --- a/src/pretix/static/pretixcontrol/js/ui/dashboard.js +++ b/src/pretix/static/pretixcontrol/js/ui/dashboard.js @@ -20,3 +20,26 @@ $(function () { add_log_expand_handlers($("#logs_target")) }); }); + +function gettext(msgid) { + if (typeof django !== 'undefined' && typeof django.gettext !== 'undefined') { + return django.gettext(msgid); + } + return msgid; +} + +$(function () { + $(".chart").css("height", "250px"); + new Morris.Area({ + element: 'rev_chart', + data: JSON.parse($("#rev-data").html()), + xkey: 'date', + ykeys: ['revenue'], + labels: [gettext('Total revenue')], + smooth: false, + resize: true, + lineColors: ['#3b1c4a'], + fillOpacity: 0.3, + preUnits: $.trim($("#currency").html()) + ' ' + }); +}); diff --git a/src/pretix/static/pretixcontrol/scss/_dashboard.scss b/src/pretix/static/pretixcontrol/scss/_dashboard.scss index 23a7477e02..973660ddbf 100644 --- a/src/pretix/static/pretixcontrol/scss/_dashboard.scss +++ b/src/pretix/static/pretixcontrol/scss/_dashboard.scss @@ -6,6 +6,11 @@ margin-right: -5px; } +.dashboard.dashboard-panels { + margin-left: -15px; + margin-right: -15px; +} + .dashboard .widget-container { flex: 1 0 auto; align-self: stretch; @@ -63,18 +68,18 @@ } } -.dashboard .shopstate { +.shopstate { text-align: center; - padding: 18px 0; span.live, span.off { display: block; - font-size: 20px; - padding: 10px 0; + font-size: 18px; } + span.live { color: $brand-success; } + span.off { color: $brand-danger; }