Add sub-events and relative date settings (#503)

* Data model

* little crud

* SubEventItemForm etc

* Drop SubEventItem.active, quota editor

* Fix failing tests

* First frontend stuff

* Addons form stuff

* Quota calculation

* net price display on EventIndex

* Add tests, solve some bugs

* Correct quota selection in more places, consolidate pricing logic

* Fix failing quota tests

* Fix TypeError

* Add tests for checkout

* Fixed a bug in QuotaForm

* Prevent immutable cart if a quota was removed from an item

* Add tests for pricing

* Handle waiting list

* Filter in check-in list

* Fixed import lost in rebase

* Fix waiting list widget

* Voucher management

* Voucher redemption

* Fix broken tests

* Add subevents to OrderChangeManager

* Create a subevent during event creation

* Fix bulk voucher creation

* Introduce subevent.active

* Copy from for subevents

* Show active in list

* ICal download for subevents

* Check start and end of presale

* Failing tests / show cart logic

* Test

* Rebase migrations

* REST API integration of sub-events

* Integrate quota calculation into the traditional quota form

* Make subevent argument to add_position optional

* Log-display foo

* pretixdroid and subevents

* Filter by subevent

* Add more tests

* Some mor tests

* Rebase fixes

* More tests

* Relative dates

* Restrict selection in relative datetime widgets

* Filter subevent list

* Re-label has_subevents

* Rebase fixes, subevents in calendar view

* Performance and caching issues

* Refactor calendar templates

* Permission tests

* Calendar fixes and month selection

* subevent selection

* Rename subevents to dates

* Add tests for calendar views
This commit is contained in:
Raphael Michel
2017-07-11 13:56:00 +02:00
committed by GitHub
parent 554800c06f
commit 8123effa65
141 changed files with 5920 additions and 1012 deletions

View File

@@ -415,7 +415,7 @@ class ImportView(ListView):
if 'event' in self.kwargs:
ctx['basetpl'] = 'pretixplugins/banktransfer/import_base.html'
if self.request.event.settings.get('payment_term_last'):
if not self.request.event.has_subevents and self.request.event.settings.get('payment_term_last'):
if now() > self.request.event.payment_term_last:
ctx['no_more_payments'] = True
else:

View File

@@ -4,7 +4,9 @@ from collections import OrderedDict
from django import forms
from django.db.models.functions import Coalesce
from django.utils.translation import ugettext as _, ugettext_lazy
from django.utils.translation import (
pgettext, pgettext_lazy, ugettext as _, ugettext_lazy,
)
from pretix.base.exporter import BaseExporter
from pretix.base.models import Order, OrderPosition, Question
@@ -21,7 +23,7 @@ class CSVCheckinList(BaseCheckinList):
@property
def export_form_fields(self):
return OrderedDict(
d = OrderedDict(
[
('items',
forms.ModelMultipleChoiceField(
@@ -61,6 +63,14 @@ class CSVCheckinList(BaseCheckinList):
)),
]
)
if self.event.has_subevents:
d['subevent'] = forms.ModelChoiceField(
self.event.subevents.all(),
label=pgettext_lazy('subevent', 'Date'),
required=False,
empty_label=pgettext_lazy('subevent', 'All dates')
)
return d
def render(self, form_data: dict):
output = io.StringIO()
@@ -81,6 +91,8 @@ class CSVCheckinList(BaseCheckinList):
headers = [
_('Order code'), _('Attendee name'), _('Product'), _('Price')
]
if form_data.get('subevent'):
qs = qs.filter(subevent=form_data.get('subevent'))
if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID)
else:
@@ -93,6 +105,9 @@ class CSVCheckinList(BaseCheckinList):
if self.event.settings.attendee_emails_asked:
headers.append(_('E-mail'))
if self.event.has_subevents:
headers.append(pgettext('subevent', 'Date'))
for q in questions:
headers.append(str(q.question))
@@ -111,6 +126,8 @@ class CSVCheckinList(BaseCheckinList):
row.append(op.secret)
if self.event.settings.attendee_emails_asked:
row.append(op.attendee_email or (op.addon_to.attendee_email if op.addon_to else ''))
if self.event.has_subevents:
row.append(str(op.subevent))
acache = {}
for a in op.answers.all():
acache[a.question_id] = str(a)

View File

@@ -280,7 +280,7 @@ class Paypal(BasePaymentProvider):
order.save()
def order_can_retry(self, order):
return self._is_still_available()
return self._is_still_available(order=order)
def order_prepare(self, request, order):
self.init_api()

View File

@@ -29,12 +29,32 @@
The code tells the app all it needs about your event.
{% endblocktrans %}
</p>
<div id="qrcodeCanvas"></div>
<a href="?flush_key=1" class="btn btn-default">{% trans "Reset authentication token" %}</a>
<script type="text/json" id="qrdata">
{{ qrdata|safe }}
{% if request.event.has_subevents %}
<form class="form-inline helper-display-inline" action="" method="get">
<p>
{% if request.event.has_subevents %}
<select name="subevent" class="form-control">
<option value="">{% trans "Choose date" context "subevent" %}</option>
{% for se in request.event.subevents.all %}
<option value="{{ se.id }}"
{% if request.GET.subevent|add:0 == se.id %}selected="selected"{% endif %}>
{{ se.name }} {{ se.get_date_range_display }}
</option>
{% endfor %}
</select>
{% endif %}
<button class="btn btn-primary" type="submit">{% trans "Show configuration" %}</button>
</p>
</form>
{% endif %}
{% if not request.event.has_subevents or subevent %}
<div id="qrcodeCanvas"></div>
<a href="?flush_key=1" class="btn btn-default">{% trans "Reset authentication token" %}</a>
<script type="text/json" id="qrdata">
{{ qrdata|safe }}
</script>
</script>
{% endif %}
<script type="text/javascript" src="{% static "pretixplugins/pretixdroid/pretixdroid.js" %}"></script>
{% endblock %}

View File

@@ -1,16 +1,22 @@
from django.conf.urls import url
from django.conf.urls import include, url
from . import views
pretixdroid_api_patterns = [
url(r'^redeem/', views.ApiRedeemView.as_view(),
name='api.redeem'),
url(r'^search/', views.ApiSearchView.as_view(),
name='api.search'),
url(r'^download/', views.ApiDownloadView.as_view(),
name='api.download'),
url(r'^status/', views.ApiStatusView.as_view(),
name='api.status'),
]
urlpatterns = [
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/pretixdroid/', views.ConfigView.as_view(),
name='config'),
url(r'^pretixdroid/api/(?P<organizer>[^/]+)/(?P<event>[^/]+)/redeem/', views.ApiRedeemView.as_view(),
name='api.redeem'),
url(r'^pretixdroid/api/(?P<organizer>[^/]+)/(?P<event>[^/]+)/search/', views.ApiSearchView.as_view(),
name='api.search'),
url(r'^pretixdroid/api/(?P<organizer>[^/]+)/(?P<event>[^/]+)/download/', views.ApiDownloadView.as_view(),
name='api.download'),
url(r'^pretixdroid/api/(?P<organizer>[^/]+)/(?P<event>[^/]+)/status/', views.ApiStatusView.as_view(),
name='api.status'),
url(r'^pretixdroid/api/(?P<organizer>[^/]+)/(?P<event>[^/]+)/(?P<subevent>\d+)/',
include(pretixdroid_api_patterns)),
url(r'^pretixdroid/api/(?P<organizer>[^/]+)/(?P<event>[^/]+)/', include(pretixdroid_api_patterns)),
]

