diff --git a/doc/development/api/plugins.rst b/doc/development/api/plugins.rst index 181feadd7..fe2287eee 100644 --- a/doc/development/api/plugins.rst +++ b/doc/development/api/plugins.rst @@ -84,6 +84,8 @@ A working example would be: restricted = False description = _("This plugin allows you to receive payments via PayPal") compatibility = "pretix>=2.7.0" + settings_links = [] + navigation_links = [] default_app_config = 'pretix_paypal.PaypalApp' @@ -185,6 +187,28 @@ your Django app label. with checking that the calling user is logged in, has appropriate permissions, etc. We plan on providing native support for this in a later version. +To make your plugin views easily discoverable, you can specify links for "Go to" +and "Settings" buttons next to your entry on the plugin page. These links should be +added to the ``navigation_links`` and ``settings_links``, respectively, in the +``PretixPluginMeta`` class. + +Each array entry consists of a tuple ``(label, urlname, kwargs)``. For the label, +either a string or a tuple of strings can be specified. In the latter case, the provided +strings will be merged with a separator indicating they are successive navigation steps +the user would need to take to reach the page via the regular menu +(e.g. "Payment > Bank transfer" as below). + +.. code-block:: python + + settings_links = [ + ((_("Payment"), _("Bank transfer")), "control:event.settings.payment.provider", {"provider": "banktransfer"}), + ] + navigation_links = [ + ((_("Bank transfer"), _("Import bank data")), "plugins:banktransfer:import", {}), + ((_("Bank transfer"), _("Export refunds")), "plugins:banktransfer:refunds.list", {}), + ] + + .. _Django app: https://docs.djangoproject.com/en/3.0/ref/applications/ .. _signal dispatcher: https://docs.djangoproject.com/en/3.0/topics/signals/ .. _namespace packages: https://legacy.python.org/dev/peps/pep-0420/ diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 6e1086021..7005579dc 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -43,7 +43,6 @@ from django.dispatch import receiver from django.urls import reverse from django.utils.formats import date_format from django.utils.html import escape, format_html -from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _, pgettext_lazy from i18nfield.strings import LazyI18nString @@ -286,7 +285,7 @@ class OrderChangedSplit(OrderChangeLogEntryType): _('Position #{posid} ({old_item}, {old_price}) split into new order: {order}'), old_item=escape(old_item), posid=data.get('positionid', '?'), - order=format_html(mark_safe('{}'), url, data['new_order']), + order=format_html('{}', url, data['new_order']), old_price=money_filter(Decimal(data['old_price']), event.currency), ) @@ -303,7 +302,7 @@ class OrderChangedSplitFrom(OrderLogEntryType): }) return format_html( _('This order has been created by splitting the order {order}'), - order=format_html(mark_safe('{}'), url, data['original_order']), + order=format_html('{}', url, data['original_order']), ) diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index c081961aa..bfd5aef7c 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -59,7 +59,7 @@ def get_event_navigation(request: HttpRequest): 'event': request.event.slug, 'organizer': request.event.organizer.slug, }), - 'active': url.url_name == 'event.settings.payment', + 'active': url.url_name in ('event.settings.payment', 'event.settings.payment.provider'), }, { 'label': _('Plugins'), diff --git a/src/pretix/control/templates/pretixcontrol/event/fragment_plugin_description.html b/src/pretix/control/templates/pretixcontrol/event/fragment_plugin_description.html index 588e87276..fa370d7c1 100644 --- a/src/pretix/control/templates/pretixcontrol/event/fragment_plugin_description.html +++ b/src/pretix/control/templates/pretixcontrol/event/fragment_plugin_description.html @@ -7,7 +7,7 @@ {% endblocktrans %}

{% endif %} {% endif %} -

{{ plugin.description|safe }}

+

{{ plugin.description|safe }}

{% if plugin.restricted and plugin.module not in request.event.settings.allowed_restricted_plugins %}

diff --git a/src/pretix/control/templates/pretixcontrol/event/payment.html b/src/pretix/control/templates/pretixcontrol/event/payment.html index 1583ff9d6..bf481901c 100644 --- a/src/pretix/control/templates/pretixcontrol/event/payment.html +++ b/src/pretix/control/templates/pretixcontrol/event/payment.html @@ -48,19 +48,19 @@ - {% empty %} + {% endfor %} - + +
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %} - {% blocktrans trimmed with plugin_settings_href='href="'|add:plugin_settings_url|add:'"'|safe %} - There are no payment providers available. Please go to the - plugin settings and activate one or more payment plugins. - {% endblocktrans %} + + {% trans "Enable additional payment plugins" %} + - {% endfor %} +

