diff --git a/src/locale/de/LC_MESSAGES/django.po b/src/locale/de/LC_MESSAGES/django.po index 5f3fc81478..0211a4ed22 100644 --- a/src/locale/de/LC_MESSAGES/django.po +++ b/src/locale/de/LC_MESSAGES/django.po @@ -1668,7 +1668,7 @@ msgstr "Exportieren" #: pretix/control/templates/pretixcontrol/orders/fragment_order_status.html:4 #: pretix/control/templates/pretixcontrol/orders/index.html:20 msgid "Pending" -msgstr "Zahlung ausstehend" +msgstr "ausstehend" #: pretix/control/templates/pretixcontrol/orders/fragment_order_status.html:6 #: pretix/control/templates/pretixcontrol/orders/index.html:19 @@ -1680,7 +1680,7 @@ msgstr "bezahlt" #: pretix/control/templates/pretixcontrol/orders/fragment_order_status.html:8 #: pretix/control/templates/pretixcontrol/orders/index.html:21 msgid "Pending (expired)" -msgstr "Zahlung ausstehend (abgelaufen)" +msgstr "ausstehend (abgelaufen)" #: pretix/control/templates/pretixcontrol/orders/fragment_order_status.html:10 #: pretix/control/templates/pretixcontrol/orders/index.html:22 @@ -1736,7 +1736,7 @@ msgstr "Zeige %(currency)s" #: pretix/control/templates/pretixcontrol/orders/overview.html:20 msgid "Total orders" -msgstr "Bestellungen gesamt" +msgstr "bestellt" #: pretix/control/templates/pretixcontrol/orders/overview.html:21 #: pretix/presale/templates/pretixpresale/event/fragment_order_status.html:4 diff --git a/src/locale/de_Informal/LC_MESSAGES/django.po b/src/locale/de_Informal/LC_MESSAGES/django.po index f4f1fac703..a674849f8f 100644 --- a/src/locale/de_Informal/LC_MESSAGES/django.po +++ b/src/locale/de_Informal/LC_MESSAGES/django.po @@ -1664,7 +1664,7 @@ msgstr "Exportieren" #: pretix/control/templates/pretixcontrol/orders/fragment_order_status.html:4 #: pretix/control/templates/pretixcontrol/orders/index.html:20 msgid "Pending" -msgstr "Zahlung ausstehend" +msgstr "ausstehend" #: pretix/control/templates/pretixcontrol/orders/fragment_order_status.html:6 #: pretix/control/templates/pretixcontrol/orders/index.html:19 @@ -1676,7 +1676,7 @@ msgstr "bezahlt" #: pretix/control/templates/pretixcontrol/orders/fragment_order_status.html:8 #: pretix/control/templates/pretixcontrol/orders/index.html:21 msgid "Pending (expired)" -msgstr "Zahlung ausstehend (abgelaufen)" +msgstr "ausstehend (abgelaufen)" #: pretix/control/templates/pretixcontrol/orders/fragment_order_status.html:10 #: pretix/control/templates/pretixcontrol/orders/index.html:22 @@ -1732,7 +1732,7 @@ msgstr "Zeige %(currency)s" #: pretix/control/templates/pretixcontrol/orders/overview.html:20 msgid "Total orders" -msgstr "Bestellungen gesamt" +msgstr "bestellt" #: pretix/control/templates/pretixcontrol/orders/overview.html:21 #: pretix/presale/templates/pretixpresale/event/fragment_order_status.html:4 diff --git a/src/pretix/base/services/stats.py b/src/pretix/base/services/stats.py new file mode 100644 index 0000000000..1602af3969 --- /dev/null +++ b/src/pretix/base/services/stats.py @@ -0,0 +1,99 @@ +from django.db.models import Count, Sum +from django.utils.translation import ugettext_lazy as _ + +from pretix.base.models import ItemCategory, Order, OrderPosition + + +def tuplesum(tuples): + return tuple(map(sum, zip(*list(tuples)))) + + +def order_overview(event): + items = event.items.all().select_related( + 'category', # for re-grouping + ).prefetch_related( + 'properties', # for .get_all_available_variations() + ).order_by('category__position', 'category_id', 'name') + + num_total = { + (p['item'], p['variation']): (p['cnt'], p['price']) + for p in + OrderPosition.objects.current.filter(order__event=event).values('item', 'variation').annotate( + cnt=Count('id'), price=Sum('price')) + } + num_cancelled = { + (p['item'], p['variation']): (p['cnt'], p['price']) + for p in (OrderPosition.objects.current + .filter(order__event=event, order__status=Order.STATUS_CANCELLED) + .values('item', 'variation') + .annotate(cnt=Count('id'), price=Sum('price'))) + } + num_refunded = { + (p['item'], p['variation']): (p['cnt'], p['price']) + for p in (OrderPosition.objects.current + .filter(order__event=event, order__status=Order.STATUS_REFUNDED) + .values('item', 'variation') + .annotate(cnt=Count('id'), price=Sum('price'))) + } + num_pending = { + (p['item'], p['variation']): (p['cnt'], p['price']) + for p in (OrderPosition.objects.current + .filter(order__event=event, + order__status__in=(Order.STATUS_PENDING, Order.STATUS_EXPIRED)) + .values('item', 'variation') + .annotate(cnt=Count('id'), price=Sum('price'))) + } + num_paid = { + (p['item'], p['variation']): (p['cnt'], p['price']) + for p in (OrderPosition.objects.current + .filter(order__event=event, order__status=Order.STATUS_PAID) + .values('item', 'variation') + .annotate(cnt=Count('id'), price=Sum('price'))) + } + + for item in items: + item.all_variations = sorted(item.get_all_variations(), + key=lambda vd: vd.ordered_values()) + for var in item.all_variations: + variid = var['variation'].identity if 'variation' in var else None + var.num_total = num_total.get((item.identity, variid), (0, 0)) + var.num_pending = num_pending.get((item.identity, variid), (0, 0)) + var.num_cancelled = num_cancelled.get((item.identity, variid), (0, 0)) + var.num_refunded = num_refunded.get((item.identity, variid), (0, 0)) + var.num_paid = num_paid.get((item.identity, variid), (0, 0)) + item.has_variations = (len(item.all_variations) != 1 + or not item.all_variations[0].empty()) + item.num_total = tuplesum(var.num_total for var in item.all_variations) + item.num_pending = tuplesum(var.num_pending for var in item.all_variations) + item.num_cancelled = tuplesum(var.num_cancelled for var in item.all_variations) + item.num_refunded = tuplesum(var.num_refunded for var in item.all_variations) + item.num_paid = tuplesum(var.num_paid for var in item.all_variations) + + nonecat = ItemCategory(name=_('Uncategorized')) + # Regroup those by category + items_by_category = sorted( + [ + # a group is a tuple of a category and a list of items + (cat if cat is not None else nonecat, [i for i in items if i.category == cat]) + for cat in set([i.category for i in items]) + # insert categories into a set for uniqueness + # a set is unsorted, so sort again by category + ], + key=lambda group: (group[0].position, group[0].identity) if group[0] is not None else (0, "") + ) + for c in items_by_category: + c[0].num_total = tuplesum(item.num_total for item in c[1]) + c[0].num_pending = tuplesum(item.num_pending for item in c[1]) + c[0].num_cancelled = tuplesum(item.num_cancelled for item in c[1]) + c[0].num_refunded = tuplesum(item.num_refunded for item in c[1]) + c[0].num_paid = tuplesum(item.num_paid for item in c[1]) + + total = { + 'num_total': tuplesum(c.num_total for c, i in items_by_category), + 'num_pending': tuplesum(c.num_pending for c, i in items_by_category), + 'num_cancelled': tuplesum(c.num_cancelled for c, i in items_by_category), + 'num_refunded': tuplesum(c.num_refunded for c, i in items_by_category), + 'num_paid': tuplesum(c.num_paid for c, i in items_by_category) + } + + return items_by_category, total diff --git a/src/pretix/control/views/main.py b/src/pretix/control/views/main.py index eb1312360f..5324b7103d 100644 --- a/src/pretix/control/views/main.py +++ b/src/pretix/control/views/main.py @@ -1,9 +1,9 @@ +from django.conf import settings from django.contrib import messages from django.core.urlresolvers import reverse from django.shortcuts import render from django.utils.translation import ugettext_lazy as _ from django.views.generic import CreateView, ListView, TemplateView -from django.conf import settings from pretix.base.models import Event, EventPermission, OrganizerPermission from pretix.control.forms.event import EventCreateForm diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 7ff9e7c9c3..dccc84d97c 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -3,7 +3,7 @@ from itertools import groupby from django import forms from django.contrib import messages from django.core.urlresolvers import reverse -from django.db.models import Q, Count, Sum +from django.db.models import Q from django.http import HttpResponse from django.shortcuts import redirect, render from django.utils.functional import cached_property @@ -11,8 +11,9 @@ from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from django.views.generic import DetailView, ListView, TemplateView, View -from pretix.base.models import Item, ItemCategory, Order, OrderPosition, Quota +from pretix.base.models import Item, Order, Quota from pretix.base.services.orders import mark_order_paid +from pretix.base.services.stats import order_overview from pretix.base.signals import ( register_data_exporters, register_payment_providers, register_ticket_outputs, @@ -260,103 +261,13 @@ class OrderExtend(OrderView): data=self.request.POST if self.request.method == "POST" else None) -def tuplesum(tuples): - return tuple(map(sum, zip(*list(tuples)))) - - class OverView(EventPermissionRequiredMixin, TemplateView): template_name = 'pretixcontrol/orders/overview.html' permission = 'can_view_orders' def get_context_data(self, **kwargs): ctx = super().get_context_data() - items = self.request.event.items.all().select_related( - 'category', # for re-grouping - ).prefetch_related( - 'properties', # for .get_all_available_variations() - ).order_by('category__position', 'category_id', 'name') - - num_total = { - (p['item'], p['variation']): (p['cnt'], p['price']) - for p in - OrderPosition.objects.current.filter(order__event=self.request.event).values('item', 'variation').annotate( - cnt=Count('id'), price=Sum('price')) - } - num_cancelled = { - (p['item'], p['variation']): (p['cnt'], p['price']) - for p in (OrderPosition.objects.current - .filter(order__event=self.request.event, order__status=Order.STATUS_CANCELLED) - .values('item', 'variation') - .annotate(cnt=Count('id'), price=Sum('price'))) - } - num_refunded = { - (p['item'], p['variation']): (p['cnt'], p['price']) - for p in (OrderPosition.objects.current - .filter(order__event=self.request.event, order__status=Order.STATUS_REFUNDED) - .values('item', 'variation') - .annotate(cnt=Count('id'), price=Sum('price'))) - } - num_pending = { - (p['item'], p['variation']): (p['cnt'], p['price']) - for p in (OrderPosition.objects.current - .filter(order__event=self.request.event, - order__status__in=(Order.STATUS_PENDING, Order.STATUS_EXPIRED)) - .values('item', 'variation') - .annotate(cnt=Count('id'), price=Sum('price'))) - } - num_paid = { - (p['item'], p['variation']): (p['cnt'], p['price']) - for p in (OrderPosition.objects.current - .filter(order__event=self.request.event, order__status=Order.STATUS_PAID) - .values('item', 'variation') - .annotate(cnt=Count('id'), price=Sum('price'))) - } - - for item in items: - item.all_variations = sorted(item.get_all_variations(), - key=lambda vd: vd.ordered_values()) - for var in item.all_variations: - variid = var['variation'].identity if 'variation' in var else None - var.num_total = num_total.get((item.identity, variid), (0, 0)) - var.num_pending = num_pending.get((item.identity, variid), (0, 0)) - var.num_cancelled = num_cancelled.get((item.identity, variid), (0, 0)) - var.num_refunded = num_refunded.get((item.identity, variid), (0, 0)) - var.num_paid = num_paid.get((item.identity, variid), (0, 0)) - item.has_variations = (len(item.all_variations) != 1 - or not item.all_variations[0].empty()) - item.num_total = tuplesum(var.num_total for var in item.all_variations) - item.num_pending = tuplesum(var.num_pending for var in item.all_variations) - item.num_cancelled = tuplesum(var.num_cancelled for var in item.all_variations) - item.num_refunded = tuplesum(var.num_refunded for var in item.all_variations) - item.num_paid = tuplesum(var.num_paid for var in item.all_variations) - - nonecat = ItemCategory(name=_('Uncategorized')) - # Regroup those by category - ctx['items_by_category'] = sorted( - [ - # a group is a tuple of a category and a list of items - (cat if cat is not None else nonecat, [i for i in items if i.category == cat]) - for cat in set([i.category for i in items]) - # insert categories into a set for uniqueness - # a set is unsorted, so sort again by category - ], - key=lambda group: (group[0].position, group[0].identity) if group[0] is not None else (0, "") - ) - for c in ctx['items_by_category']: - c[0].num_total = tuplesum(item.num_total for item in c[1]) - c[0].num_pending = tuplesum(item.num_pending for item in c[1]) - c[0].num_cancelled = tuplesum(item.num_cancelled for item in c[1]) - c[0].num_refunded = tuplesum(item.num_refunded for item in c[1]) - c[0].num_paid = tuplesum(item.num_paid for item in c[1]) - - ctx['total'] = { - 'num_total': tuplesum(c.num_total for c, i in ctx['items_by_category']), - 'num_pending': tuplesum(c.num_pending for c, i in ctx['items_by_category']), - 'num_cancelled': tuplesum(c.num_cancelled for c, i in ctx['items_by_category']), - 'num_refunded': tuplesum(c.num_refunded for c, i in ctx['items_by_category']), - 'num_paid': tuplesum(c.num_paid for c, i in ctx['items_by_category']) - } - + ctx['items_by_category'], ctx['total'] = order_overview(self.request.event) return ctx diff --git a/src/pretix/plugins/reports/__init__.py b/src/pretix/plugins/reports/__init__.py new file mode 100644 index 0000000000..9c98792dc8 --- /dev/null +++ b/src/pretix/plugins/reports/__init__.py @@ -0,0 +1,32 @@ +from django.apps import AppConfig +from django.utils.functional import cached_property +from django.utils.translation import ugettext_lazy as _ + +from pretix import __version__ as version +from pretix.base.plugins import PluginType + + +class ReportsApp(AppConfig): + name = 'pretix.plugins.reports' + verbose_name = _("Report exporter") + + class PretixPluginMeta: + type = PluginType.PAYMENT + name = _("Report exporter") + author = _("the pretix team") + version = version + description = _("This plugin allows you to generate printable reports about your sales.") + + def ready(self): + from . import signals # NOQA + + @cached_property + def compatibility_errors(self): + errs = [] + try: + import reportlab # NOQA + except ImportError: + errs.append("Python package 'reportlab' is not installed.") + return errs + +default_app_config = 'pretix.plugins.reports.ReportsApp' diff --git a/src/pretix/plugins/reports/exporters.py b/src/pretix/plugins/reports/exporters.py new file mode 100644 index 0000000000..18ae778cc8 --- /dev/null +++ b/src/pretix/plugins/reports/exporters.py @@ -0,0 +1,214 @@ +import tempfile + +from django.conf import settings +from django.contrib.staticfiles import finders +from django.http import HttpResponse +from django.utils.timezone import now +from django.utils.translation import ugettext as _ + +from pretix.base.exporter import BaseExporter +from pretix.base.services.stats import order_overview + + +class Report: + name = "report" + + def __init__(self, event): + self.event = event + + @property + def pagesize(self): + from reportlab.lib import pagesizes + + return pagesizes.portrait(pagesizes.A4) + + def get_filename(self): + return "%s-%s.pdf" % (self.name, now().strftime("%Y-%m-%d-%H-%M-%S")) + + @staticmethod + def register_fonts(): + from reportlab.pdfbase.ttfonts import TTFont + from reportlab.pdfbase import pdfmetrics + + pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf'))) + pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf'))) + pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf'))) + + def create(self): + from reportlab.platypus import BaseDocTemplate, PageTemplate + from reportlab.lib.units import mm + + with tempfile.NamedTemporaryFile(suffix=".pdf") as f: + Report.register_fonts() + doc = BaseDocTemplate(f.name, pagesize=self.pagesize, + leftMargin=15 * mm, + rightMargin=15 * mm, + topMargin=20 * mm, + bottomMargin=15 * mm) + doc.addPageTemplates([ + PageTemplate(id='All', frames=self.get_frames(doc), onPage=self.on_page, pagesize=self.pagesize) + ]) + doc.build(self.get_story(doc)) + f.seek(0) + return f.read() + + def get_frames(self, doc): + from reportlab.platypus import Frame + + self.frame = Frame(doc.leftMargin, doc.bottomMargin, + doc.width, + doc.height, + leftPadding=0, + rightPadding=0, + topPadding=0, + bottomPadding=0, + id='normal') + return [self.frame] + + def get_story(self, doc): + return [] + + def get_style(self): + from reportlab.lib.styles import getSampleStyleSheet + + styles = getSampleStyleSheet() + style = styles["Normal"] + style.fontName = 'OpenSans' + return style + + def on_page(self, canvas, doc): + canvas.saveState() + self.page_footer(canvas, doc) + self.page_header(canvas, doc) + canvas.restoreState() + + def page_footer(self, canvas, doc): + from reportlab.lib.units import mm + + canvas.setFont('OpenSans', 8) + canvas.drawString(15 * mm, 10 * mm, _("Page %d") % (doc.page,)) + canvas.drawRightString(self.pagesize[0] - 15 * mm, 10 * mm, + _("Created: %s") % now().strftime("%d.%m.%Y %H:%M:%S")) + + def page_header(self, canvas, doc): + from reportlab.lib.units import mm + + canvas.setFont('OpenSans', 10) + canvas.drawString(15 * mm, self.pagesize[1] - 15 * mm, + "%s – %s" % (self.event.organizer.name, self.event.name)) + canvas.drawRightString(self.pagesize[0] - 15 * mm, self.pagesize[1] - 15 * mm, + settings.PRETIX_INSTANCE_NAME) + canvas.setStrokeColorRGB(0, 0, 0) + canvas.line(15 * mm, self.pagesize[1] - 17 * mm, + self.pagesize[0] - 15 * mm, self.pagesize[1] - 17 * mm) + + +class OverviewReport(Report): + name = "overview" + + @property + def pagesize(self): + from reportlab.lib import pagesizes + + return pagesizes.landscape(pagesizes.A4) + + def get_story(self, doc): + from reportlab.platypus import Paragraph, Spacer, TableStyle, Table + from reportlab.lib.units import mm + + headlinestyle = self.get_style() + headlinestyle.fontSize = 15 + headlinestyle.fontName = 'OpenSansBd' + colwidths = [a * doc.width for a in (.30, .06, .08, .06, .08, .06, .08, .06, .08, .06, .08)] + tstyledata = [ + ('SPAN', (1, 0), (2, 0)), + ('SPAN', (3, 0), (4, 0)), + ('SPAN', (5, 0), (6, 0)), + ('SPAN', (7, 0), (8, 0)), + ('SPAN', (9, 0), (10, 0)), + ('ALIGN', (0, 0), (-1, 0), 'CENTER'), + ('ALIGN', (1, 1), (-1, -1), 'RIGHT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'), + ('FONTNAME', (0, -1), (-1, -1), 'OpenSansBd'), + ] + + story = [ + Paragraph(_('Orders by product'), headlinestyle), + Spacer(1, 5 * mm) + ] + tdata = [ + [ + _('Product'), _('Total orders'), '', _('Pending'), '', _('Cancelled'), '', _('Refunded'), '', + _('Paid'), '' + ], + [ + '', + _('Number'), self.event.currency, + _('Number'), self.event.currency, + _('Number'), self.event.currency, + _('Number'), self.event.currency, + _('Number'), self.event.currency + ], + ] + + items_by_category, total = order_overview(self.event) + + for tup in items_by_category: + if tup[0]: + tstyledata.append(('FONTNAME', (0, len(tdata)), (-1, len(tdata)), 'OpenSansBd')) + tdata.append([ + tup[0].name, + str(tup[0].num_total[0]), str(tup[0].num_total[1]), + str(tup[0].num_pending[0]), str(tup[0].num_pending[1]), + str(tup[0].num_cancelled[0]), str(tup[0].num_cancelled[1]), + str(tup[0].num_refunded[0]), str(tup[0].num_refunded[1]), + str(tup[0].num_paid[0]), str(tup[0].num_paid[1]) + ]) + for item in tup[1]: + tdata.append([ + item.name, + str(item.num_total[0]), str(item.num_total[1]), + str(item.num_pending[0]), str(item.num_pending[1]), + str(item.num_cancelled[0]), str(item.num_cancelled[1]), + str(item.num_refunded[0]), str(item.num_refunded[1]), + str(item.num_paid[0]), str(item.num_paid[1]) + ]) + if item.has_variations: + for var in item.all_variations: + tdata.append([ + " " + str(var), + str(var.num_total[0]), str(var.num_total[1]), + str(var.num_pending[0]), str(var.num_pending[1]), + str(var.num_cancelled[0]), str(var.num_cancelled[1]), + str(var.num_refunded[0]), str(var.num_refunded[1]), + str(var.num_paid[0]), str(var.num_paid[1]) + ]) + + tdata.append([ + _("Total"), + str(total['num_total'][0]), str(total['num_total'][1]), + str(total['num_pending'][0]), str(total['num_pending'][1]), + str(total['num_cancelled'][0]), str(total['num_cancelled'][1]), + str(total['num_refunded'][0]), str(total['num_refunded'][1]), + str(total['num_paid'][0]), str(total['num_paid'][1]) + ]) + + table = Table(tdata, colWidths=colwidths, repeatRows=2) + table.setStyle(TableStyle(tstyledata)) + story.append(table) + return story + + +class OverviewReportExporter(BaseExporter): + identifier = 'pdfreport' + verbose_name = _('Order overview (PDF)') + + def render(self, request): + response = HttpResponse(content_type='application/pdf') + response['Content-Disposition'] = 'inline; filename="report-%s.pdf"' % request.event.slug + + report = OverviewReport(request.event) + + response.write(report.create()) + return response diff --git a/src/pretix/plugins/reports/signals.py b/src/pretix/plugins/reports/signals.py new file mode 100644 index 0000000000..22ab036e25 --- /dev/null +++ b/src/pretix/plugins/reports/signals.py @@ -0,0 +1,9 @@ +from django.dispatch import receiver + +from pretix.base.signals import register_data_exporters + + +@receiver(register_data_exporters, dispatch_uid="export_overview_report_pdf") +def register_report_pdf(sender, **kwargs): + from .exporters import OverviewReportExporter + return OverviewReportExporter diff --git a/src/pretix/settings.py b/src/pretix/settings.py index a5295b4d77..3aa2b5fadf 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -142,6 +142,7 @@ INSTALLED_APPS = ( 'pretix.plugins.ticketoutputpdf', 'pretix.plugins.sendmail', 'pretix.plugins.statistics', + 'pretix.plugins.reports', 'pretix.plugins.pretixdroid', 'easy_thumbnails', ) diff --git a/src/static/fonts/OpenSans-Bold.ttf b/src/static/fonts/OpenSans-Bold.ttf new file mode 100644 index 0000000000..fd79d43bea Binary files /dev/null and b/src/static/fonts/OpenSans-Bold.ttf differ diff --git a/src/static/fonts/OpenSans-Italic.ttf b/src/static/fonts/OpenSans-Italic.ttf new file mode 100644 index 0000000000..c90da48ff3 Binary files /dev/null and b/src/static/fonts/OpenSans-Italic.ttf differ diff --git a/src/static/fonts/OpenSans-Regular.ttf b/src/static/fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000000..db433349b7 Binary files /dev/null and b/src/static/fonts/OpenSans-Regular.ttf differ