View File

@@ -8,6 +8,7 @@ from django.db.models import Count, Q
from django.http import (
HttpResponseForbidden, HttpResponseNotFound, JsonResponse,
)
from django.shortcuts import get_object_or_404
from django.utils.crypto import get_random_string
from django.utils.decorators import method_decorator
from django.utils.timezone import now
@@ -15,6 +16,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView, View
from pretix.base.models import Checkin, Event, Order, OrderPosition
from pretix.base.models.event import SubEvent
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.helpers.urls import build_absolute_uri
from pretix.multidomain.urlreverse import (
@@ -37,13 +39,26 @@ class ConfigView(EventPermissionRequiredMixin, TemplateView):
allowed_chars=string.ascii_uppercase + string.ascii_lowercase + string.digits)
self.request.event.settings.set('pretixdroid_key', key)
subevent = None
url = build_absolute_uri('plugins:pretixdroid:api.redeem', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug
})
if self.request.event.has_subevents:
if self.request.GET.get('subevent'):
subevent = get_object_or_404(SubEvent, event=self.request.event, pk=self.request.GET['subevent'])
url = build_absolute_uri('plugins:pretixdroid:api.redeem', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
'subevent': subevent.pk
})
ctx['subevent'] = subevent
ctx['qrdata'] = json.dumps({
'version': API_VERSION,
'url': build_absolute_uri('plugins:pretixdroid:api.redeem', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug
})[:-7], # the slice removes the redeem/ part at the end
'key': key
'url': url[:-7], # the slice removes the redeem/ part at the end
'key': key,
})
return ctx
@@ -61,9 +76,19 @@ class ApiView(View):
return HttpResponseNotFound('Unknown event')
if (not self.event.settings.get('pretixdroid_key')
or self.event.settings.get('pretixdroid_key') != request.GET.get('key', '')):
or self.event.settings.get('pretixdroid_key') != request.GET.get('key', '-unset-')):
return HttpResponseForbidden('Invalid key')
self.subevent = None
if self.event.has_subevents:
if 'subevent' in kwargs:
self.subevent = get_object_or_404(SubEvent, event=self.event, pk=kwargs['subevent'])
else:
return HttpResponseForbidden('No subevent selected.')
else:
if 'subevent' in kwargs:
return HttpResponseForbidden('Subevents not enabled.')
return super().dispatch(request, **kwargs)
@@ -85,7 +110,7 @@ class ApiRedeemView(ApiView):
with transaction.atomic():
created = False
op = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').get(
order__event=self.event, secret=secret
order__event=self.event, secret=secret, subevent=self.subevent
)
if op.order.status == Order.STATUS_PAID or force:
ci, created = Checkin.objects.get_or_create(position=op, defaults={
@@ -161,6 +186,7 @@ class ApiSearchView(ApiView):
& Q(
Q(secret__istartswith=query) | Q(attendee_name__icontains=query) | Q(order__code__istartswith=query)
)
& Q(subevent=self.subevent)
).annotate(checkin_cnt=Count('checkins'))[:25]
response['results'] = [serialize_op(op) for op in ops]
@@ -177,7 +203,7 @@ class ApiDownloadView(ApiView):
}
ops = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').filter(
Q(order__event=self.event)
Q(order__event=self.event) & Q(subevent=self.subevent)
).annotate(checkin_cnt=Count('checkins'))
response['results'] = [serialize_op(op) for op in ops]
@@ -186,25 +212,27 @@ class ApiDownloadView(ApiView):
class ApiStatusView(ApiView):
def get(self, request, **kwargs):
ev = self.subevent or self.event
response = {
'version': API_VERSION,
'event': {
'name': str(self.event),
'name': str(ev.name),
'slug': self.event.slug,
'organizer': {
'name': str(self.event.organizer),
'slug': self.event.organizer.slug
},
'date_from': self.event.date_from,
'date_to': self.event.date_to,
'subevent': self.subevent.pk if self.subevent else str(self.event),
'date_from': ev.date_from,
'date_to': ev.date_to,
'timezone': self.event.settings.timezone,
'url': event_absolute_uri(self.event, 'presale:event.index')
},
'checkins': Checkin.objects.filter(
position__order__event=self.event
position__order__event=self.event, position__subevent=self.subevent
).count(),
'total': OrderPosition.objects.filter(
order__event=self.event, order__status=Order.STATUS_PAID
order__event=self.event, order__status=Order.STATUS_PAID, subevent=self.subevent
).count()
}
@@ -212,28 +240,32 @@ class ApiStatusView(ApiView):
p['item']: p['cnt']
for p in OrderPosition.objects.filter(
order__event=self.event,
order__status=Order.STATUS_PAID
order__status=Order.STATUS_PAID,
subevent=self.subevent
).order_by().values('item').annotate(cnt=Count('id'))
}
op_by_variation = {
p['variation']: p['cnt']
for p in OrderPosition.objects.filter(
order__event=self.event,
order__status=Order.STATUS_PAID
order__status=Order.STATUS_PAID,
subevent=self.subevent
).order_by().values('variation').annotate(cnt=Count('id'))
}
c_by_item = {
p['position__item']: p['cnt']
for p in Checkin.objects.filter(
position__order__event=self.event,
position__order__status=Order.STATUS_PAID
position__order__status=Order.STATUS_PAID,
position__subevent=self.subevent
).order_by().values('position__item').annotate(cnt=Count('id'))
}
c_by_variation = {
p['position__variation']: p['cnt']
for p in Checkin.objects.filter(
position__order__event=self.event,
position__order__status=Order.STATUS_PAID
position__order__status=Order.STATUS_PAID,
position__subevent=self.subevent
).order_by().values('position__variation').annotate(cnt=Count('id'))
}