{% trans "Deadlines" %} diff --git a/src/pretix/control/templates/pretixcontrol/event/plugins.html b/src/pretix/control/templates/pretixcontrol/event/plugins.html index 5575abc89..6b32d6a57 100644 --- a/src/pretix/control/templates/pretixcontrol/event/plugins.html +++ b/src/pretix/control/templates/pretixcontrol/event/plugins.html @@ -10,20 +10,40 @@ software functionality, connect your event to third-party services, or apply other forms of customizations. {% endblocktrans %}

+ {% if "success" in request.GET %} +
+ {% trans "Your changes have been saved." %} +
+ {% endif %} +
+
+

+
+
+

+ + +

+
+
{% csrf_token %} - {% if "success" in request.GET %} -
- {% trans "Your changes have been saved." %} +
+
+ + {% trans "Search results" %}
- {% endif %} -
+
+
+
+
+
{% for cat, catlabel, plist, has_pictures in plugins %} -
+
{{ catlabel }}
- {% for plugin in plist %} -
+ {% for plugin, is_active, settings_links, navigation_links in plist %} +
{% if plugin.featured %}
@@ -49,8 +69,8 @@ {% if show_meta %} {{ plugin.version }} {% endif %} - {% if plugin.module in plugins_active %} - + {% if is_active %} + {% trans "Active" %} @@ -66,8 +86,32 @@
{% trans "Not available" %}
- {% elif plugin.module in plugins_active %} + {% elif is_active %}
+ {% if navigation_links %} +
+ + +
+ {% endif %} + {% if settings_links %} +
+ + +
+ {% endif %}
@@ -86,6 +130,7 @@
{% endfor %} -
+
+ {% endblock %} diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 3ee6a14a0..4667a6b96 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -34,6 +34,7 @@ # License for the specific language governing permissions and limitations under the License. import json +import logging import operator import re from collections import OrderedDict @@ -62,8 +63,9 @@ from django.http import ( from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.utils.functional import cached_property -from django.utils.html import conditional_escape +from django.utils.html import conditional_escape, format_html from django.utils.http import url_has_allowed_host_and_scheme +from django.utils.safestring import mark_safe from django.utils.timezone import now from django.utils.translation import gettext, gettext_lazy as _, gettext_noop from django.views.generic import FormView, ListView @@ -109,6 +111,8 @@ from ...helpers.format import ( from ..logdisplay import OVERVIEW_BANLIST from . import CreateView, PaginationMixin, UpdateView +logger = logging.getLogger(__name__) + class EventSettingsViewMixin: def get_context_data(self, **kwargs): @@ -339,12 +343,29 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat def get_object(self, queryset=None) -> Event: return self.request.event - def get_context_data(self, *args, **kwargs) -> dict: + def available_plugins(self, event): from pretix.base.plugins import get_all_plugins + return (p for p in get_all_plugins(event) if not p.name.startswith('.') + and getattr(p, 'visible', True)) + + def prepare_links(self, pluginmeta, key): + links = getattr(pluginmeta, key, []) + try: + return [ + ( + reverse(urlname, kwargs={"organizer": self.request.organizer.slug, "event": self.request.event.slug, **kwargs}), + " > ".join(map(str, linktext)) if isinstance(linktext, tuple) else linktext, + ) for linktext, urlname, kwargs in links + ] + except: + logger.exception('Failed to resolve settings links.') + return [] + + def get_context_data(self, *args, **kwargs) -> dict: context = super().get_context_data(*args, **kwargs) - plugins = [p for p in get_all_plugins(self.object) if not p.name.startswith('.') - and getattr(p, 'visible', True)] + plugins = list(self.available_plugins(self.object)) + order = [ 'FEATURE', 'PAYMENT', @@ -375,12 +396,18 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat ) plugins_grouped = [(c, list(plist)) for c, plist in plugins_grouped] + active_plugins = self.object.get_plugins() + + def plugin_details(plugin): + is_active = plugin.module in active_plugins + settings_links = self.prepare_links(plugin, 'settings_links') if is_active else None + navigation_links = self.prepare_links(plugin, 'navigation_links') if is_active else None + return (plugin, is_active, settings_links, navigation_links) context['plugins'] = sorted([ - (c, labels.get(c, c), plist, any(getattr(p, 'picture', None) for p in plist)) + (c, labels.get(c, c), map(plugin_details, plist), any(getattr(p, 'picture', None) for p in plist)) for c, plist in plugins_grouped ], key=lambda c: (order.index(c[0]), c[1]) if c[0] in order else (999, str(c[1]))) - context['plugins_active'] = self.object.get_plugins() context['show_meta'] = settings.PRETIX_PLUGINS_SHOW_META return context @@ -390,13 +417,10 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat return self.render_to_response(context) def post(self, request, *args, **kwargs): - from pretix.base.plugins import get_all_plugins - self.object = self.get_object() plugins_available = { - p.module: p for p in get_all_plugins(self.object) - if not p.name.startswith('.') and getattr(p, 'visible', True) + p.module: p for p in self.available_plugins(self.object) } with transaction.atomic(): @@ -404,19 +428,38 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat if key.startswith("plugin:"): module = key.split(":")[1] if value == "enable" and module in plugins_available: - if getattr(plugins_available[module], 'restricted', False): + pluginmeta = plugins_available[module] + if getattr(pluginmeta, 'restricted', False): if module not in request.event.settings.allowed_restricted_plugins: continue self.request.event.log_action('pretix.event.plugins.enabled', user=self.request.user, data={'plugin': module}) self.object.enable_plugin(module, allow_restricted=request.event.settings.allowed_restricted_plugins) + + links = self.prepare_links(pluginmeta, 'settings_links') + if links: + info = [ + '

