forked from CGM_Public/pretix_original
215
doc/api/resources/exporters.rst
Normal file
215
doc/api/resources/exporters.rst
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
.. spelling:: checkin
|
||||||
|
|
||||||
|
Data exporters
|
||||||
|
==============
|
||||||
|
|
||||||
|
pretix and it's plugins include a number of data exporters that allow you to bulk download various data from pretix in
|
||||||
|
different formats. This page shows you how to use these exporters through the API.
|
||||||
|
|
||||||
|
.. versionchanged:: 3.13
|
||||||
|
|
||||||
|
This feature has been added to the API.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
While we consider the methods listed on this page to be a stable API, the availability and specific input field
|
||||||
|
requirements of individual exporters is **not considered a stable API**. Specific exporters and their input parameters
|
||||||
|
may change at any time without warning.
|
||||||
|
|
||||||
|
Listing available exporters
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/exporters/
|
||||||
|
|
||||||
|
Returns a list of all exporters available for a given event. You will receive a list of export methods as well as their
|
||||||
|
supported input fields. Note that the exact type and validation requirements of the input fields are not given in the
|
||||||
|
response, and you might need to look into the pretix web interface to figure out the exact input required.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/exporters/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"identifier": "orderlist",
|
||||||
|
"verbose_name": "Order data",
|
||||||
|
"input_parameters": [
|
||||||
|
{
|
||||||
|
"name": "_format",
|
||||||
|
"required": true,
|
||||||
|
"choices": [
|
||||||
|
"xlsx",
|
||||||
|
"orders:default",
|
||||||
|
"orders:excel",
|
||||||
|
"orders:semicolon",
|
||||||
|
"positions:default",
|
||||||
|
"positions:excel",
|
||||||
|
"positions:semicolon",
|
||||||
|
"fees:default",
|
||||||
|
"fees:excel",
|
||||||
|
"fees:semicolon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "paid_only",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||||
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/exporters/
|
||||||
|
|
||||||
|
Returns a list of all cross-event exporters available for a given organizer. You will receive a list of export methods as well as their
|
||||||
|
supported input fields. Note that the exact type and validation requirements of the input fields are not given in the
|
||||||
|
response, and you might need to look into the pretix web interface to figure out the exact input required.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/exporters/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"count": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"identifier": "orderlist",
|
||||||
|
"verbose_name": "Order data",
|
||||||
|
"input_parameters": [
|
||||||
|
{
|
||||||
|
"name": "events",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "_format",
|
||||||
|
"required": true,
|
||||||
|
"choices": [
|
||||||
|
"xlsx",
|
||||||
|
"orders:default",
|
||||||
|
"orders:excel",
|
||||||
|
"orders:semicolon",
|
||||||
|
"positions:default",
|
||||||
|
"positions:excel",
|
||||||
|
"positions:semicolon",
|
||||||
|
"fees:default",
|
||||||
|
"fees:excel",
|
||||||
|
"fees:semicolon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "paid_only",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||||
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
|
:statuscode 200: no error
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
|
|
||||||
|
Running an export
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
Since exports often include large data sets, they might take longer than the duration of an HTTP request. Therefore,
|
||||||
|
creating an export is a two-step process. First you need to start an export task with one of the following to API
|
||||||
|
endpoints:
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/exporters/(identifier)/run/
|
||||||
|
|
||||||
|
Starts an export task. If your input parameters validate correctly, a ``202 Accepted`` status code is returned.
|
||||||
|
The body points you to the download URL of the result.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
POST /api/v1/organizers/bigevents/events/sampleconf/exporters/orderlist/run/ HTTP/1.1
|
||||||
|
Host: pretix.eu
|
||||||
|
Accept: application/json, text/javascript
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"_format": "xlsx"
|
||||||
|
}
|
||||||
|
|
||||||
|
**Example response**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Vary: Accept
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"download": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderlist/download/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/"
|
||||||
|
}
|
||||||
|
|
||||||
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
|
:param event: The ``slug`` field of the event to fetch
|
||||||
|
:param identifier: The ``identifier`` field of the exporter to run
|
||||||
|
:statuscode 202: no error
|
||||||
|
:statuscode 400: Invalid input options
|
||||||
|
:statuscode 401: Authentication failure
|
||||||
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
|
|
||||||
|
.. http:post:: /api/v1/organizers/(organizer)/exporters/(identifier)/run/
|
||||||
|
|
||||||
|
The endpoint for organizer-level exports works just like event-level exports (see above).
|
||||||
|
|
||||||
|
|
||||||
|
Downloading the result
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
When starting an export, you receive a ``url`` for downloading the result. Running a ``GET`` request on that result will
|
||||||
|
yield one of the following status codes:
|
||||||
|
|
||||||
|
* ``200 OK`` – The export succeeded. The body will be your resulting file. Might be large!
|
||||||
|
* ``409 Conflict`` – Your export is still running. The body will be JSON with the structure ``{"status": "running", "percentage": 40}``. ``percentage`` can be ``null`` if it is not known and ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do.
|
||||||
|
* ``410 Gone`` – Running the export has failed permanently. The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}``
|
||||||
|
* ``404 Not Found`` – The export does not exist / is expired.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
Running exports puts a lot of stress on the system, we kindly ask you not to run more than two exports at the same time.
|
||||||
|
|
||||||
@@ -27,5 +27,6 @@ Resources and endpoints
|
|||||||
devices
|
devices
|
||||||
webhooks
|
webhooks
|
||||||
seatingplans
|
seatingplans
|
||||||
|
exporters
|
||||||
billing_invoices
|
billing_invoices
|
||||||
billing_var
|
billing_var
|
||||||
|
|||||||
110
src/pretix/api/serializers/exporters.py
Normal file
110
src/pretix/api/serializers/exporters.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
from django import forms
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class FormFieldWrapperField(serializers.Field):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.form_field = kwargs.pop('form_field')
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def to_representation(self, value):
|
||||||
|
return self.form_field.widget.format_value(value)
|
||||||
|
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
d = self.form_field.widget.value_from_datadict({'name': data}, {}, 'name')
|
||||||
|
d = self.form_field.clean(d)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
simple_mappings = (
|
||||||
|
(forms.DateField, serializers.DateField, tuple()),
|
||||||
|
(forms.TimeField, serializers.TimeField, tuple()),
|
||||||
|
(forms.SplitDateTimeField, serializers.DateTimeField, tuple()),
|
||||||
|
(forms.DateTimeField, serializers.DateTimeField, tuple()),
|
||||||
|
(forms.DecimalField, serializers.DecimalField, ('max_digits', 'decimal_places', 'min_value', 'max_value')),
|
||||||
|
(forms.FloatField, serializers.FloatField, tuple()),
|
||||||
|
(forms.IntegerField, serializers.IntegerField, tuple()),
|
||||||
|
(forms.EmailField, serializers.EmailField, tuple()),
|
||||||
|
(forms.UUIDField, serializers.UUIDField, tuple()),
|
||||||
|
(forms.URLField, serializers.URLField, tuple()),
|
||||||
|
(forms.NullBooleanField, serializers.NullBooleanField, tuple()),
|
||||||
|
(forms.BooleanField, serializers.BooleanField, tuple()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SerializerDescriptionField(serializers.Field):
|
||||||
|
def to_representation(self, value):
|
||||||
|
fields = []
|
||||||
|
for k, v in value.fields.items():
|
||||||
|
d = {
|
||||||
|
'name': k,
|
||||||
|
'required': v.required,
|
||||||
|
}
|
||||||
|
if isinstance(v, serializers.ChoiceField):
|
||||||
|
d['choices'] = list(v.choices.keys())
|
||||||
|
fields.append(d)
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
class ExporterSerializer(serializers.Serializer):
|
||||||
|
identifier = serializers.CharField()
|
||||||
|
verbose_name = serializers.CharField()
|
||||||
|
input_parameters = SerializerDescriptionField(source='_serializer')
|
||||||
|
|
||||||
|
|
||||||
|
class JobRunSerializer(serializers.Serializer):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
ex = kwargs.pop('exporter')
|
||||||
|
events = kwargs.pop('events', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if events is not None:
|
||||||
|
self.fields["events"] = serializers.SlugRelatedField(
|
||||||
|
queryset=events,
|
||||||
|
required=True,
|
||||||
|
allow_empty=False,
|
||||||
|
slug_field='slug',
|
||||||
|
many=True
|
||||||
|
)
|
||||||
|
for k, v in ex.export_form_fields.items():
|
||||||
|
for m_from, m_to, m_kwargs in simple_mappings:
|
||||||
|
if isinstance(v, m_from):
|
||||||
|
self.fields[k] = m_to(
|
||||||
|
required=v.required,
|
||||||
|
allow_null=not v.required,
|
||||||
|
validators=v.validators,
|
||||||
|
**{kwarg: getattr(v, kwargs, None) for kwarg in m_kwargs}
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if isinstance(v, forms.ModelMultipleChoiceField):
|
||||||
|
self.fields[k] = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=v.queryset,
|
||||||
|
required=v.required,
|
||||||
|
allow_empty=not v.required,
|
||||||
|
validators=v.validators,
|
||||||
|
many=True
|
||||||
|
)
|
||||||
|
elif isinstance(v, forms.ModelChoiceField):
|
||||||
|
self.fields[k] = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=v.queryset,
|
||||||
|
required=v.required,
|
||||||
|
allow_null=not v.required,
|
||||||
|
validators=v.validators,
|
||||||
|
)
|
||||||
|
elif isinstance(v, forms.MultipleChoiceField):
|
||||||
|
self.fields[k] = serializers.MultipleChoiceField(
|
||||||
|
choices=v.choices,
|
||||||
|
required=v.required,
|
||||||
|
allow_empty=not v.required,
|
||||||
|
validators=v.validators,
|
||||||
|
)
|
||||||
|
elif isinstance(v, forms.ChoiceField):
|
||||||
|
self.fields[k] = serializers.ChoiceField(
|
||||||
|
choices=v.choices,
|
||||||
|
required=v.required,
|
||||||
|
allow_null=not v.required,
|
||||||
|
validators=v.validators,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.fields[k] = FormFieldWrapperField(form_field=v, required=v.required, allow_null=not v.required)
|
||||||
@@ -7,8 +7,8 @@ from rest_framework import routers
|
|||||||
from pretix.api.views import cart
|
from pretix.api.views import cart
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
checkin, device, event, item, oauth, order, organizer, user, version,
|
checkin, device, event, exporters, item, oauth, order, organizer, user,
|
||||||
voucher, waitinglist, webhooks,
|
version, voucher, waitinglist, webhooks,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = routers.DefaultRouter()
|
router = routers.DefaultRouter()
|
||||||
@@ -22,6 +22,7 @@ orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
|
|||||||
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
|
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
|
||||||
orga_router.register(r'teams', organizer.TeamViewSet)
|
orga_router.register(r'teams', organizer.TeamViewSet)
|
||||||
orga_router.register(r'devices', organizer.DeviceViewSet)
|
orga_router.register(r'devices', organizer.DeviceViewSet)
|
||||||
|
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
|
||||||
|
|
||||||
team_router = routers.DefaultRouter()
|
team_router = routers.DefaultRouter()
|
||||||
team_router.register(r'members', organizer.TeamMemberViewSet)
|
team_router.register(r'members', organizer.TeamMemberViewSet)
|
||||||
@@ -44,6 +45,7 @@ event_router.register(r'taxrules', event.TaxRuleViewSet)
|
|||||||
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
||||||
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
||||||
event_router.register(r'cartpositions', cart.CartPositionViewSet)
|
event_router.register(r'cartpositions', cart.CartPositionViewSet)
|
||||||
|
event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters')
|
||||||
|
|
||||||
checkinlist_router = routers.DefaultRouter()
|
checkinlist_router = routers.DefaultRouter()
|
||||||
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')
|
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet, basename='checkinlistpos')
|
||||||
|
|||||||
154
src/pretix/api/views/exporters.py
Normal file
154
src/pretix/api/views/exporters.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import Http404
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils.functional import cached_property
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.reverse import reverse
|
||||||
|
|
||||||
|
from pretix.api.serializers.exporters import (
|
||||||
|
ExporterSerializer, JobRunSerializer,
|
||||||
|
)
|
||||||
|
from pretix.base.models import CachedFile, Device, TeamAPIToken
|
||||||
|
from pretix.base.services.export import export, multiexport
|
||||||
|
from pretix.base.signals import (
|
||||||
|
register_data_exporters, register_multievent_data_exporters,
|
||||||
|
)
|
||||||
|
from pretix.helpers.http import ChunkBasedFileResponse
|
||||||
|
|
||||||
|
|
||||||
|
class ExportersMixin:
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
res = ExporterSerializer(self.exporters, many=True)
|
||||||
|
return Response({
|
||||||
|
"count": len(self.exporters),
|
||||||
|
"next": None,
|
||||||
|
"previous": None,
|
||||||
|
"results": res.data
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
instances = [e for e in self.exporters if e.identifier == self.kwargs.get('pk')]
|
||||||
|
if not instances:
|
||||||
|
raise Http404()
|
||||||
|
return instances[0]
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
serializer = ExporterSerializer(instance)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
||||||
|
def download(self, *args, **kwargs):
|
||||||
|
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
||||||
|
if cf.file:
|
||||||
|
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
||||||
|
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
|
||||||
|
return resp
|
||||||
|
elif not settings.HAS_CELERY:
|
||||||
|
return Response(
|
||||||
|
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
|
||||||
|
status=status.HTTP_410_GONE
|
||||||
|
)
|
||||||
|
|
||||||
|
res = AsyncResult(kwargs['asyncid'])
|
||||||
|
if res.failed():
|
||||||
|
if isinstance(res.info, dict) and res.info['exc_type'] == 'ExportError':
|
||||||
|
msg = res.info['exc_message']
|
||||||
|
else:
|
||||||
|
msg = 'Internal error'
|
||||||
|
return Response(
|
||||||
|
{'status': 'failed', 'message': msg},
|
||||||
|
status=status.HTTP_410_GONE
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'status': 'running' if res.state in ('PROGRESS', 'STARTED', 'SUCCESS') else 'waiting',
|
||||||
|
'percentage': res.result.get('value', None) if res.result else None,
|
||||||
|
},
|
||||||
|
status=status.HTTP_409_CONFLICT
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['POST'])
|
||||||
|
def run(self, *args, **kwargs):
|
||||||
|
instance = self.get_object()
|
||||||
|
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
cf = CachedFile()
|
||||||
|
cf.date = now()
|
||||||
|
cf.expires = now() + timedelta(days=3)
|
||||||
|
cf.save()
|
||||||
|
d = serializer.data
|
||||||
|
for k, v in d.items():
|
||||||
|
if isinstance(v, set):
|
||||||
|
d[k] = list(v)
|
||||||
|
async_result = self.do_export(cf, instance, d)
|
||||||
|
|
||||||
|
url_kwargs = {
|
||||||
|
'asyncid': str(async_result.id),
|
||||||
|
'cfid': str(cf.id),
|
||||||
|
}
|
||||||
|
url_kwargs.update(self.kwargs)
|
||||||
|
return Response({
|
||||||
|
'download': reverse('api-v1:exporters-download', kwargs=url_kwargs, request=self.request)
|
||||||
|
}, status=status.HTTP_202_ACCEPTED)
|
||||||
|
|
||||||
|
|
||||||
|
class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||||
|
permission = 'can_view_orders'
|
||||||
|
|
||||||
|
def get_serializer_kwargs(self):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def exporters(self):
|
||||||
|
exporters = []
|
||||||
|
responses = register_data_exporters.send(self.request.event)
|
||||||
|
for ex in sorted([response(self.request.event) for r, response in responses], key=lambda ex: str(ex.verbose_name)):
|
||||||
|
ex._serializer = JobRunSerializer(exporter=ex)
|
||||||
|
exporters.append(ex)
|
||||||
|
return exporters
|
||||||
|
|
||||||
|
def do_export(self, cf, instance, data):
|
||||||
|
return export.apply_async(args=(self.request.event.id, str(cf.id), instance.identifier, data))
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||||
|
permission = None
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def exporters(self):
|
||||||
|
exporters = []
|
||||||
|
events = (self.request.auth or self.request.user).get_events_with_permission('can_view_orders', request=self.request).filter(
|
||||||
|
organizer=self.request.organizer
|
||||||
|
)
|
||||||
|
responses = register_multievent_data_exporters.send(self.request.organizer)
|
||||||
|
for ex in sorted([response(events) for r, response in responses if response], key=lambda ex: str(ex.verbose_name)):
|
||||||
|
ex._serializer = JobRunSerializer(exporter=ex, events=events)
|
||||||
|
exporters.append(ex)
|
||||||
|
return exporters
|
||||||
|
|
||||||
|
def get_serializer_kwargs(self):
|
||||||
|
return {
|
||||||
|
'events': self.request.auth.get_events_with_permission('can_view_orders', request=self.request).filter(
|
||||||
|
organizer=self.request.organizer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def do_export(self, cf, instance, data):
|
||||||
|
return multiexport.apply_async(kwargs={
|
||||||
|
'organizer': self.request.organizer.id,
|
||||||
|
'user': self.request.user.id if self.request.user.is_authenticated else None,
|
||||||
|
'token': self.request.auth.pk if isinstance(self.request.auth, TeamAPIToken) else None,
|
||||||
|
'device': self.request.auth.pk if isinstance(self.request.auth, Device) else None,
|
||||||
|
'fileid': str(cf.id),
|
||||||
|
'provider': instance.identifier,
|
||||||
|
'form_data': data
|
||||||
|
})
|
||||||
@@ -41,7 +41,7 @@ class MailExporter(BaseExporter):
|
|||||||
initial=[Order.STATUS_PENDING, Order.STATUS_PAID],
|
initial=[Order.STATUS_PENDING, Order.STATUS_PAID],
|
||||||
choices=Order.STATUS_CHOICE,
|
choices=Order.STATUS_CHOICE,
|
||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple,
|
||||||
required=False
|
required=True
|
||||||
)),
|
)),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -222,3 +222,15 @@ class Device(LoggedModel):
|
|||||||
return self.organizer.events.all()
|
return self.organizer.events.all()
|
||||||
else:
|
else:
|
||||||
return self.limit_events.all()
|
return self.limit_events.all()
|
||||||
|
|
||||||
|
def get_events_with_permission(self, permission, request=None):
|
||||||
|
"""
|
||||||
|
Returns a queryset of events the device has a specific permissions to.
|
||||||
|
|
||||||
|
:param request: Ignored, for compatibility with User model
|
||||||
|
:return: Iterable of Events
|
||||||
|
"""
|
||||||
|
if permission in self.permission_set():
|
||||||
|
return self.get_events_with_any_permission()
|
||||||
|
else:
|
||||||
|
return self.organizer.events.none()
|
||||||
|
|||||||
@@ -357,3 +357,15 @@ class TeamAPIToken(models.Model):
|
|||||||
return self.team.organizer.events.all()
|
return self.team.organizer.events.all()
|
||||||
else:
|
else:
|
||||||
return self.team.limit_events.all()
|
return self.team.limit_events.all()
|
||||||
|
|
||||||
|
def get_events_with_permission(self, permission, request=None):
|
||||||
|
"""
|
||||||
|
Returns a queryset of events the token has a specific permissions to.
|
||||||
|
|
||||||
|
:param request: Ignored, for compatibility with User model
|
||||||
|
:return: Iterable of Events
|
||||||
|
"""
|
||||||
|
if getattr(self.team, permission, False):
|
||||||
|
return self.get_events_with_any_permission()
|
||||||
|
else:
|
||||||
|
return self.team.organizer.events.none()
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.utils.timezone import override
|
from django.utils.timezone import override
|
||||||
from django.utils.translation import gettext
|
from django.utils.translation import gettext
|
||||||
|
|
||||||
from pretix.base.i18n import LazyLocaleException, language
|
from pretix.base.i18n import LazyLocaleException, language
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CachedFile, Event, Organizer, User, cachedfile_name,
|
CachedFile, Device, Event, Organizer, TeamAPIToken, User, cachedfile_name,
|
||||||
)
|
)
|
||||||
from pretix.base.services.tasks import (
|
from pretix.base.services.tasks import (
|
||||||
ProfiledEventTask, ProfiledOrganizerUserTask,
|
ProfiledEventTask, ProfiledOrganizerUserTask,
|
||||||
@@ -48,7 +49,13 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
|
|||||||
|
|
||||||
|
|
||||||
@app.task(base=ProfiledOrganizerUserTask, throws=(ExportError,), bind=True)
|
@app.task(base=ProfiledOrganizerUserTask, throws=(ExportError,), bind=True)
|
||||||
def multiexport(self, organizer: Organizer, user: User, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
|
def multiexport(self, organizer: Organizer, user: User, device: int, token: int, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
|
||||||
|
if device:
|
||||||
|
device = Device.objects.get(pk=device)
|
||||||
|
if token:
|
||||||
|
device = TeamAPIToken.objects.get(pk=token)
|
||||||
|
allowed_events = (device or token or user).get_events_with_permission('can_view_orders')
|
||||||
|
|
||||||
def set_progress(val):
|
def set_progress(val):
|
||||||
if not self.request.called_directly:
|
if not self.request.called_directly:
|
||||||
self.update_state(
|
self.update_state(
|
||||||
@@ -57,10 +64,22 @@ def multiexport(self, organizer: Organizer, user: User, fileid: str, provider: s
|
|||||||
)
|
)
|
||||||
|
|
||||||
file = CachedFile.objects.get(id=fileid)
|
file = CachedFile.objects.get(id=fileid)
|
||||||
with language(user.locale), override(user.timezone):
|
if user:
|
||||||
allowed_events = user.get_events_with_permission('can_view_orders')
|
locale = user.locale
|
||||||
|
timezone = user.timezone
|
||||||
events = allowed_events.filter(pk__in=form_data.get('events'))
|
else:
|
||||||
|
e = allowed_events.first()
|
||||||
|
if e:
|
||||||
|
locale = e.settings.locale
|
||||||
|
timezone = e.settings.timezone
|
||||||
|
else:
|
||||||
|
locale = settings.LANGUAGE_CODE
|
||||||
|
timezone = settings.TIME_ZONE
|
||||||
|
with language(locale), override(timezone):
|
||||||
|
if isinstance(form_data['events'][0], str):
|
||||||
|
events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer)
|
||||||
|
else:
|
||||||
|
events = allowed_events.filter(pk__in=form_data.get('events'))
|
||||||
responses = register_multievent_data_exporters.send(organizer)
|
responses = register_multievent_data_exporters.send(organizer)
|
||||||
|
|
||||||
for receiver, response in responses:
|
for receiver, response in responses:
|
||||||
|
|||||||
@@ -96,8 +96,9 @@ class OrganizerUserTask(app.Task):
|
|||||||
kwargs['organizer'] = organizer
|
kwargs['organizer'] = organizer
|
||||||
|
|
||||||
user_id = kwargs['user']
|
user_id = kwargs['user']
|
||||||
user = User.objects.get(pk=user_id)
|
if user_id is not None:
|
||||||
kwargs['user'] = user
|
user = User.objects.get(pk=user_id)
|
||||||
|
kwargs['user'] = user
|
||||||
|
|
||||||
with scope(organizer=organizer):
|
with scope(organizer=organizer):
|
||||||
ret = super().__call__(*args, **kwargs)
|
ret = super().__call__(*args, **kwargs)
|
||||||
|
|||||||
134
src/tests/api/test_exporters.py
Normal file
134
src/tests/api/test_exporters.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import copy
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pretix.base.models import CachedFile
|
||||||
|
|
||||||
|
SAMPLE_EXPORTER_CONFIG = {
|
||||||
|
"identifier": "orderlist",
|
||||||
|
"verbose_name": "Order data",
|
||||||
|
"input_parameters": [
|
||||||
|
{
|
||||||
|
"name": "_format",
|
||||||
|
"required": True,
|
||||||
|
"choices": [
|
||||||
|
"xlsx",
|
||||||
|
"orders:default",
|
||||||
|
"orders:excel",
|
||||||
|
"orders:semicolon",
|
||||||
|
"positions:default",
|
||||||
|
"positions:excel",
|
||||||
|
"positions:semicolon",
|
||||||
|
"fees:default",
|
||||||
|
"fees:excel",
|
||||||
|
"fees:semicolon"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "paid_only",
|
||||||
|
"required": False
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_event_list(token_client, organizer, event):
|
||||||
|
c = copy.deepcopy(SAMPLE_EXPORTER_CONFIG)
|
||||||
|
resp = token_client.get('/api/v1/organizers/{}/events/{}/exporters/'.format(organizer.slug, event.slug))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert c in resp.data['results']
|
||||||
|
|
||||||
|
resp = token_client.get('/api/v1/organizers/{}/events/{}/exporters/orderlist/'.format(organizer.slug, event.slug))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert c == resp.data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_org_list(token_client, organizer, event):
|
||||||
|
c = copy.deepcopy(SAMPLE_EXPORTER_CONFIG)
|
||||||
|
c['input_parameters'].insert(0, {
|
||||||
|
"name": "events",
|
||||||
|
"required": True
|
||||||
|
})
|
||||||
|
resp = token_client.get('/api/v1/organizers/{}/exporters/'.format(organizer.slug))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
print(resp.data['results'])
|
||||||
|
assert c in resp.data['results']
|
||||||
|
resp = token_client.get('/api/v1/organizers/{}/exporters/orderlist/'.format(organizer.slug, event.slug))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert c == resp.data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_event_validate(token_client, organizer, team, event):
|
||||||
|
resp = token_client.post('/api/v1/organizers/{}/events/{}/exporters/orderlist/run/'.format(organizer.slug, event.slug), data={
|
||||||
|
}, format='json')
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.data == {"_format": ["This field is required."]}
|
||||||
|
|
||||||
|
resp = token_client.post('/api/v1/organizers/{}/events/{}/exporters/orderlist/run/'.format(organizer.slug, event.slug), data={
|
||||||
|
'_format': 'FOOBAR',
|
||||||
|
}, format='json')
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.data == {"_format": ["\"FOOBAR\" is not a valid choice."]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_org_validate_events(token_client, organizer, team, event):
|
||||||
|
resp = token_client.post('/api/v1/organizers/{}/exporters/orderlist/run/'.format(organizer.slug), data={
|
||||||
|
'_format': 'xlsx',
|
||||||
|
}, format='json')
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.data == {"events": ["This field is required."]}
|
||||||
|
|
||||||
|
resp = token_client.post('/api/v1/organizers/{}/exporters/orderlist/run/'.format(organizer.slug), data={
|
||||||
|
'_format': 'xlsx',
|
||||||
|
'events': ["nonexisting"]
|
||||||
|
}, format='json')
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.data == {"events": ["Object with slug=nonexisting does not exist."]}
|
||||||
|
|
||||||
|
resp = token_client.post('/api/v1/organizers/{}/exporters/orderlist/run/'.format(organizer.slug), data={
|
||||||
|
'events': [event.slug],
|
||||||
|
'_format': 'xlsx'
|
||||||
|
}, format='json')
|
||||||
|
assert resp.status_code == 202
|
||||||
|
|
||||||
|
team.all_events = False
|
||||||
|
team.save()
|
||||||
|
|
||||||
|
resp = token_client.post('/api/v1/organizers/{}/exporters/orderlist/run/'.format(organizer.slug), data={
|
||||||
|
'_format': 'xlsx',
|
||||||
|
'events': [event.slug]
|
||||||
|
}, format='json')
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.data == {"events": [f"Object with slug={event.slug} does not exist."]}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_run_success(token_client, organizer, team, event):
|
||||||
|
resp = token_client.post('/api/v1/organizers/{}/events/{}/exporters/orderlist/run/'.format(organizer.slug, event.slug), data={
|
||||||
|
'_format': 'xlsx',
|
||||||
|
}, format='json')
|
||||||
|
assert resp.status_code == 202
|
||||||
|
assert "download" in resp.data
|
||||||
|
resp = token_client.get("/" + resp.data["download"].split("/", 3)[3])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp["Content-Type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_download_nonexisting(token_client, organizer, team, event):
|
||||||
|
resp = token_client.get('/api/v1/organizers/{}/events/{}/exporters/orderlist/download/{}/{}/'.format(
|
||||||
|
organizer.slug, event.slug, uuid.uuid4(), uuid.uuid4()
|
||||||
|
))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_gone_without_celery(token_client, organizer, team, event):
|
||||||
|
cf = CachedFile.objects.create()
|
||||||
|
resp = token_client.get('/api/v1/organizers/{}/events/{}/exporters/orderlist/download/{}/{}/'.format(organizer.slug, event.slug, uuid.uuid4(), cf.id))
|
||||||
|
assert resp.status_code == 410
|
||||||
@@ -132,6 +132,8 @@ event_permission_sub_urls = [
|
|||||||
('get', 'can_view_orders', 'cartpositions/1/', 404),
|
('get', 'can_view_orders', 'cartpositions/1/', 404),
|
||||||
('post', 'can_change_orders', 'cartpositions/', 400),
|
('post', 'can_change_orders', 'cartpositions/', 400),
|
||||||
('delete', 'can_change_orders', 'cartpositions/1/', 404),
|
('delete', 'can_change_orders', 'cartpositions/1/', 404),
|
||||||
|
('post', 'can_view_orders', 'exporters/invoicedata/run/', 400),
|
||||||
|
('get', 'can_view_orders', 'exporters/invoicedata/download/bc3f9884-26ee-425b-8636-80613f84b6fa/3cb49ae6-eda3-4605-814e-099e23777b36/', 404),
|
||||||
]
|
]
|
||||||
|
|
||||||
org_permission_sub_urls = [
|
org_permission_sub_urls = [
|
||||||
|
|||||||
Reference in New Issue
Block a user