Add system report for pretix Enterprise (#4213)

* Add system report for pretix Enterprise

* Update src/pretix/control/sysreport.py

Co-authored-by: Mira <weller@rami.io>

* ADd missing license header

---------

Co-authored-by: Mira <weller@rami.io>
This commit is contained in:
Raphael Michel
2024-06-13 17:08:36 +02:00
committed by GitHub
parent 3b48b0782d
commit e9a95b0b09
5 changed files with 374 additions and 1 deletions

View File

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

View File

@@ -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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
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 = "<br/>".join(lines[:50])
if len(lines) > 50:
d += "<br/>..."
if not d:
return ""
return d

View File

@@ -0,0 +1,35 @@
{% extends "pretixcontrol/global_settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<p>
{% 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." %}
</p>
<form method="post">
{% csrf_token %}
<p>
<label>
{% trans "First month of license term:" %}
<select name="month" class="form-control">
<option value="1">{% trans "January" %}</option>
<option value="2">{% trans "February" %}</option>
<option value="3">{% trans "March" %}</option>
<option value="4">{% trans "April" %}</option>
<option value="5">{% trans "May" %}</option>
<option value="6">{% trans "June" %}</option>
<option value="7">{% trans "July" %}</option>
<option value="8">{% trans "August" %}</option>
<option value="9">{% trans "September" %}</option>
<option value="10">{% trans "October" %}</option>
<option value="11">{% trans "November" %}</option>
<option value="11">{% trans "December" %}</option>
</select>
</label>
</p>
<button type="submit" class="btn btn-primary btn-lg">
{% trans "Generate report" %}
</button>
</form>
{% endblock %}

View File

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

View File

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