', + format_html(_('The plugin {} is now active, you can configure it here:'), + format_html("{}", pluginmeta.name)), + '

', + ] + [ + format_html('{} ', url, text) + for url, text in links + ] + ['

'] + else: + info = [ + format_html(_('The plugin {} is now active.'), + format_html("{}", pluginmeta.name)), + ] + messages.success(self.request, mark_safe("".join(info))) else: self.request.event.log_action('pretix.event.plugins.disabled', user=self.request.user, data={'plugin': module}) self.object.disable_plugin(module) + messages.success(self.request, _('The plugin has been disabled.')) self.object.save() - messages.success(self.request, _('Your changes have been saved.')) return redirect(self.get_success_url()) def get_success_url(self) -> str: diff --git a/src/pretix/plugins/autocheckin/apps.py b/src/pretix/plugins/autocheckin/apps.py index 2960abe4e..75297eaf9 100644 --- a/src/pretix/plugins/autocheckin/apps.py +++ b/src/pretix/plugins/autocheckin/apps.py @@ -38,6 +38,9 @@ class AutoCheckinApp(AppConfig): description = _( "Automatically check-in specific tickets after they have been sold." ) + navigation_links = [ + ((_("Check-in"), _("Auto check-in")), "plugins:autocheckin:index", {}), + ] def ready(self): from . import signals # NOQA diff --git a/src/pretix/plugins/badges/apps.py b/src/pretix/plugins/badges/apps.py index 6f60ff45d..4bb75c05f 100644 --- a/src/pretix/plugins/badges/apps.py +++ b/src/pretix/plugins/badges/apps.py @@ -37,6 +37,9 @@ class BadgesApp(AppConfig): featured = True description = _("Automatically generate badges or name tags for your attendees. You can download the badges in the " "backend or automatically print them with our check-in apps.") + settings_links = [ + (_("Badges"), "plugins:badges:index", {}), + ] def ready(self): from . import signals # NOQA diff --git a/src/pretix/plugins/banktransfer/apps.py b/src/pretix/plugins/banktransfer/apps.py index 715341802..7bcb6f91a 100644 --- a/src/pretix/plugins/banktransfer/apps.py +++ b/src/pretix/plugins/banktransfer/apps.py @@ -38,6 +38,13 @@ class BankTransferApp(AppConfig): version = version description = _("Accept payments from your customers using classical wire transfer methods with your own " "bank account.") + settings_links = [ + ((_("Payment"), _("Bank transfer")), "control:event.settings.payment.provider", {"provider": "banktransfer"}), + ] + navigation_links = [ + ((_("Bank transfer"), _("Import bank data")), "plugins:banktransfer:import", {}), + ((_("Bank transfer"), _("Export refunds")), "plugins:banktransfer:refunds.list", {}), + ] def ready(self): from . import signals # NOQA diff --git a/src/pretix/plugins/manualpayment/apps.py b/src/pretix/plugins/manualpayment/apps.py index 0f98bf401..bd1f2ad7f 100644 --- a/src/pretix/plugins/manualpayment/apps.py +++ b/src/pretix/plugins/manualpayment/apps.py @@ -35,3 +35,6 @@ class ManualPaymentApp(AppConfig): version = version category = 'PAYMENT' description = _("A fully customizable payment method for manual processing.") + settings_links = [ + ((_("Payment"), _("Manual payment")), "control:event.settings.payment.provider", {"provider": "manual"}), + ] diff --git a/src/pretix/plugins/paypal2/apps.py b/src/pretix/plugins/paypal2/apps.py index cbc4b3b32..2fee7bd4a 100644 --- a/src/pretix/plugins/paypal2/apps.py +++ b/src/pretix/plugins/paypal2/apps.py @@ -41,6 +41,9 @@ class Paypal2App(AppConfig): "also offer payments in a variety of local payment methods such as eps, iDEAL, and " "many more to your customers - they don't even need a PayPal account. PayPal is one of the " "most popular payment methods world-wide.") + settings_links = [ + ((_("Payment"), _("PayPal")), "control:event.settings.payment.provider", {"provider": "paypal_settings"}), + ] def ready(self): from . import signals # NOQA diff --git a/src/pretix/plugins/returnurl/apps.py b/src/pretix/plugins/returnurl/apps.py index fed4a5e9e..f5217c3b6 100644 --- a/src/pretix/plugins/returnurl/apps.py +++ b/src/pretix/plugins/returnurl/apps.py @@ -36,6 +36,9 @@ class ReturnURLApp(AppConfig): category = 'API' description = _("This plugin allows to link to payments and redirect back afterwards. This is useful in " "combination with our API.") + settings_links = [ + ((_("Settings"), _("Redirection")), "plugins:returnurl:settings", {}), + ] def ready(self): from . import signals # NOQA diff --git a/src/pretix/plugins/statistics/apps.py b/src/pretix/plugins/statistics/apps.py index 79b82f70c..580782e4c 100644 --- a/src/pretix/plugins/statistics/apps.py +++ b/src/pretix/plugins/statistics/apps.py @@ -35,6 +35,9 @@ class StatisticsApp(AppConfig): version = version category = 'FEATURE' description = _("Get a birds-eye view of your event sales with graphical statistics.") + navigation_links = [ + ((_("Orders"), _("Statistics")), "plugins:statistics:index", {}), + ] def ready(self): from . import signals # NOQA diff --git a/src/pretix/plugins/stripe/apps.py b/src/pretix/plugins/stripe/apps.py index b66257d1f..de478128f 100644 --- a/src/pretix/plugins/stripe/apps.py +++ b/src/pretix/plugins/stripe/apps.py @@ -40,6 +40,9 @@ class StripeApp(AppConfig): description = _("Accept payments via Stripe, a globally popular payment service provider. Stripe supports " "payments via credit cards as well as many local payment methods such as iDEAL, Alipay," "and many more.") + settings_links = [ + ((_("Payment"), _("Stripe")), "control:event.settings.payment.provider", {"provider": "stripe_settings"}), + ] def ready(self): from . import signals, tasks # NOQA diff --git a/src/pretix/plugins/ticketoutputpdf/apps.py b/src/pretix/plugins/ticketoutputpdf/apps.py index 9adfbefa5..b307dd9a2 100644 --- a/src/pretix/plugins/ticketoutputpdf/apps.py +++ b/src/pretix/plugins/ticketoutputpdf/apps.py @@ -51,6 +51,9 @@ class TicketOutputPdfApp(AppConfig): featured = True description = _("Issue tickets as PDF files, usable on any device. Our drag-and-drop editor allows you to " "customize the layout of the PDF files to your brand.") + settings_links = [ + ((_("Settings"), _("Tickets")), "control:event.settings.tickets", {}), + ] def ready(self): from . import signals # NOQA diff --git a/src/pretix/plugins/webcheckin/apps.py b/src/pretix/plugins/webcheckin/apps.py index a9c51233c..af85d4703 100644 --- a/src/pretix/plugins/webcheckin/apps.py +++ b/src/pretix/plugins/webcheckin/apps.py @@ -36,6 +36,9 @@ class WebCheckinApp(AppConfig): experimental = True category = "FEATURE" description = _("Turn your browser into a check-in device to perform access control.") + navigation_links = [ + ((_("Check-in"), _("Web Check-in")), "plugins:webcheckin:index", {}), + ] def ready(self): from . import signals # NOQA diff --git a/src/pretix/static/pretixbase/scss/_theme.scss b/src/pretix/static/pretixbase/scss/_theme.scss index 8489ce30b..9e7d0eb24 100644 --- a/src/pretix/static/pretixbase/scss/_theme.scss +++ b/src/pretix/static/pretixbase/scss/_theme.scss @@ -114,6 +114,10 @@ input[type=number]::-webkit-outer-spin-button { background: transparent; border: transparent; } + +.btn-group-flex { display: flex; } +.btn-group-flex > .btn { flex: 1; } + .panel-heading { border-radius: 0; } diff --git a/src/pretix/static/pretixcontrol/js/ui/plugins.js b/src/pretix/static/pretixcontrol/js/ui/plugins.js new file mode 100644 index 000000000..ba862c95f --- /dev/null +++ b/src/pretix/static/pretixcontrol/js/ui/plugins.js @@ -0,0 +1,84 @@ +$(function() { + var plugins = $(".plugin-container").toArray().map(function(el) { + return { + sortName: el.getAttribute('data-plugin-name').toLowerCase().replace(/pretix /g, ''), + name: el.getAttribute('data-plugin-name').toLowerCase(), + module: el.getAttribute('data-plugin-module').toLowerCase(), + description: $(el).find('.plugin-description').text().toLowerCase(), + html: el.outerHTML, + category: $(el).closest('[data-plugin-category]').attr('data-plugin-category'), + categoryLabel: $(el).closest('[data-plugin-category]').attr('data-plugin-category-label'), + active: !!$(el).has('[data-is-active]').length, + } + }); + function SearchMatcher(term, fields) { + this.searchFor = term.toLowerCase().split(/\s+/); + this.fields = fields; + } + function inStringRanked(haystack, needle) { + let pos = -1, rank = 0; + do { + pos = haystack.indexOf(needle, pos + 1); + if (pos !== -1) rank = 10; + if (pos === 0 || haystack.charCodeAt(pos - 1) <= 47) + return 15; // string start or word start (=char before match is special char) + } while (pos !== -1); + return rank; + } + SearchMatcher.prototype.isMatch = function(obj) { + let rank = 0; + for(let j = 0; j < this.searchFor.length; j++) { + var searchFor = this.searchFor[j]; + for(let i = this.fields.length - 1; i >= 0; i--) { + var result = inStringRanked(obj[this.fields[i]], searchFor); + if (result) { + rank += (i + 1) * result; + break; + } + } + } + return rank; + } + function strcmp(a, b) { + return a > b ? 1 : a < b ? -1 : 0; + } + var $results_box = $("#plugin_search_results"); + var $plugin_tabs = $("#plugin_tabs"); + var $results = $("#plugin_search_results .plugin-list"); + function search() { + $results.html(""); + var value = $("#plugin_search_input").val(); + var only_active = $("input[name=plugin_state_filter][value=active]").prop("checked"); + if (!value && !only_active) { + $results_box.hide(); $plugin_tabs.show(); + return; + } + $results_box.show(); $plugin_tabs.hide(); + var matcher = new SearchMatcher(value, ["description", "module", "name"]); + var matches = []; + for(const plugin of plugins) { + if (only_active && !plugin.active) continue; + var rank = matcher.isMatch(plugin); + if (!rank) continue; + matches.push([rank, plugin]); + } + matches.sort(function (a,b) { return (b[0]-a[0]) || strcmp(a[1].sortName, b[1].sortName); }) + $results.append(matches.map(function(res) { return $(res[1].html).prepend('' + res[1].categoryLabel + ''); })) + $results.find(".panel-body, .panel, .featured-plugin, .btn-lg").removeClass("panel-body panel featured-plugin btn-lg"); + if (matches.length === 0) { + $results.append(gettext("No results")); + } + } + $("#plugin_search_input").on("input", search); + $("input[name=plugin_state_filter]").on("change", search); + $results_box.find("button.close").on("click", function() { + $("input[name=plugin_state_filter][value=all]").prop("checked", true).trigger("click"); + $("#plugin_search_input").val("").trigger("input"); + }); + if (location.search) { + var search = new URLSearchParams(location.search); + if (search.has('q')) { + $("#plugin_search_input").val(search.get("q")).trigger("input"); + } + } +}) diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index f6f9112ac..9a3892032 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -770,6 +770,9 @@ h1 .label { flex-shrink: 0; padding-top: 15px; } + .plugin-container:first-child { + padding-top: 0; + } .plugin-container:not(.featured-plugin) + .plugin-container { border-top: 1px solid #ccc; }