View File

@@ -9,10 +9,11 @@ from django.contrib.staticfiles import finders
from django.db.models import Sum
from django.utils.formats import date_format, localize
from django.utils.timezone import now
from django.utils.translation import ugettext as _
from django.utils.translation import pgettext, pgettext_lazy, ugettext as _
from pretix.base.exporter import BaseExporter
from pretix.base.models import Order, OrderPosition
from pretix.base.models.event import SubEvent
from pretix.base.services.stats import order_overview
@@ -161,6 +162,13 @@ class OverviewReport(Report):
Paragraph(_('Orders by product'), headlinestyle),
Spacer(1, 5 * mm)
]
if self.form_data.get('subevent'):
try:
subevent = self.event.subevents.get(pk=self.form_data.get('subevent'))
except SubEvent.DoesNotExist:
subevent = self.form_data.get('subevent')
story.append(Paragraph(pgettext('subevent', 'Date: {}').format(subevent), self.get_style()))
story.append(Spacer(1, 5 * mm))
tdata = [
[
_('Product'), _('Canceled'), '', _('Refunded'), '', _('Expired'), '', _('Purchased'),
@@ -180,7 +188,7 @@ class OverviewReport(Report):
],
]
items_by_category, total = order_overview(self.event)
items_by_category, total = order_overview(self.event, subevent=self.form_data.get('subevent'))
for tup in items_by_category:
if tup[0]:
@@ -231,6 +239,18 @@ class OverviewReport(Report):
story.append(table)
return story
@property
def export_form_fields(self) -> dict:
d = OrderedDict()
if self.event.has_subevents:
d['subevent'] = forms.ModelChoiceField(
self.event.subevents.all(),
label=pgettext_lazy('subevent', 'Date'),
required=False,
empty_label=pgettext_lazy('subevent', 'All dates')
)
return d
class OrderTaxListReport(Report):
name = "ordertaxlist"

