mirror of
https://github.com/pretix/pretix.git
synced 2026-05-06 15:24:02 +00:00
Queueing and mapping utilities for outbound data sync (#4881)
Add a registry for datasync providers and an associated sync queue, to be used by plugins that transfer data from pretix orders to external systems. Additionally, provide a generic data mapping interface to be used in settings pages of such plugins, to let users configure which information from pretix to fill into which data fields of the external system. --------- Co-authored-by: Raphael Michel <michel@rami.io>
This commit is contained in:
@@ -42,3 +42,4 @@ class PretixControlConfig(AppConfig):
|
||||
def ready(self):
|
||||
from .views import dashboards # noqa
|
||||
from . import logdisplay # noqa
|
||||
from .views import datasync # noqa
|
||||
|
||||
127
src/pretix/control/forms/mapping.py
Normal file
127
src/pretix/control/forms/mapping.py
Normal file
@@ -0,0 +1,127 @@
|
||||
#
|
||||
# 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 json
|
||||
|
||||
from django import forms
|
||||
from django.forms import formset_factory
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import Question
|
||||
from pretix.base.models.datasync import (
|
||||
MODE_APPEND_LIST, MODE_OVERWRITE, MODE_SET_IF_EMPTY, MODE_SET_IF_NEW,
|
||||
)
|
||||
|
||||
|
||||
class PropertyMappingForm(forms.Form):
|
||||
pretix_field = forms.CharField()
|
||||
external_field = forms.CharField()
|
||||
value_map = forms.CharField(required=False)
|
||||
overwrite = forms.ChoiceField(
|
||||
choices=[
|
||||
(MODE_OVERWRITE, _("Overwrite")),
|
||||
(MODE_SET_IF_NEW, _("Fill if new")),
|
||||
(MODE_SET_IF_EMPTY, _("Fill if empty")),
|
||||
(MODE_APPEND_LIST, _("Add to list")),
|
||||
]
|
||||
)
|
||||
|
||||
def __init__(self, pretix_fields, external_fields_id, available_modes, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["pretix_field"] = forms.ChoiceField(
|
||||
label=_("pretix field"),
|
||||
choices=pretix_fields_choices(pretix_fields, kwargs.get("initial", {}).get("pretix_field")),
|
||||
required=False,
|
||||
)
|
||||
if external_fields_id:
|
||||
self.fields["external_field"] = forms.ChoiceField(
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
"data-model-select2": "json_script",
|
||||
"data-select2-src": "#" + external_fields_id,
|
||||
},
|
||||
),
|
||||
)
|
||||
self.fields["external_field"].choices = [
|
||||
(self["external_field"].value(), self["external_field"].value()),
|
||||
]
|
||||
self.fields["overwrite"].choices = [
|
||||
(key, label) for (key, label) in self.fields["overwrite"].choices if key in available_modes
|
||||
]
|
||||
|
||||
|
||||
class PropertyMappingFormSet(formset_factory(
|
||||
PropertyMappingForm,
|
||||
can_order=True,
|
||||
can_delete=True,
|
||||
extra=0,
|
||||
)):
|
||||
template_name = "pretixcontrol/datasync/property_mappings_formset.html"
|
||||
|
||||
def __init__(self, pretix_fields, external_fields, available_modes, prefix, *args, initial_json=None, **kwargs):
|
||||
if initial_json:
|
||||
kwargs["initial"] = json.loads(initial_json)
|
||||
super().__init__(
|
||||
form_kwargs={
|
||||
"pretix_fields": pretix_fields,
|
||||
"external_fields_id": prefix + "external-fields" if external_fields else None,
|
||||
"available_modes": available_modes,
|
||||
},
|
||||
prefix=prefix,
|
||||
*args, **kwargs)
|
||||
self.external_fields = external_fields
|
||||
|
||||
def get_context(self):
|
||||
ctx = super().get_context()
|
||||
ctx["external_fields"] = self.external_fields
|
||||
ctx["external_fields_id"] = self.prefix + "external-fields"
|
||||
return ctx
|
||||
|
||||
def to_property_mappings_json(self):
|
||||
"""
|
||||
Returns a property mapping configuration as a JSON-serialized list of dictionaries.
|
||||
|
||||
Each entry specifies how to transfer data from one pretix field to one field in the external system:
|
||||
|
||||
- `pretix_field`: Name of a pretix data source field as declared in `pretix.base.datasync.sourcefields.get_data_fields`.
|
||||
- `external_field`: Name of the target field in the external system. Implementation-defined by the sync provider.
|
||||
- `value_map`: Dictionary mapping pretix value to external value. Only used for enumeration-type fields.
|
||||
- `overwrite`: Mode of operation if the object already exists in the target system.
|
||||
|
||||
- `MODE_OVERWRITE` (`"overwrite"`) to always overwrite existing value.
|
||||
- `MODE_SET_IF_NEW` (`"if_new"`) to only set the value if object does not exist in target system yet.
|
||||
- `MODE_SET_IF_EMPTY` (`"if_empty"`) to only set the value if object does not exist in target system,
|
||||
or the field is currently empty in target system.
|
||||
- `MODE_APPEND_LIST` (`"append"`) if the field is an array or a multi-select: add the value to the list.
|
||||
"""
|
||||
mappings = [f.cleaned_data for f in self.ordered_forms]
|
||||
return json.dumps(mappings)
|
||||
|
||||
|
||||
QUESTION_TYPE_LABELS = dict(Question.TYPE_CHOICES)
|
||||
|
||||
|
||||
def pretix_fields_choices(pretix_fields, initial_choice):
|
||||
return [
|
||||
(f.key, f.label + " [" + QUESTION_TYPE_LABELS[f.type] + "]")
|
||||
for f in pretix_fields
|
||||
if not f.deprecated or f.key == initial_choice
|
||||
]
|
||||
@@ -43,9 +43,11 @@ 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
|
||||
|
||||
from pretix.base.datasync.datasync import datasync_providers
|
||||
from pretix.base.logentrytypes import (
|
||||
DiscountLogEntryType, EventLogEntryType, ItemCategoryLogEntryType,
|
||||
ItemLogEntryType, LogEntryType, OrderLogEntryType, QuestionLogEntryType,
|
||||
@@ -421,6 +423,51 @@ class OrderPrintLogEntryType(OrderLogEntryType):
|
||||
)
|
||||
|
||||
|
||||
class OrderDataSyncLogEntryType(OrderLogEntryType):
|
||||
def display(self, logentry, data):
|
||||
try:
|
||||
from pretix.base.datasync.datasync import datasync_providers
|
||||
provider_class, meta = datasync_providers.get(identifier=data['provider'])
|
||||
data['provider_display_name'] = provider_class.display_name
|
||||
except (KeyError, AttributeError):
|
||||
data['provider_display_name'] = data.get('provider')
|
||||
return super().display(logentry, data)
|
||||
|
||||
|
||||
@log_entry_types.new_from_dict({
|
||||
"pretix.event.order.data_sync.success": _("Data successfully transferred to {provider_display_name}."),
|
||||
})
|
||||
class OrderDataSyncSuccessLogEntryType(OrderDataSyncLogEntryType):
|
||||
def display(self, logentry, data):
|
||||
links = []
|
||||
if data.get('provider') and data.get('objects'):
|
||||
prov, meta = datasync_providers.get(identifier=data['provider'])
|
||||
if prov:
|
||||
for objs in data['objects'].values():
|
||||
links.append(", ".join(
|
||||
prov.get_external_link_html(logentry.event, obj['external_link_href'], obj['external_link_display_name'])
|
||||
for obj in objs
|
||||
if obj and 'external_link_href' in obj and 'external_link_display_name' in obj
|
||||
))
|
||||
|
||||
return mark_safe(escape(super().display(logentry, data)) + "".join("<p>" + link + "</p>" for link in links))
|
||||
|
||||
|
||||
@log_entry_types.new_from_dict({
|
||||
"pretix.event.order.data_sync.failed.config": _("Transferring data to {provider_display_name} failed due to invalid configuration:"),
|
||||
"pretix.event.order.data_sync.failed.exceeded": _("Maximum number of retries exceeded while transferring data to {provider_display_name}:"),
|
||||
"pretix.event.order.data_sync.failed.permanent": _("Error while transferring data to {provider_display_name}:"),
|
||||
"pretix.event.order.data_sync.failed.internal": _("Internal error while transferring data to {provider_display_name}."),
|
||||
"pretix.event.order.data_sync.failed.timeout": _("Internal error while transferring data to {provider_display_name}."),
|
||||
})
|
||||
class OrderDataSyncErrorLogEntryType(OrderDataSyncLogEntryType):
|
||||
def display(self, logentry, data):
|
||||
errmes = data["error"]
|
||||
if not isinstance(errmes, list):
|
||||
errmes = [errmes]
|
||||
return mark_safe(escape(super().display(logentry, data)) + "".join("<p>" + escape(msg) + "</p>" for msg in errmes))
|
||||
|
||||
|
||||
@receiver(signal=logentry_display, dispatch_uid="pretixcontrol_logentry_display")
|
||||
def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
|
||||
|
||||
@@ -451,6 +451,11 @@ def get_global_navigation(request):
|
||||
'url': reverse('control:global.sysreport'),
|
||||
'active': (url.url_name == 'global.sysreport'),
|
||||
},
|
||||
{
|
||||
'label': _('Data sync problems'),
|
||||
'url': reverse('control:global.datasync.failedjobs'),
|
||||
'active': (url.url_name == 'global.datasync.failedjobs'),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
@@ -655,6 +660,18 @@ def get_organizer_navigation(request):
|
||||
'icon': 'download',
|
||||
})
|
||||
|
||||
if 'can_change_organizer_settings' in request.orgapermset:
|
||||
merge_in(nav, [{
|
||||
'parent': reverse('control:organizer.export', kwargs={
|
||||
'organizer': request.organizer.slug,
|
||||
}),
|
||||
'label': _('Data sync problems'),
|
||||
'url': reverse('control:organizer.datasync.failedjobs', kwargs={
|
||||
'organizer': request.organizer.slug,
|
||||
}),
|
||||
'active': (url.url_name == 'organizer.datasync.failedjobs'),
|
||||
}])
|
||||
|
||||
merge_in(nav, sorted(
|
||||
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
|
||||
[]),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load bootstrap3 %}
|
||||
{% load escapejson %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
{% trans "Data transfer to external systems" %}
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="list-group">
|
||||
{% for identifier, display_name, pending, objects in providers %}
|
||||
<li class="list-group-item">
|
||||
<form action="{% url "control:event.order.sync_job" organizer=event.organizer.slug event=event.slug code=order.code provider=identifier %}" method="post" class="form-inline pull-right">
|
||||
{% csrf_token %}
|
||||
{% if pending %}
|
||||
{% if pending.not_before > now or pending.need_manual_retry %}
|
||||
<button type="submit" name="run_job_now" value="{{ pending.pk }}" class="btn btn-default"><i class="fa fa-refresh"></i> {% trans "Retry now" %}</button>
|
||||
{% endif %}
|
||||
<button type="submit" name="cancel_job" value="{{ pending.pk }}" class="btn btn-danger"><i class="fa fa-times"></i> {% trans "Cancel" %}</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-default"><i class="fa fa-refresh"></i> {% trans "Sync now" %}</button>
|
||||
<input type="hidden" name="queue_sync" value="true">
|
||||
{% endif %}
|
||||
</form>
|
||||
<p><b>{{ display_name }}</b></p>
|
||||
{% if pending %}
|
||||
<p>
|
||||
{% if pending.need_manual_retry %}
|
||||
<i class="fa fa-warning"></i>
|
||||
{% trans "Error" %}: {{ pending.get_need_manual_retry_display }}
|
||||
{% elif pending.failed_attempts %}
|
||||
<i class="fa fa-warning"></i>
|
||||
{% blocktrans trimmed with num=pending.failed_attempts max=pending.max_retry_attempts %}
|
||||
Error. Retry {{ num }} of {{ max }}.
|
||||
{% endblocktrans %}
|
||||
{% if pending.not_before %}
|
||||
{% blocktrans trimmed with datetime=pending.not_before|date:"SHORT_DATETIME_FORMAT" %}
|
||||
Waiting until {{ datetime }}
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% elif pending.not_before > now %}
|
||||
{% blocktrans trimmed with datetime=pending.not_before|date:"SHORT_DATETIME_FORMAT" %}
|
||||
Waiting until {{ datetime }}
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
<i class="fa fa-hourglass"></i> {% trans "Pending" %}
|
||||
{% endif %}
|
||||
<span class="text-muted">({% blocktrans trimmed with datetime=pending.triggered|date:"SHORT_DATETIME_FORMAT" %}triggered at {{ datetime }}
|
||||
{% endblocktrans %})</span>
|
||||
<!-- {{ pending.triggered_by }} / {{ pending.triggered }} -->
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<ul>
|
||||
{% for obj in objects %}
|
||||
<li>
|
||||
{% if obj.external_link_html %}
|
||||
{{ obj.external_link_html }}
|
||||
{% else %}
|
||||
{{ obj.external_object_type }}
|
||||
{% trans "identified by" %} {{ obj.external_id_field }}
|
||||
<em>{{ obj.id_value }}</em>
|
||||
{% endif %}
|
||||
<time class="text-muted" datetime="{{ obj.transmitted.isoformat }}">{{ obj.transmitted|date:"SHORT_DATETIME_FORMAT" }}</time>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>{% trans "No data transmitted." %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,86 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{% trans "Sync problems" %}</h2>
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
On this page, we provide a list of orders where data synchronisation to an external system has failed.
|
||||
You can start another attempt to sync them manually.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
{% if queue_items %}
|
||||
<label aria-label="{% trans "select all rows for batch-operation" %}"
|
||||
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
|
||||
{% endif %}
|
||||
</th>
|
||||
<th>{% trans "Order" %}</th>
|
||||
<th>{% trans "Sync provider" %}</th>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "Failure mode" %}</th>
|
||||
{% if staff_session %}
|
||||
<th>in_flight</th><th>retry</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in queue_items %}
|
||||
<tr>
|
||||
<td><input type="checkbox" name="idlist" value="{{ item.pk }}"></td>
|
||||
<td>
|
||||
{% if staff_session %}{{ item.order.event.organizer.slug }} -{% endif %}
|
||||
<a href="{% url "control:event.order" event=item.order.event.slug organizer=item.order.event.organizer.slug code=item.order.code %}">
|
||||
{{ item.order.full_code }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ item.provider_display_name }}</td>
|
||||
<td>
|
||||
{{ item.triggered }}
|
||||
{% if staff_session %}({{ item.triggered_by }}){% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.need_manual_retry %}
|
||||
{{ item.get_need_manual_retry_display }}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with datetime=item.not_before|date:"SHORT_DATETIME_FORMAT" %}
|
||||
Temporary error, will retry after {{ datetime }}
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% if staff_session %}({{ item.need_manual_retry }}){% endif %}
|
||||
</td>
|
||||
{% if staff_session %}
|
||||
<td>{{ item.in_flight }} ({{ item.in_flight_since }})</td><td>{{ item.failed_attempts }} / {{ item.max_retry_attempts }} ({{ item.not_before }})</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center">{% trans "No problems." %}</td>
|
||||
{% if staff_session %}
|
||||
<td></td><td></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% if queue_items %}
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<button type="submit" name="action" value="retry" class="btn btn-primary"><i class="fa fa-refresh"></i> {% trans "Retry selected" %}</button>
|
||||
<button type="submit" name="action" value="cancel" class="btn btn-danger"><i class="fa fa-times"></i> {% trans "Cancel selected" %}</button>
|
||||
</td>
|
||||
{% if staff_session %}
|
||||
<td></td><td></td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</tfoot>
|
||||
{% endif %}
|
||||
</table>
|
||||
</form>
|
||||
{% include "pretixcontrol/pagination.html" %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load escapejson %}
|
||||
{% load formset_tags %}
|
||||
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
|
||||
{{ formset.management_form }}
|
||||
{% bootstrap_formset_errors formset %}
|
||||
<div data-formset-body>
|
||||
{% for f in formset %}
|
||||
{% bootstrap_form_errors f %}
|
||||
<div class="row formset-row" data-formset-form>
|
||||
<div class="sr-only">
|
||||
{{ f.id }}
|
||||
{% bootstrap_field f.DELETE form_group_class="" layout="inline" %}
|
||||
{% bootstrap_field f.ORDER form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field f.pretix_field layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field f.external_field layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
{% bootstrap_field f.overwrite layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
{{ f.value_map.as_hidden }}
|
||||
<div class="col-md-2 text-right flip">
|
||||
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
|
||||
|
||||
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
|
||||
data-target="#editValueMapModal" title="{% trans "Edit value mapping" %}">
|
||||
<i class="fa fa-edit"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<script type="form-template" data-formset-empty-form>
|
||||
{% escapescript %}
|
||||
<div class="row formset-row" data-formset-form>
|
||||
<div class="sr-only">
|
||||
{% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field formset.empty_form.pretix_field layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{% bootstrap_field formset.empty_form.external_field layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
{% bootstrap_field formset.empty_form.overwrite layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
{{ f.value_map.as_hidden }}
|
||||
<div class="col-md-2 text-right flip">
|
||||
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
|
||||
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
|
||||
data-target="#editValueMapModal" title="{% trans "Edit value mapping" %}">
|
||||
<i class="fa fa-edit"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-up-button>
|
||||
<i class="fa fa-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-default" data-formset-move-down-button>
|
||||
<i class="fa fa-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-danger" data-formset-delete-button>
|
||||
<i class="fa fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
</script>
|
||||
<p>
|
||||
<button type="button" class="btn btn-default" data-formset-add>
|
||||
<i class="fa fa-plus"></i> {% trans "Add property" %}</button>
|
||||
</p>
|
||||
</div>
|
||||
{% if external_fields %}
|
||||
{{ external_fields|json_script:external_fields_id }}
|
||||
{% endif %}
|
||||
@@ -79,6 +79,15 @@
|
||||
class="btn btn-primary">{% trans "Show affected orders" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if has_sync_problems %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
Orders in this event could not be <strong>synced to an external system</strong> as configured.
|
||||
{% endblocktrans %}
|
||||
<a href="{% url "control:event.datasync.failedjobs" event=request.event.slug organizer=request.event.organizer.slug %}"
|
||||
class="btn btn-primary">{% trans "Show sync problems" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% eventsignal request.event "pretix.control.signals.event_dashboard_top" request=request %}
|
||||
|
||||
{% if request.event.has_subevents %}
|
||||
|
||||
@@ -37,9 +37,9 @@ from django.urls import include, re_path
|
||||
from django.views.generic.base import RedirectView
|
||||
|
||||
from pretix.control.views import (
|
||||
auth, checkin, dashboards, discounts, event, geo, global_settings, item,
|
||||
main, modelimport, oauth, orders, organizer, pdf, search, shredder,
|
||||
subevents, typeahead, user, users, vouchers, waitinglist,
|
||||
auth, checkin, dashboards, datasync, discounts, event, geo,
|
||||
global_settings, item, main, modelimport, oauth, orders, organizer, pdf,
|
||||
search, shredder, subevents, typeahead, user, users, vouchers, waitinglist,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -58,6 +58,7 @@ urlpatterns = [
|
||||
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'^global/datasync/failedjobs/$', datasync.GlobalFailedSyncJobsView.as_view(), name='global.datasync.failedjobs'),
|
||||
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'),
|
||||
re_path(r'^logdetail/refund/$', global_settings.RefundDetailView.as_view(), name='global.refunddetail'),
|
||||
@@ -248,6 +249,7 @@ urlpatterns = [
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/delete$', organizer.DeleteScheduledExportView.as_view(),
|
||||
name='organizer.export.scheduled.delete'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/ticket_select2$', typeahead.ticket_select2, name='organizer.ticket_select2'),
|
||||
re_path(r'^organizer/(?P<organizer>[^/]+)/datasync/failedjobs/$', datasync.OrganizerFailedSyncJobsView.as_view(), name='organizer.datasync.failedjobs'),
|
||||
re_path(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'),
|
||||
re_path(r'^events/$', main.EventList.as_view(), name='events'),
|
||||
re_path(r'^events/add$', main.EventWizard.as_view(), name='events.add'),
|
||||
@@ -428,6 +430,8 @@ urlpatterns = [
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/cancellationrequests/(?P<req>\d+)/delete$',
|
||||
orders.OrderCancellationRequestDelete.as_view(),
|
||||
name='event.order.cancellationrequests.delete'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/sync_job/(?P<provider>[^/]+)/$', datasync.ControlSyncJob.as_view(),
|
||||
name='event.order.sync_job'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transactions/$', orders.OrderTransactions.as_view(), name='event.order.transactions'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/$', orders.OrderDetail.as_view(), name='event.order'),
|
||||
re_path(r'^invoice/(?P<invoice>[^/]+)$', orders.InvoiceDownload.as_view(),
|
||||
@@ -474,6 +478,7 @@ urlpatterns = [
|
||||
name='event.orders.checkinlists.edit'),
|
||||
re_path(r'^checkinlists/(?P<list>\d+)/delete$', checkin.CheckinListDelete.as_view(),
|
||||
name='event.orders.checkinlists.delete'),
|
||||
re_path(r'^datasync/failedjobs/$', datasync.EventFailedSyncJobsView.as_view(), name='event.datasync.failedjobs'),
|
||||
])),
|
||||
re_path(r'^event/(?P<organizer>[^/]+)/$', RedirectView.as_view(pattern_name='control:organizer'), name='event.organizerredirect'),
|
||||
]
|
||||
|
||||
@@ -383,6 +383,10 @@ def event_index(request, organizer, event):
|
||||
ctx['has_cancellation_requests'] = can_view_orders and CancellationRequest.objects.filter(
|
||||
order__event=request.event
|
||||
).exists()
|
||||
ctx['has_sync_problems'] = can_change_event_settings and request.event.queued_sync_jobs.filter(
|
||||
Q(need_manual_retry__isnull=False)
|
||||
| Q(failed_attempts__gt=0)
|
||||
).exists()
|
||||
|
||||
ctx['timeline'] = [
|
||||
{
|
||||
|
||||
150
src/pretix/control/views/datasync.py
Normal file
150
src/pretix/control/views/datasync.py
Normal file
@@ -0,0 +1,150 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
|
||||
from itertools import groupby
|
||||
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpResponseNotAllowed
|
||||
from django.shortcuts import redirect
|
||||
from django.template.loader import get_template
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import ListView
|
||||
|
||||
from pretix.base.datasync.datasync import datasync_providers
|
||||
from pretix.base.models import Event, Order
|
||||
from pretix.base.models.datasync import OrderSyncQueue
|
||||
from pretix.control.permissions import (
|
||||
AdministratorPermissionRequiredMixin, EventPermissionRequiredMixin,
|
||||
OrganizerPermissionRequiredMixin,
|
||||
)
|
||||
from pretix.control.signals import order_info
|
||||
from pretix.control.views.orders import OrderView
|
||||
|
||||
|
||||
@receiver(order_info, dispatch_uid="datasync_control_order_info")
|
||||
def on_control_order_info(sender: Event, request, order: Order, **kwargs):
|
||||
providers = [provider for provider, meta in datasync_providers.filter(active_in=sender)]
|
||||
if not providers:
|
||||
return ""
|
||||
|
||||
queued = {p.sync_provider: p for p in order.queued_sync_jobs.all()}
|
||||
objects = {
|
||||
provider: list(objects)
|
||||
for (provider, objects)
|
||||
in groupby(order.sync_results.order_by('sync_provider').all(), key=lambda o: o.sync_provider)
|
||||
}
|
||||
providers = [(provider.identifier, provider.display_name, queued.get(provider.identifier), objects.get(provider.identifier)) for provider in providers]
|
||||
|
||||
template = get_template("pretixcontrol/datasync/control_order_info.html")
|
||||
ctx = {
|
||||
"order": order,
|
||||
"request": request,
|
||||
"event": sender,
|
||||
"providers": providers,
|
||||
"now": now(),
|
||||
}
|
||||
return template.render(ctx, request=request)
|
||||
|
||||
|
||||
class ControlSyncJob(OrderView):
|
||||
permission = 'can_change_orders'
|
||||
|
||||
def post(self, request, provider, *args, **kwargs):
|
||||
prov, meta = datasync_providers.get(active_in=self.request.event, identifier=provider)
|
||||
|
||||
if self.request.POST.get("queue_sync") == "true":
|
||||
prov.enqueue_order(self.order, 'user')
|
||||
messages.success(self.request, _('The sync job has been enqueued and will run in the next minutes.'))
|
||||
elif self.request.POST.get("cancel_job"):
|
||||
job = self.order.queued_sync_jobs.get(pk=self.request.POST.get("cancel_job"))
|
||||
if job.in_flight:
|
||||
messages.warning(self.request, _('The sync job is already in progress.'))
|
||||
else:
|
||||
job.delete()
|
||||
messages.success(self.request, _('The sync job has been canceled.'))
|
||||
elif self.request.POST.get("run_job_now"):
|
||||
job = self.order.queued_sync_jobs.get(pk=self.request.POST.get("run_job_now"))
|
||||
job.not_before = now()
|
||||
job.need_manual_retry = None
|
||||
job.save()
|
||||
messages.success(self.request, _('The sync job has been set to run as soon as possible.'))
|
||||
|
||||
return redirect(self.get_order_url())
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
return HttpResponseNotAllowed(['POST'])
|
||||
|
||||
|
||||
class FailedSyncJobsView(ListView):
|
||||
template_name = 'pretixcontrol/datasync/failed_jobs.html'
|
||||
model = OrderSyncQueue
|
||||
context_object_name = 'queue_items'
|
||||
paginate_by = 100
|
||||
ordering = ('triggered',)
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(
|
||||
Q(need_manual_retry__isnull=False)
|
||||
| Q(failed_attempts__gt=0)
|
||||
).select_related(
|
||||
'order'
|
||||
)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
items = self.get_queryset().filter(pk__in=request.POST.getlist('idlist'))
|
||||
|
||||
if self.request.POST.get("action") == "retry":
|
||||
for item in items:
|
||||
item.not_before = now()
|
||||
item.need_manual_retry = None
|
||||
item.save()
|
||||
messages.success(self.request, _('The selected jobs have been set to run as soon as possible.'))
|
||||
elif self.request.POST.get("action") == "cancel":
|
||||
items.delete()
|
||||
messages.success(self.request, _('The selected jobs have been canceled.'))
|
||||
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
|
||||
class GlobalFailedSyncJobsView(AdministratorPermissionRequiredMixin, FailedSyncJobsView):
|
||||
pass
|
||||
|
||||
|
||||
class OrganizerFailedSyncJobsView(OrganizerPermissionRequiredMixin, FailedSyncJobsView):
|
||||
permission = "can_change_organizer_settings"
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(
|
||||
event__organizer=self.request.organizer
|
||||
)
|
||||
|
||||
|
||||
class EventFailedSyncJobsView(EventPermissionRequiredMixin, FailedSyncJobsView):
|
||||
permission = "can_change_event_settings"
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(
|
||||
event=self.request.event
|
||||
)
|
||||
Reference in New Issue
Block a user