diff --git a/src/pretix/base/__init__.py b/src/pretix/base/__init__.py index d30036e03..db8b41e7e 100644 --- a/src/pretix/base/__init__.py +++ b/src/pretix/base/__init__.py @@ -9,7 +9,7 @@ class PretixBaseConfig(AppConfig): from . import exporter # NOQA from . import payment # NOQA from . import exporters # NOQA - from .services import export, mail, tickets, cart, orders, cleanup # NOQA + from .services import export, mail, tickets, cart, orders, cleanup, update_check # NOQA try: from .celery_app import app as celery_app # NOQA diff --git a/src/pretix/base/management/commands/rebuild.py b/src/pretix/base/management/commands/rebuild.py index d04edd4f1..16e0e49c1 100644 --- a/src/pretix/base/management/commands/rebuild.py +++ b/src/pretix/base/management/commands/rebuild.py @@ -1,6 +1,8 @@ from django.core.management import call_command from django.core.management.base import BaseCommand +from pretix.base.settings import GlobalSettingsObject + class Command(BaseCommand): help = "Rebuild static files and language files" @@ -10,3 +12,7 @@ class Command(BaseCommand): call_command('compilejsi18n', verbosity=1, interactive=False) call_command('collectstatic', verbosity=1, interactive=False) call_command('compress', verbosity=1, interactive=False) + gs = GlobalSettingsObject() + del gs.settings.update_check_last + del gs.settings.update_check_result + del gs.settings.update_check_result_warning diff --git a/src/pretix/base/services/update_check.py b/src/pretix/base/services/update_check.py new file mode 100644 index 000000000..c43781ebb --- /dev/null +++ b/src/pretix/base/services/update_check.py @@ -0,0 +1,125 @@ +import sys +import uuid +from datetime import timedelta + +import requests +from django.dispatch import receiver +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _, ugettext_noop +from i18nfield.strings import LazyI18nString + +from pretix import __version__ +from pretix.base.models import Event +from pretix.base.plugins import get_all_plugins +from pretix.base.services.mail import mail +from pretix.base.settings import GlobalSettingsObject +from pretix.base.signals import periodic_task +from pretix.celery_app import app +from pretix.helpers.urls import build_absolute_uri + + +@receiver(signal=periodic_task) +def run_update_check(sender, **kwargs): + gs = GlobalSettingsObject() + if not gs.settings.update_check_perform: + return + + if not gs.settings.update_check_last or now() - gs.settings.update_check_last > timedelta(hours=23): + update_check.apply_async() + + +@app.task +def update_check(): + gs = GlobalSettingsObject() + if not gs.settings.update_check_perform: + return + + if not gs.settings.update_check_id: + gs.settings.set('update_check_id', uuid.uuid4().hex) + + if 'runserver' in sys.argv: + gs.settings.set('update_check_last', now()) + gs.settings.set('update_check_result', { + 'error': 'development' + }) + return + + check_payload = { + 'id': gs.settings.get('update_check_id'), + 'version': __version__, + 'events': { + 'total': Event.objects.count(), + 'live': Event.objects.filter(live=True).count(), + }, + 'plugins': [ + { + 'name': p.module, + 'version': p.version + } for p in get_all_plugins() + ] + } + try: + r = requests.post('https://pretix.eu/.update_check/', json=check_payload) + gs.settings.set('update_check_last', now()) + if r.status_code != 200: + gs.settings.set('update_check_result', { + 'error': 'http_error' + }) + else: + rdata = r.json() + update_available = rdata['version']['updatable'] or any(p['updatable'] for p in rdata['plugins'].values()) + gs.settings.set('update_check_result_warning', update_available) + if update_available and rdata != gs.settings.update_check_result: + send_update_notification_email() + gs.settings.set('update_check_result', rdata) + except requests.RequestException: + gs.settings.set('update_check_last', now()) + gs.settings.set('update_check_result', { + 'error': 'unavailable' + }) + + +def send_update_notification_email(): + gs = GlobalSettingsObject() + if not gs.settings.update_check_email: + return + + mail( + gs.settings.update_check_email, + _('pretix update available'), + LazyI18nString.from_gettext( + ugettext_noop( + 'Hi!\n\nAn update is available for pretix or for one of the plugins you installed in your ' + 'pretix installation. Please click on the following link for more information:\n\n {url} \n\n' + 'You can always find information on the latest updates on the pretix.eu blog:\n\n' + 'https://pretix.eu/about/en/blog/' + '\n\nBest,\n\nyour pretix developers' + ) + ), + { + 'url': build_absolute_uri('control:global.update') + }, + ) + + +def check_result_table(): + gs = GlobalSettingsObject() + res = gs.settings.update_check_result + if not res: + return { + 'error': 'no_result' + } + + if 'error' in res: + return res + + table = [] + table.append(('pretix', __version__, res['version']['latest'], res['version']['updatable'])) + for p in get_all_plugins(): + if p.module in res['plugins']: + pdata = res['plugins'][p.module] + table.append((_('Plugin: %s') % p.name, p.version, pdata['latest'], pdata['updatable'])) + else: + table.append((_('Plugin: %s') % p.name, p.version, '?', False)) + + return table diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 0e2f8661c..341526e3f 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -351,6 +351,34 @@ Your {event} team""")) 'frontpage_text': { 'default': '', 'type': LazyI18nString + }, + 'update_check_ack': { + 'default': 'False', + 'type': bool + }, + 'update_check_email': { + 'default': '', + 'type': str + }, + 'update_check_perform': { + 'default': 'True', + 'type': bool + }, + 'update_check_result': { + 'default': None, + 'type': dict + }, + 'update_check_result_warning': { + 'default': 'False', + 'type': bool + }, + 'update_check_last': { + 'default': None, + 'type': datetime + }, + 'update_check_id': { + 'default': None, + 'type': str } } diff --git a/src/pretix/control/context.py b/src/pretix/control/context.py index d7edd15dd..83027432c 100644 --- a/src/pretix/control/context.py +++ b/src/pretix/control/context.py @@ -3,6 +3,8 @@ import sys from django.conf import settings from django.core.urlresolvers import Resolver404, get_script_prefix, resolve +from pretix.base.settings import GlobalSettingsObject + from .signals import html_head, nav_event, nav_topbar from .utils.i18n import get_javascript_format, get_moment_locale @@ -53,4 +55,13 @@ def contextprocessor(request): elif 'runserver' in sys.argv: ctx['development_warning'] = True + ctx['warning_update_available'] = False + ctx['warning_update_check_active'] = False + if request.user.is_superuser: + gs = GlobalSettingsObject() + if gs.settings.update_check_result_warning: + ctx['warning_update_available'] = True + if not gs.settings.update_check_ack and 'runserver' not in sys.argv: + ctx['warning_update_check_active'] = True + return ctx diff --git a/src/pretix/control/forms/global_settings.py b/src/pretix/control/forms/global_settings.py index 275d2ec9a..a5bbbde33 100644 --- a/src/pretix/control/forms/global_settings.py +++ b/src/pretix/control/forms/global_settings.py @@ -1,5 +1,6 @@ from collections import OrderedDict +from django import forms from django.utils.translation import ugettext_lazy as _ from i18nfield.forms import I18nFormField, I18nTextInput @@ -32,3 +33,26 @@ class GlobalSettingsForm(SettingsForm): for key, value in response.items(): # We need to be this explicit, since OrderedDict.update does not retain ordering self.fields[key] = value + + +class UpdateSettingsForm(SettingsForm): + update_check_perform = forms.BooleanField( + required=False, + label=_("Perform update checks"), + help_text=_("During the update check, pretix will report an anonymous, unique installation ID, " + "the current version of pretix and your installed plugins and the number of active and " + "inactive events in your installation to servers operated by the pretix developers. We " + "will only store anonymous data, never any IP adresses and we will not know who you are " + "or where to find your instance. You can disable this behaviour here at any time.") + ) + update_check_email = forms.EmailField( + required=False, + label=_("E-mail notifications"), + help_text=_("We will notify you at this address if we detect that a new update is available. This " + "address will not be transmitted to pretix.eu, the emails will be sent by this server " + "locally.") + ) + + def __init__(self, *args, **kwargs): + self.obj = GlobalSettingsObject() + super().__init__(*args, obj=self.obj, **kwargs) diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index c33d1f104..24eab7c44 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -105,6 +105,13 @@ {% endfor %} + {% if warning_update_available %} +
  • + + + +
  • + {% endif %}
  • {{ request.user.get_full_name }} @@ -141,7 +148,8 @@
  • {% if request.user.is_superuser %}
  • - + {% trans "Global settings" %} @@ -173,6 +181,19 @@ {% endfor %} {% endif %} + {% if warning_update_check_active %} +
    + + {% blocktrans trimmed %} + Starting with version 1.2.0, pretix automatically checks for updates in the background. + During this check, anonymous data is transmitted to servers operated by pretix' + developers. Click on this message to find out more, disable this feature or enter your + email address to get notified via email if a new update arrives. This message will + disappear once you clicked it. + {% endblocktrans %} + +
    + {% endif %} {% if debug_warning %}
    diff --git a/src/pretix/control/templates/pretixcontrol/global_settings.html b/src/pretix/control/templates/pretixcontrol/global_settings.html index 49bc37454..59961d8bf 100644 --- a/src/pretix/control/templates/pretixcontrol/global_settings.html +++ b/src/pretix/control/templates/pretixcontrol/global_settings.html @@ -1,10 +1,8 @@ -{% extends "pretixcontrol/base.html" %} +{% extends "pretixcontrol/global_settings_base.html" %} {% load i18n %} {% load bootstrap3 %} -{% block title %}{% trans "Global settings" %}{% endblock %} -{% block content %} -

    {% trans "Global settings" %}

    +{% block inner %}
    {% csrf_token %} {% bootstrap_form_errors form %} diff --git a/src/pretix/control/templates/pretixcontrol/global_settings_base.html b/src/pretix/control/templates/pretixcontrol/global_settings_base.html new file mode 100644 index 000000000..b2d309b3a --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/global_settings_base.html @@ -0,0 +1,22 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} + +{% block title %}{% trans "Global settings" %}{% endblock %} +{% block content %} +

    {% trans "Global settings" %}

    + + {% block inner %} + {% endblock %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/global_update.html b/src/pretix/control/templates/pretixcontrol/global_update.html new file mode 100644 index 000000000..b642e70fc --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/global_update.html @@ -0,0 +1,90 @@ +{% extends "pretixcontrol/global_settings_base.html" %} +{% load i18n %} +{% load bootstrap3 %} + +{% block inner %} +
    + {% trans "Update check results" %} + {% if not gs.settings.update_check_perform %} +
    + {% trans "Update checks are disabled." %} +
    + {% elif not gs.settings.update_check_last %} +
    + {% trans "No update check has been performed yet since the last update of this installation. Update checks are performed on a daily basis if your cronjob is set up properly." %} +
    + + {% csrf_token %} +

    + +

    + + {% elif "error" in gs.settings.update_check_result %} +
    + {% trans "The last update check was not successful." %} + {% if gs.settings.update_check_result.error == "http_error" %} + {% trans "The pretix.eu server returned an error code." %} + {% elif gs.settings.update_check_result.error == "unavailable" %} + {% trans "The pretix.eu server could not be reached." %} + {% elif gs.settings.update_check_result.error == "development" %} + {% trans "This installation appears to be a development installation." %} + {% endif %} +
    +
    + {% csrf_token %} +

    + +

    +
    + {% else %} +
    + {% csrf_token %} +

    + {% blocktrans trimmed with date=gs.settings.update_check_last|date:"SHORT_DATETIME_FORMAT" %} + Last updated: {{ date }} + {% endblocktrans %} + +

    +
    +
    + + + + + + + + + + {% for row in tbl %} + + + + + + {% endfor %} + +
    {% trans "Component" %}{% trans "Installed version" %}{% trans "Latest version" %}
    {{ row.0 }}{{ row.1 }}{{ row.2 }}
    +
    + {% endif %} +
    +
    + {% csrf_token %} +
    + {% trans "Update check settings" %} + {% bootstrap_form_errors form %} + {% bootstrap_form form layout='horizontal' %} +
    +
    + +
    +
    +{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 9c3a6b759..3d7df7277 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -14,9 +14,10 @@ urlpatterns = [ url(r'^forgot$', auth.Forgot.as_view(), name='auth.forgot'), url(r'^forgot/recover$', auth.Recover.as_view(), name='auth.forgot.recover'), url(r'^$', dashboards.user_index, name='index'), - url(r'^global/settings/$', global_settings.GlobalSettingsView.as_view(), name='global-settings'), + url(r'^global/settings/$', global_settings.GlobalSettingsView.as_view(), name='global.settings'), + url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='global.update'), url(r'^reauth/$', user.ReauthView.as_view(), name='user.reauth'), - url(r'^settings$', user.UserSettings.as_view(), name='user.settings'), + url(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'), url(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'), url(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'), url(r'^settings/2fa/add$', user.User2FADeviceAddView.as_view(), name='user.settings.2fa.add'), diff --git a/src/pretix/control/views/global_settings.py b/src/pretix/control/views/global_settings.py index 5ca1bda22..a143c241f 100644 --- a/src/pretix/control/views/global_settings.py +++ b/src/pretix/control/views/global_settings.py @@ -1,9 +1,13 @@ from django.contrib import messages -from django.shortcuts import reverse +from django.shortcuts import redirect, reverse from django.utils.translation import ugettext_lazy as _ from django.views.generic import FormView -from pretix.control.forms.global_settings import GlobalSettingsForm +from pretix.base.services.update_check import check_result_table, update_check +from pretix.base.settings import GlobalSettingsObject +from pretix.control.forms.global_settings import ( + GlobalSettingsForm, UpdateSettingsForm, +) from pretix.control.permissions import AdministratorPermissionRequiredMixin @@ -21,4 +25,34 @@ class GlobalSettingsView(AdministratorPermissionRequiredMixin, FormView): return super().form_invalid(form) def get_success_url(self): - return reverse('control:global-settings') + return reverse('control:global.settings') + + +class UpdateCheckView(AdministratorPermissionRequiredMixin, FormView): + template_name = 'pretixcontrol/global_update.html' + form_class = UpdateSettingsForm + + def post(self, request, *args, **kwargs): + if 'trigger' in request.POST: + update_check.apply() + return redirect(self.get_success_url()) + return super().post(request, *args, **kwargs) + + def form_valid(self, form): + form.save() + messages.success(self.request, _('Your changes have been saved.')) + return super().form_valid(form) + + def form_invalid(self, form): + messages.error(self.request, _('Your changes have not been saved, see below for errors.')) + return super().form_invalid(form) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data() + ctx['gs'] = GlobalSettingsObject() + ctx['gs'].settings.set('update_check_ack', True) + ctx['tbl'] = check_result_table() + return ctx + + def get_success_url(self): + return reverse('control:global.update') diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index 1936bab06..4dd6eb9d8 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -27,6 +27,10 @@ nav.navbar { display: inline; } +nav.navbar .danger, nav.navbar .danger:hover, nav.navbar .danger:active { + background: $brand-danger !important; +} + .navbar-header .navbar-events { color: white; padding-top: 6px; diff --git a/src/requirements/dev.txt b/src/requirements/dev.txt index 6f2cb7cec..83edac0f1 100644 --- a/src/requirements/dev.txt +++ b/src/requirements/dev.txt @@ -13,3 +13,4 @@ isort pytest-mock pytest-rerunfailures pytest-warnings +responses diff --git a/src/setup.py b/src/setup.py index 5ddb07984..a3595b45e 100644 --- a/src/setup.py +++ b/src/setup.py @@ -115,7 +115,8 @@ setup( 'isort', 'pytest-mock', 'pytest-rerunfailures', - 'pytest-warnings' + 'pytest-warnings', + 'responses' ], 'memcached': ['pylibmc'], 'mysql': ['mysqlclient'], diff --git a/src/tests/base/test_updatecheck.py b/src/tests/base/test_updatecheck.py new file mode 100644 index 000000000..7ef066208 --- /dev/null +++ b/src/tests/base/test_updatecheck.py @@ -0,0 +1,189 @@ +import json +from datetime import timedelta + +import pytest +import responses +from django.core import mail as djmail +from django.utils.timezone import now + +from pretix import __version__ +from pretix.base.services import update_check +from pretix.base.settings import GlobalSettingsObject + + +def request_callback_updatable(request): + json_data = json.loads(request.body.decode()) + resp_body = { + 'status': 'ok', + 'version': { + 'latest': '1000.0.0', + 'yours': json_data.get('version'), + 'updatable': True + }, + 'plugins': {} + } + return 200, {'Content-Type': 'text/json'}, json.dumps(resp_body) + + +def request_callback_not_updatable(request): + json_data = json.loads(request.body.decode()) + resp_body = { + 'status': 'ok', + 'version': { + 'latest': '1.0.0', + 'yours': json_data.get('version'), + 'updatable': False + }, + 'plugins': {} + } + return 200, {'Content-Type': 'text/json'}, json.dumps(resp_body) + + +def request_callback_disallowed(request): + pytest.fail("Request issued even though none should be issued.") + + +@pytest.mark.django_db +@responses.activate +def test_update_check_disabled(): + gs = GlobalSettingsObject() + gs.settings.update_check_perform = False + + responses.add_callback( + responses.POST, 'https://pretix.eu/.update_check/', + callback=request_callback_disallowed, + content_type='application/json', + ) + update_check.update_check.apply(throw=True) + + +@pytest.mark.django_db +@responses.activate +def test_update_check_sent_no_updates(): + responses.add_callback( + responses.POST, 'https://pretix.eu/.update_check/', + callback=request_callback_not_updatable, + content_type='application/json', + ) + update_check.update_check.apply(throw=True) + gs = GlobalSettingsObject() + assert not gs.settings.update_check_result_warning + storeddata = gs.settings.update_check_result + assert not storeddata['version']['updatable'] + + +@pytest.mark.django_db +@responses.activate +def test_update_check_sent_updates(): + responses.add_callback( + responses.POST, 'https://pretix.eu/.update_check/', + callback=request_callback_updatable, + content_type='application/json', + ) + update_check.update_check.apply(throw=True) + gs = GlobalSettingsObject() + assert gs.settings.update_check_result_warning + storeddata = gs.settings.update_check_result + assert storeddata['version']['updatable'] + + +@pytest.mark.django_db +@responses.activate +def test_update_check_mail_sent(): + gs = GlobalSettingsObject() + gs.settings.update_check_email = 'test@example.org' + + responses.add_callback( + responses.POST, 'https://pretix.eu/.update_check/', + callback=request_callback_updatable, + content_type='application/json', + ) + update_check.update_check.apply(throw=True) + + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].to == ['test@example.org'] + assert 'update' in djmail.outbox[0].subject + + +@pytest.mark.django_db +@responses.activate +def test_update_check_mail_sent_only_after_change(): + gs = GlobalSettingsObject() + gs.settings.update_check_email = 'test@example.org' + + with responses.RequestsMock() as rsps: + rsps.add_callback( + responses.POST, 'https://pretix.eu/.update_check/', + callback=request_callback_updatable, + content_type='application/json', + ) + + update_check.update_check.apply(throw=True) + assert len(djmail.outbox) == 1 + + update_check.update_check.apply(throw=True) + assert len(djmail.outbox) == 1 + + with responses.RequestsMock() as rsps: + rsps.add_callback( + responses.POST, 'https://pretix.eu/.update_check/', + callback=request_callback_not_updatable, + content_type='application/json', + ) + + update_check.update_check.apply(throw=True) + assert len(djmail.outbox) == 1 + + with responses.RequestsMock() as rsps: + rsps.add_callback( + responses.POST, 'https://pretix.eu/.update_check/', + callback=request_callback_updatable, + content_type='application/json', + ) + + update_check.update_check.apply(throw=True) + assert len(djmail.outbox) == 2 + + +@pytest.mark.django_db +def test_update_cron_interval(monkeypatch): + called = False + + def callee(): + nonlocal called + called = True + + monkeypatch.setattr(update_check.update_check, 'apply_async', callee) + + gs = GlobalSettingsObject() + gs.settings.update_check_email = 'test@example.org' + + gs.settings.update_check_last = now() - timedelta(hours=14) + update_check.run_update_check(None) + assert not called + + gs.settings.update_check_last = now() - timedelta(hours=24) + update_check.run_update_check(None) + assert called + + +@pytest.mark.django_db +def test_result_table_empty(): + assert update_check.check_result_table() == { + 'error': 'no_result' + } + + +@responses.activate +@pytest.mark.django_db +def test_result_table_up2date(): + responses.add_callback( + responses.POST, 'https://pretix.eu/.update_check/', + callback=request_callback_not_updatable, + content_type='application/json', + ) + update_check.update_check.apply(throw=True) + tbl = update_check.check_result_table() + assert tbl[0] == ('pretix', __version__, '1.0.0', False) + assert tbl[1][0].startswith('Plugin: ') + assert tbl[1][2] == '?' diff --git a/src/tests/control/test_updatecheck.py b/src/tests/control/test_updatecheck.py new file mode 100644 index 000000000..d838a82a8 --- /dev/null +++ b/src/tests/control/test_updatecheck.py @@ -0,0 +1,58 @@ +import pytest + +from pretix.base.models import User +from pretix.base.settings import GlobalSettingsObject + + +@pytest.fixture +def user(): + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + return user + + +@pytest.mark.django_db +def test_update_notice_displayed(client, user): + client.login(email='dummy@dummy.dummy', password='dummy') + + r = client.get('/control/') + assert 'pretix automatically checks for updates in the background' not in r.content.decode() + + user.is_superuser = True + user.save() + r = client.get('/control/') + assert 'pretix automatically checks for updates in the background' in r.content.decode() + + client.get('/control/global/update/') # Click it + r = client.get('/control/') + assert 'pretix automatically checks for updates in the background' not in r.content.decode() + + +@pytest.mark.django_db +def test_settings(client, user): + user.is_superuser = True + user.save() + client.login(email='dummy@dummy.dummy', password='dummy') + + client.post('/control/global/update/', {'update_check_email': 'test@example.org', 'update_check_perform': 'on'}) + gs = GlobalSettingsObject() + gs.settings._flush() + assert gs.settings.update_check_perform + assert gs.settings.update_check_email + + client.post('/control/global/update/', {'update_check_email': '', 'update_check_perform': ''}) + gs.settings._flush() + assert not gs.settings.update_check_perform + assert not gs.settings.update_check_email + + +@pytest.mark.django_db +def test_trigger(client, user): + user.is_superuser = True + user.save() + client.login(email='dummy@dummy.dummy', password='dummy') + + gs = GlobalSettingsObject() + assert not gs.settings.update_check_last + client.post('/control/global/update/', {'trigger': 'on'}) + gs.settings._flush() + assert gs.settings.update_check_last