View File

@@ -1,15 +1,22 @@
from django import forms
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from pretix.base.forms import PlaceholderValidator
from pretix.base.models import Order
from pretix.base.models.event import SubEvent
class MailForm(forms.Form):
sendto = forms.MultipleChoiceField() # overridden later
subject = forms.CharField(label=_("Subject"))
message = forms.CharField(label=_("Message"))
subevent = forms.ModelChoiceField(
SubEvent.objects.none(),
label=_('Only send to customers of'),
required=False,
empty_label=pgettext_lazy('subevent', 'All dates')
)
def __init__(self, *args, **kwargs):
event = kwargs.pop('event')
@@ -35,3 +42,7 @@ class MailForm(forms.Form):
label=_("Send to"), widget=forms.CheckboxSelectMultiple,
choices=choices
)
if event.has_subevents:
self.fields['subevent'].queryset = event.subevents.all()
else:
del self.fields['subevent']

View File

@@ -9,17 +9,20 @@
{% for log in logs %}
<li class="list-group-item logentry">
<p class="meta">
<span class="fa fa-clock-o"></span> {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}
<span class="fa fa-clock-o fa-fw"></span> {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% if log.user %}
<br/><span class="fa fa-user"></span> {{ log.user.get_full_name }}
<br/><span class="fa fa-user fa-fw"></span> {{ log.user.get_full_name }}
{% endif %}
{% if log.display %}
<br/><span class="fa fa-comment-o"></span> {{ log.display }}
<br/><span class="fa fa-comment-o fa-fw"></span> {{ log.display }}
{% endif %}
<br/><span class="fa fa-shopping-cart"></span> {% trans "Sent to orders:" %}
<br/><span class="fa fa-shopping-cart fa-fw"></span> {% trans "Sent to orders:" %}
{% for status in log.parsed_data.sendto %}
{{ status }}{% if forloop.revcounter > 1 %},{% endif %}
{% endfor %}
{% if log.pdata.subevent_obj %}
<br/><span class="fa fa-calendar fa-fw"></span> {{ log.pdata.subevent_obj }}
{% endif %}
</p>
<p>
{% for locale, value in log.pdata.locales.items %}

