Add search and links to plugin settings (#4854)

* Show links to plugin views and settings in plugin list and in success message after activating a plugin
* Fix menu highlighting in payment provider settings
* Specify settings_links and navigation_links for built-in plugins
* Add link to payment plugins from payment settings
* Add client-side search and "View only active plugins" for plugins page
This commit is contained in:
luelista
2025-03-24 15:04:35 +01:00
committed by GitHub
parent 6a92b98766
commit 5375e22781
20 changed files with 272 additions and 36 deletions

View File

@@ -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('<a href="{}">{}</a>'), url, data['new_order']),
order=format_html('<a href="{}">{}</a>', 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('<a href="{}">{}</a>'), url, data['original_order']),
order=format_html('<a href="{}">{}</a>', url, data['original_order']),
)

View File

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

View File

@@ -7,7 +7,7 @@
{% endblocktrans %}</p>
{% endif %}
{% endif %}
<p>{{ plugin.description|safe }}</p>
<p class="plugin-description">{{ plugin.description|safe }}</p>
{% if plugin.restricted and plugin.module not in request.event.settings.allowed_restricted_plugins %}
<p class="text-muted">
<span class="fa fa-info-circle" aria-hidden="true"></span>

View File

@@ -48,19 +48,19 @@
</a>
</td>
</tr>
{% empty %}
{% endfor %}
<tr>
<td colspan="3">
<td colspan="4">
<br>
{% 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
<a {{ plugin_settings_href }}>plugin settings</a> and activate one or more payment plugins.
{% endblocktrans %}
<a href="{{ plugin_settings_url }}#tab-0-1-open" class="btn btn-default">
<i class="fa fa-plus"></i> {% trans "Enable additional payment plugins" %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</fieldset>
<fieldset>
<legend>{% trans "Deadlines" %}</legend>

View File

@@ -10,20 +10,40 @@
software functionality, connect your event to third-party services, or apply other forms of customizations.
{% endblocktrans %}
</p>
{% if "success" in request.GET %}
<div class="alert alert-success">
{% trans "Your changes have been saved." %}
</div>
{% endif %}
<div class="row">
<div class="col-lg-10">
<p><input type="search" id="plugin_search_input" class="form-control" placeholder="{% trans "Search" %}"></p>
</div>
<div class="col-lg-2 text-right">
<p class="btn-group btn-group-flex" data-toggle="buttons">
<label class="btn btn-primary-if-active active"><input type="radio" name="plugin_state_filter" value="all" checked> {% trans "All" %}</label>
<label class="btn btn-primary-if-active"><input type="radio" name="plugin_state_filter" value="active"> {% trans "Active" %}</label>
</p>
</div>
</div>
<form action="" method="post" class="form-horizontal form-plugins">
{% csrf_token %}
{% if "success" in request.GET %}
<div class="alert alert-success">
{% trans "Your changes have been saved." %}
<div id="plugin_search_results" class="panel panel-default collapse">
<div class="panel-heading">
<button type="button" class="close" aria-label="Close"><span aria-hidden="true">×</span></button>
{% trans "Search results" %}
</div>
{% endif %}
<div class="tabbed-form">
<div class="panel-body">
<div class="plugin-list"></div>
</div>
</div>
<div id="plugin_tabs"><div class="tabbed-form">
{% for cat, catlabel, plist, has_pictures in plugins %}
<fieldset>
<fieldset data-plugin-category="{{ cat }}" data-plugin-category-label="{{ catlabel }}">
<legend>{{ catlabel }}</legend>
<div class="plugin-list">
{% for plugin in plist %}
<div class="plugin-container {% if plugin.featured %}featured-plugin{% endif %}" id="plugin_{{ plugin.module }}">
{% for plugin, is_active, settings_links, navigation_links in plist %}
<div class="plugin-container {% if plugin.featured %}featured-plugin{% endif %}" id="plugin_{{ plugin.module }}" data-plugin-module="{{ plugin.module }}" data-plugin-name="{{ plugin.name }}">
{% if plugin.featured %}
<div class="panel panel-default">
<div class="panel-body">
@@ -49,8 +69,8 @@
{% if show_meta %}
<span class="text-muted text-sm">{{ plugin.version }}</span>
{% endif %}
{% if plugin.module in plugins_active %}
<span class="label label-success">
{% if is_active %}
<span class="label label-success" data-is-active>
<span class="fa fa-check" aria-hidden="true"></span>
{% trans "Active" %}
</span>
@@ -66,8 +86,32 @@
<div class="plugin-action">
<span class="text-muted">{% trans "Not available" %}</span>
</div>
{% elif plugin.module in plugins_active %}
{% elif is_active %}
<div class="plugin-action flip">
{% if navigation_links %}
<div class="btn-group">
<button type="button" class="btn btn-default dropdown-toggle{% if plugin.featured %} btn-lg{% endif %}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="{% trans "Open plugin settings" %}">
<span class="fa fa-compass"></span> {% trans "Go to" %} <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% for link in navigation_links %}
<li><a href="{{ link.0 }}">{{ link.1 }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if settings_links %}
<div class="btn-group">
<button type="button" class="btn btn-default dropdown-toggle{% if plugin.featured %} btn-lg{% endif %}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="{% trans "Open plugin settings" %}">
<span class="fa fa-cog"></span> {% trans "Settings" %} <span class="caret"></span>
</button>
<ul class="dropdown-menu">
{% for link in settings_links %}
<li><a href="{{ link.0 }}">{{ link.1 }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
<button class="btn btn-default{% if plugin.featured %} btn-lg{% endif %}" name="plugin:{{ plugin.module }}"
value="disable">{% trans "Disable" %}</button>
</div>
@@ -86,6 +130,7 @@
</div>
</fieldset>
{% endfor %}
</div>
</div></div>
</form>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/plugins.js" %}"></script>
{% endblock %}

View File

@@ -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 = [
'<p>',
format_html(_('The plugin {} is now active, you can configure it here:'),
format_html("<strong>{}</strong>", pluginmeta.name)),
'</p><p>',
] + [
format_html('<a href="{}" class="btn btn-default">{}</a> ', url, text)
for url, text in links
] + ['</p>']
else:
info = [
format_html(_('The plugin {} is now active.'),
format_html("<strong>{}</strong>", 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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('<span class="pull-right">' + res[1].categoryLabel + '</span>'); }))
$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");
}
}
})

View File

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