Allow to import orders (#1516)

* Allow to import orders

* seats, subevents

* Plugin support

* Add docs

* Warn about lack of quota handling

* Control interface test

* Test skeleton

* First tests for the impotr columns

* Add tests for all columns

* Fix question validation
This commit is contained in:
Raphael Michel
2019-12-11 11:44:06 +01:00
committed by GitHub
parent 1c99e01af9
commit 24b931e1c3
21 changed files with 2009 additions and 56 deletions

View File

@@ -0,0 +1,53 @@
from django import forms
from django.utils.translation import gettext_lazy as _
from pretix.base.services.orderimport import get_all_columns
class ProcessForm(forms.Form):
orders = forms.ChoiceField(
label=_('Import mode'),
choices=(
('many', _('Create a separate order for each line')),
('one', _('Create one order with one position per line')),
)
)
status = forms.ChoiceField(
label=_('Order status'),
choices=(
('paid', _('Create orders as fully paid')),
('pending', _('Create orders as pending and still require payment')),
)
)
testmode = forms.BooleanField(
label=_('Create orders as test mode orders'),
required=False
)
def __init__(self, *args, **kwargs):
headers = kwargs.pop('headers')
initital = kwargs.pop('initial', {})
self.event = kwargs.pop('event')
initital['testmode'] = self.event.testmode
kwargs['initial'] = initital
super().__init__(*args, **kwargs)
header_choices = [
('csv:{}'.format(h), _('CSV column: "{name}"').format(name=h)) for h in headers
]
for c in get_all_columns(self.event):
choices = []
if c.default_value:
choices.append((c.default_value, c.default_label))
choices += header_choices
for k, v in c.static_choices():
choices.append(('static:{}'.format(k), v))
self.fields[c.identifier] = forms.ChoiceField(
label=str(c.verbose_name),
choices=choices,
widget=forms.Select(
attrs={'data-static': 'true'}
)
)

View File

@@ -169,6 +169,57 @@ def get_event_navigation(request: HttpRequest):
})
if 'can_view_orders' in request.eventpermset:
children = [
{
'label': _('All orders'),
'url': reverse('control:event.orders', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name in ('event.orders', 'event.order') or "event.order." in url.url_name,
},
{
'label': _('Overview'),
'url': reverse('control:event.orders.overview', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.overview' in url.url_name,
},
{
'label': _('Refunds'),
'url': reverse('control:event.orders.refunds', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.refunds' in url.url_name,
},
{
'label': _('Export'),
'url': reverse('control:event.orders.export', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.export' in url.url_name,
},
{
'label': _('Waiting list'),
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.waitinglist' in url.url_name,
},
]
if 'can_change_orders' in request.eventpermset:
children.append({
'label': _('Import'),
'url': reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.import' in url.url_name,
})
nav.append({
'label': _('Orders'),
'url': reverse('control:event.orders', kwargs={
@@ -177,48 +228,7 @@ def get_event_navigation(request: HttpRequest):
}),
'active': False,
'icon': 'shopping-cart',
'children': [
{
'label': _('All orders'),
'url': reverse('control:event.orders', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': url.url_name in ('event.orders', 'event.order') or "event.order." in url.url_name,
},
{
'label': _('Overview'),
'url': reverse('control:event.orders.overview', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.overview' in url.url_name,
},
{
'label': _('Refunds'),
'url': reverse('control:event.orders.refunds', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.refunds' in url.url_name,
},
{
'label': _('Export'),
'url': reverse('control:event.orders.export', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.export' in url.url_name,
},
{
'label': _('Waiting list'),
'url': reverse('control:event.orders.waitinglist', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': 'event.orders.waitinglist' in url.url_name,
},
]
'children': children
})
if 'can_view_vouchers' in request.eventpermset:

View File

@@ -0,0 +1,61 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load static %}
{% load getitem %}
{% load bootstrap3 %}
{% block title %}{% trans "Import attendees" %}{% endblock %}
{% block content %}
<h1>{% trans "Import attendees" %}</h1>
<form action="" method="post" class="form-horizontal" data-asynctask data-asynctask-long>
{% csrf_token %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Data preview" %}</h3>
</div>
<div class="table-responsive panel-body">
<table class="table table-condensed">
<thead>
<tr>
{% for fn in parsed.fieldnames %}
<th>{{ fn }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for r in sample_rows %}
<tr>
{% for fn in parsed.fieldnames %}
<td>{{ r|getitem:fn }}</td>
{% endfor %}
</tr>
{% endfor %}
<tr>
<td class="text-center" colspan="{{ parsed.fieldnames|length }}">
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Import settings" %}</h3>
</div>
<div class="panel-body">
{% bootstrap_form_errors form %}
{% bootstrap_form form layout="horizontal" %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
The import will be performed regardless of your quotas, so it will be possible to overbook your event using this option.
{% endblocktrans %}
</div>
</div>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Perform import" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Import attendees" %}{% endblock %}
{% block content %}
<h1>{% trans "Import attendees" %}</h1>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Upload a new file" %}</h3>
</div>
<div class="panel-body">
<form action="" method="post" enctype="multipart/form-data" class="form-inline">
{% csrf_token %}
<p>
{% blocktrans trimmed %}
The uploaded file should be a CSV file with a header row. You will be able to assign the
meanings of the different columns in the next step.
{% endblocktrans %}
</p>
<div class="form-group">
<label for="file">{% trans "Import file" %}: </label> <input id="file" type="file" name="file"/>
</div>
<div class="clearfix"></div>
<button class="btn btn-primary pull-right flip" type="submit">
<span class="icon icon-upload"></span> {% trans "Start import" %}
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
from django import template
register = template.Library()
@register.filter(name='getitem')
def getitem_filter(value, itemname):
if not value:
return ''
return value[itemname]

View File

@@ -2,8 +2,8 @@ from django.conf.urls import include, url
from pretix.control.views import (
auth, checkin, dashboards, event, geo, global_settings, item, main, oauth,
orders, organizer, pdf, search, shredder, subevents, typeahead, user,
users, vouchers, waitinglist,
orderimport, orders, organizer, pdf, search, shredder, subevents,
typeahead, user, users, vouchers, waitinglist,
)
urlpatterns = [
@@ -257,6 +257,8 @@ urlpatterns = [
url(r'^invoice/(?P<invoice>[^/]+)$', orders.InvoiceDownload.as_view(),
name='event.invoice.download'),
url(r'^orders/overview/$', orders.OverView.as_view(), name='event.orders.overview'),
url(r'^orders/import/$', orderimport.ImportView.as_view(), name='event.orders.import'),
url(r'^orders/import/(?P<file>[^/]+)/$', orderimport.ProcessView.as_view(), name='event.orders.import.process'),
url(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
url(r'^orders/export/do$', orders.ExportDoView.as_view(), name='event.orders.export.do'),
url(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'),

View File

@@ -0,0 +1,125 @@
import logging
from datetime import timedelta
from django.conf import settings
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.views.generic import FormView, TemplateView
from pretix.base.models import CachedFile
from pretix.base.services.orderimport import import_orders, parse_csv
from pretix.base.views.tasks import AsyncAction
from pretix.control.forms.orderimport import ProcessForm
from pretix.control.permissions import EventPermissionRequiredMixin
logger = logging.getLogger(__name__)
class ImportView(EventPermissionRequiredMixin, TemplateView):
template_name = 'pretixcontrol/orders/import_start.html'
permission = 'can_change_orders'
def post(self, request, *args, **kwargs):
if 'file' not in request.FILES:
return redirect(reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}))
if not request.FILES['file'].name.endswith('.csv'):
messages.error(request, _('Please only upload CSV files.'))
return redirect(reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}))
if request.FILES['file'].size > 1024 * 1024 * 10:
messages.error(request, _('Please do not upload files larger than 10 MB.'))
return redirect(reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}))
cf = CachedFile.objects.create(
expires=now() + timedelta(days=1),
date=now(),
filename='import.csv',
type='text/csv',
)
cf.file.save('import.csv', request.FILES['file'])
return redirect(reverse('control:event.orders.import.process', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
'file': cf.id
}))
class ProcessView(EventPermissionRequiredMixin, AsyncAction, FormView):
permission = 'can_change_orders'
template_name = 'pretixcontrol/orders/import_process.html'
form_class = ProcessForm
task = import_orders
known_errortypes = ['DataImportError']
def get_form_kwargs(self):
k = super().get_form_kwargs()
k.update({
'event': self.request.event,
'initial': self.request.event.settings.order_import_settings,
'headers': self.parsed.fieldnames
})
return k
def form_valid(self, form):
self.request.event.settings.order_import_settings = form.cleaned_data
return self.do(
self.request.event.pk, self.file.id, form.cleaned_data, self.request.LANGUAGE_CODE,
self.request.user.pk
)
@cached_property
def file(self):
return get_object_or_404(CachedFile, pk=self.kwargs.get("file"), filename="import.csv")
@cached_property
def parsed(self):
return parse_csv(self.file.file, 1024 * 1024)
def get(self, request, *args, **kwargs):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
return FormView.get(self, request, *args, **kwargs)
def get_success_message(self, value):
return _('The import was successful.')
def get_success_url(self, value):
return reverse('control:event.orders', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
})
def dispatch(self, request, *args, **kwargs):
if not self.parsed:
messages.error(request, _('We\'ve been unable to parse the uploaded file as a CSV file.'))
return redirect(reverse('control:event.orders.import', kwargs={
'event': request.event.slug,
'organizer': request.organizer.slug,
}))
return super().dispatch(request, *args, **kwargs)
def get_error_url(self):
return reverse('control:event.orders.import.process', kwargs={
'event': self.request.event.slug,
'organizer': self.request.organizer.slug,
'file': self.file.id
})
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['file'] = self.file
ctx['parsed'] = self.parsed
ctx['sample_rows'] = list(self.parsed)[:3]
return ctx