View File

@@ -8,6 +8,9 @@
<form class="form-horizontal" method="post" action="">
{% csrf_token %}
{% bootstrap_field form.sendto layout='horizontal' %}
{% if form.subevent %}
{% bootstrap_field form.subevent layout='horizontal' %}
{% endif %}
{% bootstrap_field form.subject layout='horizontal' %}
{% bootstrap_field form.message layout='horizontal' %}
{% if request.method == "POST" %}

View File

@@ -13,6 +13,7 @@ from django.views.generic import FormView, ListView
from pretix.base.i18n import LazyI18nString, language
from pretix.base.models import InvoiceAddress, LogEntry, Order
from pretix.base.models.event import SubEvent
from pretix.base.services.mail import SendMailException, mail
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -43,6 +44,13 @@ class SenderView(EventPermissionRequiredMixin, FormView):
'subject': LazyI18nString(logentry.parsed_data['subject']),
'sendto': logentry.parsed_data['sendto'],
}
if logentry.parsed_data.get('subevent'):
try:
kwargs['initial']['subevent'] = self.request.event.subevents.get(
pk=logentry.parsed_data['subevent']['id']
)
except SubEvent.DoesNotExist:
pass
except LogEntry.DoesNotExist:
raise Http404(_('You supplied an invalid log entry ID'))
return kwargs
@@ -57,6 +65,8 @@ class SenderView(EventPermissionRequiredMixin, FormView):
if 'overdue' in form.cleaned_data['sendto']:
statusq |= Q(status=Order.STATUS_PENDING, expires__lt=now())
orders = qs.filter(statusq)
if form.cleaned_data.get('subevent'):
orders = orders.filter(positions__subevent__in=(form.cleaned_data.get('subevent'),)).distinct()
tz = pytz.timezone(self.request.event.settings.timezone)
@@ -119,7 +129,7 @@ class SenderView(EventPermissionRequiredMixin, FormView):
data={
'subject': form.cleaned_data['subject'],
'message': form.cleaned_data['message'],
'recipient': o.email
'recipient': o.email,
}
)
except SendMailException:
@@ -175,5 +185,10 @@ class EmailHistoryView(EventPermissionRequiredMixin, ListView):
log.pdata['sendto'] = [
status[s] for s in log.pdata['sendto']
]
if log.pdata.get('subevent'):
try:
log.pdata['subevent_obj'] = self.request.event.subevents.get(pk=log.pdata['subevent']['id'])
except SubEvent.DoesNotExist:
pass
return ctx

View File

@@ -91,7 +91,7 @@ class Stripe(BasePaymentProvider):
return template.render(ctx)
def order_can_retry(self, order):
return self._is_still_available()
return self._is_still_available(order=order)
def _charge_source(self, source, order):
try: