From e9a95b0b09f07a69dbb600296e9b4f469dd7824a Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 13 Jun 2024 17:08:36 +0200 Subject: [PATCH] Add system report for pretix Enterprise (#4213) * Add system report for pretix Enterprise * Update src/pretix/control/sysreport.py Co-authored-by: Mira * ADd missing license header --------- Co-authored-by: Mira --- src/pretix/control/navigation.py | 5 + src/pretix/control/sysreport.py | 307 ++++++++++++++++++ .../pretixcontrol/global_sysreport.html | 35 ++ src/pretix/control/urls.py | 1 + src/pretix/control/views/global_settings.py | 27 +- 5 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 src/pretix/control/sysreport.py create mode 100644 src/pretix/control/templates/pretixcontrol/global_sysreport.html diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index 41e68ca974..46b8cc5a32 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -444,6 +444,11 @@ def get_global_navigation(request): 'url': reverse('control:global.license'), 'active': (url.url_name == 'global.license'), }, + { + 'label': _('System report'), + 'url': reverse('control:global.sysreport'), + 'active': (url.url_name == 'global.sysreport'), + }, ] }) diff --git a/src/pretix/control/sysreport.py b/src/pretix/control/sysreport.py new file mode 100644 index 0000000000..0c86deb263 --- /dev/null +++ b/src/pretix/control/sysreport.py @@ -0,0 +1,307 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import os +import platform +import sys +import zoneinfo +from datetime import datetime, timedelta + +from django.conf import settings +from django.db.models import Count, Exists, F, Min, OuterRef, Q, Sum +from django.utils.formats import date_format +from django.utils.timezone import now +from reportlab.lib import pagesizes +from reportlab.lib.enums import TA_CENTER +from reportlab.lib.units import mm +from reportlab.platypus import Paragraph, Spacer, Table, TableStyle + +from pretix import __version__ +from pretix.base.models import Order, OrderPayment, Transaction +from pretix.base.plugins import get_all_plugins +from pretix.base.templatetags.money import money_filter +from pretix.plugins.reports.exporters import ReportlabExportMixin +from pretix.settings import DATA_DIR + + +class SysReport(ReportlabExportMixin): + @property + def pagesize(self): + return pagesizes.portrait(pagesizes.A4) + + def __init__(self, start_month, tzname): + self.tzname = tzname + self.tz = zoneinfo.ZoneInfo(tzname) + self.start_month = start_month + + def page_header(self, canvas, doc): + pass + + 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] - doc.rightMargin, + 10 * mm, + "Created: %s" + % date_format(now().astimezone(self.tz), "SHORT_DATETIME_FORMAT"), + ) + + def render(self): + return "sysreport.pdf", "application/pdf", self.create({}) + + def get_story(self, doc, form_data): + headlinestyle = self.get_style() + headlinestyle.fontSize = 15 + subheadlinestyle = self.get_style() + subheadlinestyle.fontSize = 13 + style_small = self.get_style() + style_small.fontSize = 6 + + story = [ + Paragraph("System report", headlinestyle), + Spacer(1, 5 * mm), + Paragraph("Usage", subheadlinestyle), + Spacer(1, 5 * mm), + self._usage_table(), + Spacer(1, 5 * mm), + Paragraph("Installed versions", subheadlinestyle), + Spacer(1, 5 * mm), + self._tech_table(), + Spacer(1, 5 * mm), + Paragraph("Plugins", subheadlinestyle), + Spacer(1, 5 * mm), + Paragraph(self._get_plugin_versions(), style_small), + Spacer(1, 5 * mm), + Paragraph("Custom templates", subheadlinestyle), + Spacer(1, 5 * mm), + Paragraph(self._get_custom_templates(), style_small), + Spacer(1, 5 * mm), + ] + + return story + + def _tech_table(self): + style = self.get_style() + style.fontSize = 8 + style_small = self.get_style() + style_small.fontSize = 6 + + w = self.pagesize[0] - 30 * mm + colwidths = [ + a * w + for a in ( + 0.2, + 0.8, + ) + ] + tstyledata = [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (0, -1), 0), + ("RIGHTPADDING", (-1, 0), (-1, -1), 0), + ] + tdata = [ + [Paragraph("Site URL:", style), Paragraph(settings.SITE_URL, style)], + [Paragraph("pretix version:", style), Paragraph(__version__, style)], + [Paragraph("Python version:", style), Paragraph(sys.version, style)], + [Paragraph("Platform:", style), Paragraph(platform.platform(), style)], + [ + Paragraph("Database engine:", style), + Paragraph(settings.DATABASES["default"]["ENGINE"], style), + ], + ] + table = Table(tdata, colWidths=colwidths, repeatRows=0) + table.setStyle(TableStyle(tstyledata)) + return table + + def _usage_table(self): + style = self.get_style() + style.fontSize = 8 + style_small = self.get_style() + style_small.fontSize = 6 + style_small.leading = 8 + style_small.alignment = TA_CENTER + style_small_head = self.get_style() + style_small_head.fontSize = 6 + style_small_head.leading = 8 + style_small_head.alignment = TA_CENTER + style_small_head.fontName = "OpenSansBd" + + w = self.pagesize[0] - 30 * mm + + successful = ( + Q(status=Order.STATUS_PAID) + | Q(valid_if_pending=True, status=Order.STATUS_PENDING) + | Q( + Exists( + OrderPayment.objects.filter( + order_id=OuterRef("pk"), + state__in=( + OrderPayment.PAYMENT_STATE_CONFIRMED, + OrderPayment.PAYMENT_STATE_REFUNDED, + ), + ) + ), + ) + ) + orders_q = Order.objects.filter( + successful, + testmode=False, + ) + orders_testmode_q = Order.objects.filter( + testmode=True, + ) + orders_unconfirmed_q = Order.objects.filter( + ~successful, + testmode=False, + ) + revenue_q = Transaction.objects.filter( + Exists( + OrderPayment.objects.filter( + order_id=OuterRef("order_id"), + state__in=( + OrderPayment.PAYMENT_STATE_CONFIRMED, + OrderPayment.PAYMENT_STATE_REFUNDED, + ), + ) + ), + order__testmode=False, + ) + + currencies = sorted( + list( + set( + Transaction.objects.annotate(c=F("order__event__currency")) + .values_list("c", flat=True) + .distinct() + ) + ) + ) + + year_first = orders_q.aggregate(m=Min("datetime__year"))["m"] + if not year_first: + year_first = now().year + elif datetime.now().month - 1 <= self.start_month: + year_first -= 1 + year_last = now().year + tdata = [ + [ + Paragraph(l, style_small_head) + for l in ( + "Time frame", + "Currency", + "Successful orders", + "Net revenue", + "Testmode orders", + "Unsucessful orders", + "Positions", + "Gross revenue", + ) + ] + ] + + for year in range(year_first, year_last + 1): + for i, c in enumerate(currencies): + first_day = datetime( + year, self.start_month, 1, 0, 0, 0, 0, tzinfo=self.tz + ) + after_day = datetime( + year + 1, self.start_month, 1, 0, 0, 0, 0, tzinfo=self.tz + ) + + orders_count = ( + orders_q.filter( + datetime__gte=first_day, datetime__lt=after_day + ).aggregate(c=Count("*"))["c"] + or 0 + ) + testmode_count = ( + orders_testmode_q.filter( + datetime__gte=first_day, datetime__lt=after_day + ).aggregate(c=Count("*"))["c"] + or 0 + ) + unconfirmed_count = ( + orders_unconfirmed_q.filter( + datetime__gte=first_day, datetime__lt=after_day + ).aggregate(c=Count("*"))["c"] + or 0 + ) + revenue_data = revenue_q.filter( + datetime__gte=first_day, datetime__lt=after_day, order__event__currency=c + ).aggregate( + c=Sum("count"), + s_net=Sum(F("price") - F("tax_value")), + s_gross=Sum(F("price")), + ) + + tdata.append( + ( + Paragraph( + date_format(first_day, "M Y") + + " – " + + date_format(after_day - timedelta(days=1), "M Y"), + style_small, + ), + Paragraph(c, style_small), + Paragraph(str(orders_count), style_small) if i == 0 else "", + Paragraph(money_filter(revenue_data.get("s_net") or 0, c), style_small), + Paragraph(str(testmode_count), style_small) if i == 0 else "", + Paragraph(str(unconfirmed_count), style_small) if i == 0 else "", + Paragraph(str(revenue_data.get("c") or 0), style_small), + Paragraph(money_filter(revenue_data.get("s_gross") or 0, c), style_small), + ) + ) + + colwidths = [a * w for a in (0.18,) + (0.82 / 7,) * 7] + tstyledata = [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("LEFTPADDING", (0, 0), (0, -1), 0), + ("RIGHTPADDING", (-1, 0), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 1), + ] + table = Table(tdata, colWidths=colwidths, repeatRows=0) + table.setStyle(TableStyle(tstyledata)) + return table + + def _get_plugin_versions(self): + lines = [] + for p in get_all_plugins(): + lines.append(f"{p.name} {p.version}") + return ", ".join(lines) + + def _get_custom_templates(self): + lines = [] + for dirpath, dirnames, filenames in os.walk( + os.path.join(DATA_DIR, "templates") + ): + for f in filenames: + lines.append(f"{dirpath}/{f}") + + d = "
".join(lines[:50]) + if len(lines) > 50: + d += "
..." + if not d: + return "–" + return d diff --git a/src/pretix/control/templates/pretixcontrol/global_sysreport.html b/src/pretix/control/templates/pretixcontrol/global_sysreport.html new file mode 100644 index 0000000000..20689567cf --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/global_sysreport.html @@ -0,0 +1,35 @@ +{% extends "pretixcontrol/global_settings_base.html" %} +{% load i18n %} +{% load bootstrap3 %} + +{% block inner %} +

+ {% trans "If you have a pretix Enterprise license, this report must be submitted to pretix support when your license renews. It may also be requested by pretix support to aid debugging of problems." %} + {% trans "It serves two purposes: Collecting useful information that might help with debugging problems in your pretix installation, and verifying that your usage of pretix is in compliance with the Enterprise license you purchased." %} +

+
+ {% csrf_token %} +

+ +

+ +
+{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index eb6b4a0a38..1ce039366c 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -56,6 +56,7 @@ urlpatterns = [ re_path(r'^global/settings/$', global_settings.GlobalSettingsView.as_view(), name='global.settings'), re_path(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='global.update'), re_path(r'^global/license/$', global_settings.LicenseCheckView.as_view(), name='global.license'), + re_path(r'^global/sysreport/$', global_settings.SysReportView.as_view(), name='global.sysreport'), re_path(r'^global/message/$', global_settings.MessageView.as_view(), name='global.message'), re_path(r'^logdetail/$', global_settings.LogDetailView.as_view(), name='global.logdetail'), re_path(r'^logdetail/payment/$', global_settings.PaymentDetailView.as_view(), name='global.paymentdetail'), diff --git a/src/pretix/control/views/global_settings.py b/src/pretix/control/views/global_settings.py index 2db9ff60d6..c8b506a15b 100644 --- a/src/pretix/control/views/global_settings.py +++ b/src/pretix/control/views/global_settings.py @@ -32,14 +32,16 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. import importlib_metadata as metadata +from django.conf import settings from django.contrib import messages -from django.http import JsonResponse +from django.http import HttpResponse, JsonResponse from django.shortcuts import get_object_or_404, redirect, reverse from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django.views import View from django.views.generic import FormView, TemplateView +from pretix.base.i18n import language from pretix.base.models import LogEntry, OrderPayment, OrderRefund from pretix.base.services.update_check import check_result_table, update_check from pretix.base.settings import GlobalSettingsObject @@ -49,6 +51,7 @@ from pretix.control.forms.global_settings import ( from pretix.control.permissions import ( AdministratorPermissionRequiredMixin, StaffMemberRequiredMixin, ) +from pretix.control.sysreport import SysReport class GlobalSettingsView(AdministratorPermissionRequiredMixin, FormView): @@ -262,3 +265,25 @@ class LicenseCheckView(StaffMemberRequiredMixin, FormView): )) return res + + +class SysReportView(AdministratorPermissionRequiredMixin, TemplateView): + template_name = 'pretixcontrol/global_sysreport.html' + + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + with language("en"): + try: + month = int(request.POST.get("month")) + except ValueError: + return super().get(request, *args, **kwargs) + if month < 1 or month > 12: + return super().get(request, *args, **kwargs) + name, mime, data = SysReport(month, settings.TIME_ZONE).render() + resp = HttpResponse(data) + resp['Content-Type'] = mime + resp['Content-Disposition'] = 'inline; filename="{}"'.format(name) + resp._csp_ignore = True + return resp