Refs #314 -- Read-only REST API (#513)

* initial commit

* API auth

* Hierarchical URLs

* Add session auth

* Strong hierarchy

* Add filters

* Add i18n fields, questions

* More viewsets and serializers

* Ticket download

* Add OrderPosition serializer

* View-level permissions

* More tests

* More tests

* Add basic API docs

* Add REST API to docs frontpage

* Tests for order endpoints

* Add invoice tests

* Voucher and waitinglist tests

* Doc draft

* order docs

* Docs on all viewsets

* Disable DRF docs, style sphinx, style browsable API

* Fix tests

* deprecated imports

* Test foo

* Attendee names

* Fix migration problems

* Remove browsable API, plugin integration

* Doc fixes
This commit is contained in:
Raphael Michel
2017-06-19 11:16:04 +02:00
committed by GitHub
parent 6df3a7d4b5
commit b2d4bea1d0
71 changed files with 4213 additions and 59 deletions

View File

View File

View File

@@ -0,0 +1,43 @@
from rest_framework.permissions import SAFE_METHODS, BasePermission
from pretix.base.models import Event
from pretix.base.models.organizer import Organizer, TeamAPIToken
class EventPermission(BasePermission):
model = TeamAPIToken
def has_permission(self, request, view):
if not request.user.is_authenticated and not isinstance(request.auth, TeamAPIToken):
if request.method in SAFE_METHODS and request.path.startswith('/api/v1/docs/'):
return True
return False
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken)
else request.user)
if 'event' in request.resolver_match.kwargs and 'organizer' in request.resolver_match.kwargs:
request.event = Event.objects.filter(
slug=request.resolver_match.kwargs['event'],
organizer__slug=request.resolver_match.kwargs['organizer'],
).select_related('organizer').first()
if not request.event or not perm_holder.has_event_permission(request.event.organizer, request.event):
return False
request.organizer = request.event.organizer
request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
if hasattr(view, 'permission'):
if view.permission and view.permission not in request.eventpermset:
return False
elif 'organizer' in request.resolver_match.kwargs:
request.organizer = Organizer.objects.filter(
slug=request.resolver_match.kwargs['organizer'],
).first()
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer):
return False
request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
if hasattr(view, 'permission'):
if view.permission and view.permission not in request.orgapermset:
return False
return True

View File

@@ -0,0 +1,21 @@
from django.contrib.auth.models import AnonymousUser
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
from pretix.base.models.organizer import TeamAPIToken
class TeamTokenAuthentication(TokenAuthentication):
model = TeamAPIToken
def authenticate_credentials(self, key):
model = self.get_model()
try:
token = model.objects.select_related('team', 'team__organizer').get(token=key)
except model.DoesNotExist:
raise exceptions.AuthenticationFailed('Invalid token.')
if not token.active:
raise exceptions.AuthenticationFailed('Token inactive or deleted.')
return AnonymousUser(), token

View File

View File

@@ -0,0 +1,10 @@
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Event
class EventSerializer(I18nAwareModelSerializer):
class Meta:
model = Event
fields = ('name', 'slug', 'live', 'currency', 'date_from',
'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location')

View File

@@ -0,0 +1,31 @@
from django.conf import settings
from i18nfield.fields import I18nCharField, I18nTextField
from rest_framework.fields import Field
from rest_framework.serializers import ModelSerializer
class I18nField(Field):
def __init__(self, **kwargs):
self.allow_blank = kwargs.pop('allow_blank', False)
self.trim_whitespace = kwargs.pop('trim_whitespace', True)
self.max_length = kwargs.pop('max_length', None)
self.min_length = kwargs.pop('min_length', None)
super().__init__(**kwargs)
def to_representation(self, value):
if value is None or value.data is None:
return None
if isinstance(value.data, dict):
return value.data
else:
return {
settings.LANGUAGE_CODE: str(value.data)
}
class I18nAwareModelSerializer(ModelSerializer):
pass
I18nAwareModelSerializer.serializer_field_mapping[I18nCharField] = I18nField
I18nAwareModelSerializer.serializer_field_mapping[I18nTextField] = I18nField

View File

@@ -0,0 +1,64 @@
from rest_framework import serializers
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption,
Quota,
)
class InlineItemVariationSerializer(I18nAwareModelSerializer):
class Meta:
model = ItemVariation
fields = ('id', 'value', 'active', 'description',
'position', 'default_price', 'price')
class InlineItemAddOnSerializer(serializers.ModelSerializer):
class Meta:
model = ItemAddOn
fields = ('addon_category', 'min_count', 'max_count',
'position')
class ItemSerializer(I18nAwareModelSerializer):
addons = InlineItemAddOnSerializer(many=True)
variations = InlineItemVariationSerializer(many=True)
class Meta:
model = Item
fields = ('id', 'category', 'name', 'active', 'description',
'default_price', 'free_price', 'tax_rate', 'admission',
'position', 'picture', 'available_from', 'available_until',
'require_voucher', 'hide_without_voucher', 'allow_cancel',
'min_per_order', 'max_per_order', 'has_variations',
'variations', 'addons')
class ItemCategorySerializer(I18nAwareModelSerializer):
class Meta:
model = ItemCategory
fields = ('id', 'name', 'description', 'position', 'is_addon')
class InlineQuestionOptionSerializer(I18nAwareModelSerializer):
class Meta:
model = QuestionOption
fields = ('id', 'answer')
class QuestionSerializer(I18nAwareModelSerializer):
options = InlineQuestionOptionSerializer(many=True)
class Meta:
model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position')
class QuotaSerializer(I18nAwareModelSerializer):
class Meta:
model = Quota
fields = ('id', 'name', 'size', 'items', 'variations')

View File

@@ -0,0 +1,110 @@
from rest_framework import serializers
from rest_framework.reverse import reverse
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
)
from pretix.base.signals import register_ticket_outputs
class InvoiceAdddressSerializer(I18nAwareModelSerializer):
class Meta:
model = InvoiceAddress
fields = ('last_modified', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id')
class CheckinSerializer(I18nAwareModelSerializer):
class Meta:
model = Checkin
fields = ('datetime',)
class OrderDownloadsField(serializers.Field):
def to_representation(self, instance: Order):
if instance.status != Order.STATUS_PAID:
return []
request = self.context['request']
res = []
responses = register_ticket_outputs.send(instance.event)
for receiver, response in responses:
provider = response(instance.event)
if provider.is_enabled:
res.append({
'output': provider.identifier,
'url': reverse('api-v1:order-download', kwargs={
'organizer': instance.event.organizer.slug,
'event': instance.event.slug,
'code': instance.code,
'output': provider.identifier,
}, request=request)
})
return res
class PositionDownloadsField(serializers.Field):
def to_representation(self, instance: OrderPosition):
if instance.order.status != Order.STATUS_PAID:
return []
if instance.addon_to_id and not instance.order.event.settings.ticket_download_addons:
return []
if not instance.item.admission and not instance.order.event.settings.ticket_download_nonadm:
return []
request = self.context['request']
res = []
responses = register_ticket_outputs.send(instance.order.event)
for receiver, response in responses:
provider = response(instance.order.event)
if provider.is_enabled:
res.append({
'output': provider.identifier,
'url': reverse('api-v1:orderposition-download', kwargs={
'organizer': instance.order.event.organizer.slug,
'event': instance.order.event.slug,
'pk': instance.pk,
'output': provider.identifier,
}, request=request)
})
return res
class OrderPositionSerializer(I18nAwareModelSerializer):
checkins = CheckinSerializer(many=True)
downloads = PositionDownloadsField(source='*')
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
class Meta:
model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'checkins', 'downloads')
class OrderSerializer(I18nAwareModelSerializer):
invoice_address = InvoiceAdddressSerializer()
positions = OrderPositionSerializer(many=True)
downloads = OrderDownloadsField(source='*')
class Meta:
model = Order
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'payment_fee', 'payment_fee_tax_rate', 'payment_fee_tax_value',
'total', 'comment', 'invoice_address', 'positions', 'downloads')
class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
class Meta:
model = InvoiceLine
fields = ('description', 'gross_value', 'tax_value', 'tax_rate')
class InvoiceSerializer(I18nAwareModelSerializer):
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
refers = serializers.SlugRelatedField(slug_field='invoice_no', read_only=True)
lines = InlineInvoiceLineSerializer(many=True)
class Meta:
model = Invoice
fields = ('order', 'invoice_no', 'is_cancellation', 'invoice_from', 'invoice_to', 'date', 'refers', 'locale',
'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines')

View File

@@ -0,0 +1,8 @@
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Organizer
class OrganizerSerializer(I18nAwareModelSerializer):
class Meta:
model = Organizer
fields = ('name', 'slug')

View File

@@ -0,0 +1,10 @@
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Voucher
class VoucherSerializer(I18nAwareModelSerializer):
class Meta:
model = Voucher
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
'tag', 'comment')

View File

@@ -0,0 +1,9 @@
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import WaitingListEntry
class WaitingListSerializer(I18nAwareModelSerializer):
class Meta:
model = WaitingListEntry
fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale')

View File

View File

@@ -0,0 +1,19 @@
{% extends "rest_framework/base.html" %}
{% load staticfiles %}
{% load compress %}
{% block bootstrap_theme %}
{% compress css %}
<link rel="stylesheet" type="text/x-scss" href="{% static "rest_framework/scss/main.scss" %}" />
{% endcompress %}
{% endblock %}
{% block branding %}
<a class="navbar-brand" href="/api/v1/">pretix REST API</a>
{% endblock %}
{% block description %}
<div class="alert alert-info alert-docs-link">
<a href="https://docs.pretix.eu/en/latest/api/index.html">
You can find documentation on our REST API on docs.pretix.eu.
</a>
</div>
{% endblock %}

36
src/pretix/api/urls.py Normal file
View File

@@ -0,0 +1,36 @@
import importlib
from django.apps import apps
from django.conf.urls import include, url
from rest_framework import routers
from .views import event, item, order, organizer, voucher, waitinglist
router = routers.DefaultRouter()
router.register(r'organizers', organizer.OrganizerViewSet)
orga_router = routers.DefaultRouter()
orga_router.register(r'events', event.EventViewSet)
event_router = routers.DefaultRouter()
event_router.register(r'items', item.ItemViewSet)
event_router.register(r'categories', item.ItemCategoryViewSet)
event_router.register(r'questions', item.QuestionViewSet)
event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet)
event_router.register(r'orders', order.OrderViewSet)
event_router.register(r'orderpositions', order.OrderPositionViewSet)
event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
# Force import of all plugins to give them a chance to register URLs with the router
for app in apps.get_app_configs():
if hasattr(app, 'PretixPluginMeta'):
if importlib.util.find_spec(app.name + '.urls'):
importlib.import_module(app.name + '.urls')
urlpatterns = [
url(r'^', include(router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/', include(orga_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/', include(event_router.urls)),
]

View File

View File

@@ -0,0 +1,14 @@
from rest_framework import viewsets
from pretix.api.serializers.event import EventSerializer
from pretix.base.models import Event
class EventViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = EventSerializer
queryset = Event.objects.none()
lookup_field = 'slug'
lookup_url_kwarg = 'event'
def get_queryset(self):
return self.request.organizer.events.all()

View File

@@ -0,0 +1,67 @@
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from rest_framework.filters import OrderingFilter
from pretix.api.serializers.item import (
ItemCategorySerializer, ItemSerializer, QuestionSerializer,
QuotaSerializer,
)
from pretix.base.models import Item, ItemCategory, Question, Quota
class ItemFilter(FilterSet):
class Meta:
model = Item
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
class ItemViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = ItemSerializer
queryset = Item.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
filter_class = ItemFilter
def get_queryset(self):
return self.request.event.items.prefetch_related('variations', 'addons').all()
class ItemCategoryFilter(FilterSet):
class Meta:
model = ItemCategory
fields = ['is_addon']
class ItemCategoryViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = ItemCategorySerializer
queryset = ItemCategory.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
filter_class = ItemCategoryFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
def get_queryset(self):
return self.request.event.categories.all()
class QuestionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = QuestionSerializer
queryset = Question.objects.none()
filter_backends = (OrderingFilter,)
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
def get_queryset(self):
return self.request.event.questions.prefetch_related('options').all()
class QuotaViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = QuotaSerializer
queryset = Quota.objects.none()
filter_backends = (OrderingFilter,)
ordering_fields = ('id', 'size')
ordering = ('id',)
def get_queryset(self):
return self.request.event.quotas.all()

View File

@@ -0,0 +1,181 @@
import django_filters
from django.db.models import Q
from django.http import FileResponse
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from rest_framework.decorators import detail_route
from rest_framework.exceptions import APIException, NotFound, PermissionDenied
from rest_framework.filters import OrderingFilter
from pretix.api.serializers.order import (
InvoiceSerializer, OrderPositionSerializer, OrderSerializer,
)
from pretix.base.models import Invoice, Order, OrderPosition
from pretix.base.services.invoices import invoice_pdf
from pretix.base.services.tickets import (
get_cachedticket_for_order, get_cachedticket_for_position,
)
from pretix.base.signals import register_ticket_outputs
class OrderFilter(FilterSet):
class Meta:
model = Order
fields = ['code', 'status', 'email', 'locale']
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer
queryset = Order.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('datetime',)
ordering_fields = ('datetime', 'code', 'status')
filter_class = OrderFilter
lookup_field = 'code'
permission = 'can_view_orders'
def get_queryset(self):
return self.request.event.orders.prefetch_related(
'positions', 'positions__checkins', 'positions__item',
).select_related(
'invoice_address'
)
def _get_output_provider(self, identifier):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
prov = response(self.request.event)
if prov.identifier == identifier:
return prov
raise NotFound('Unknown output provider.')
@detail_route(url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
provider = self._get_output_provider(output)
order = self.get_object()
if order.status != Order.STATUS_PAID:
raise PermissionDenied("Downloads are not available for unpaid orders.")
ct = get_cachedticket_for_order(order, provider.identifier)
if not ct.file:
raise RetryException()
else:
resp = FileResponse(ct.file.file, content_type=ct.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
self.request.event.slug.upper(), order.code,
provider.identifier, ct.extension
)
return resp
class OrderPositionFilter(FilterSet):
order = django_filters.CharFilter(name='order', lookup_expr='code')
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(checkins__isnull=not value)
def attendee_name_qs(self, queryset, name, value):
return queryset.filter(Q(attendee_name=value) | Q(addon_to__attendee_name=value))
class Meta:
model = OrderPosition
fields = ['item', 'variation', 'attendee_name', 'secret', 'order', 'order__status', 'has_checkin',
'addon_to']
class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('order__datetime', 'positionid')
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
filter_class = OrderPositionFilter
permission = 'can_view_orders'
def get_queryset(self):
return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related(
'checkins',
).select_related(
'item', 'order', 'order__event', 'order__event__organizer'
)
def _get_output_provider(self, identifier):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
prov = response(self.request.event)
if prov.identifier == identifier:
return prov
raise NotFound('Unknown output provider.')
@detail_route(url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
provider = self._get_output_provider(output)
pos = self.get_object()
if pos.order.status != Order.STATUS_PAID:
raise PermissionDenied("Downloads are not available for unpaid orders.")
if pos.addon_to_id and not request.event.settings.ticket_download_addons:
raise PermissionDenied("Downloads are not enabled for add-on products.")
if not pos.item.admission and not request.event.settings.ticket_download_nonadm:
raise PermissionDenied("Downloads are not enabled for non-admission products.")
ct = get_cachedticket_for_position(pos, provider.identifier)
if not ct.file:
raise RetryException()
else:
resp = FileResponse(ct.file.file, content_type=ct.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), pos.order.code, pos.positionid,
provider.identifier, ct.extension
)
return resp
class InvoiceFilter(FilterSet):
refers = django_filters.CharFilter(name='refers', lookup_expr='invoice_no__iexact')
order = django_filters.CharFilter(name='order', lookup_expr='code__iexact')
class Meta:
model = Invoice
fields = ['order', 'invoice_no', 'is_cancellation', 'refers', 'locale']
class RetryException(APIException):
status_code = 409
default_detail = 'The requested resource is not ready, please retry later.'
default_code = 'retry_later'
class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = InvoiceSerializer
queryset = Invoice.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('invoice_no',)
ordering_fields = ('invoice_no', 'date')
filter_class = InvoiceFilter
lookup_field = 'invoice_no'
lookup_url_kwarg = 'invoice_no'
permission = 'can_view_orders'
def get_queryset(self):
return self.request.event.invoices.prefetch_related('lines').select_related('order')
@detail_route()
def download(self, request, **kwargs):
invoice = self.get_object()
if not invoice.file:
invoice_pdf(invoice.pk)
invoice.refresh_from_db()
if not invoice.file:
raise RetryException()
resp = FileResponse(invoice.file.file, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
return resp

View File

@@ -0,0 +1,20 @@
from rest_framework import viewsets
from pretix.api.serializers.organizer import OrganizerSerializer
from pretix.base.models import Organizer
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrganizerSerializer
queryset = Organizer.objects.none()
lookup_field = 'slug'
lookup_url_kwarg = 'organizer'
def get_queryset(self):
if self.request.user.is_authenticated():
if self.request.user.is_superuser:
return Organizer.objects.all()
else:
return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True))
else:
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)

View File

@@ -0,0 +1,40 @@
from django.db.models import F, Q
from django.utils.timezone import now
from django_filters.rest_framework import (
BooleanFilter, DjangoFilterBackend, FilterSet,
)
from rest_framework import viewsets
from rest_framework.filters import OrderingFilter
from pretix.api.serializers.voucher import VoucherSerializer
from pretix.base.models import Voucher
class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active')
class Meta:
model = Voucher
fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota',
'price_mode', 'value', 'item', 'variation', 'quota', 'tag']
def filter_active(self, queryset, name, value):
if value:
return queryset.filter(Q(redeemed__lt=F('max_usages')) &
(Q(valid_until__isnull=True) | Q(valid_until__gt=now())))
else:
return queryset.filter(Q(redeemed__gte=F('max_usages')) |
(Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
class VoucherViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = VoucherSerializer
queryset = Voucher.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('id',)
ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value')
filter_class = VoucherFilter
permission = 'can_view_vouchers'
def get_queryset(self):
return self.request.event.vouchers.all()

View File

@@ -0,0 +1,31 @@
import django_filters
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
from rest_framework.filters import OrderingFilter
from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.models import WaitingListEntry
class WaitingListFilter(FilterSet):
has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs')
def has_voucher_qs(self, queryset, name, value):
return queryset.filter(voucher__isnull=not value)
class Meta:
model = WaitingListEntry
fields = ['item', 'variation', 'email', 'locale', 'has_voucher']
class WaitingListViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = WaitingListSerializer
queryset = WaitingListEntry.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('created',)
ordering_fields = ('id', 'created', 'email', 'item')
filter_class = WaitingListFilter
permission = 'can_view_orders'
def get_queryset(self):
return self.request.event.waitinglistentries.all()

View File

@@ -161,6 +161,9 @@ def _merge_csp(a, b):
class SecurityMiddleware(MiddlewareMixin):
CSP_EXEMPT = (
'/api/v1/docs/',
)
def process_response(self, request, resp):
if settings.DEBUG and resp.status_code >= 400:
@@ -199,6 +202,7 @@ class SecurityMiddleware(MiddlewareMixin):
else:
staticdomain += " " + settings.SITE_URL
dynamicdomain += " " + settings.SITE_URL
if hasattr(request, 'organizer') and request.organizer:
domain = get_domain(request.organizer)
if domain:
@@ -207,5 +211,6 @@ class SecurityMiddleware(MiddlewareMixin):
domain = '%s:%d' % (domain, siteurlsplit.port)
dynamicdomain += " " + domain
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain)
if request.path not in self.CSP_EXEMPT:
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain)
return resp

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-06-02 09:48
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.organizer
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0061_auto_20170521_0942'),
]
operations = [
migrations.CreateModel(
name='TeamAPIToken',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=190)),
('active', models.BooleanField(default=True)),
('token', models.CharField(default=pretix.base.models.organizer.generate_api_token, max_length=64)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to='pretixbase.Team')),
],
),
]

View File

@@ -1,8 +1,8 @@
import string
from datetime import date
from decimal import Decimal
from django.db import DatabaseError, models, transaction
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
@@ -15,6 +15,10 @@ def invoice_filename(instance, filename: str) -> str:
)
def today():
return timezone.now().date()
class Invoice(models.Model):
"""
Represents an invoice that is issued because of an order. Because invoices are legally required
@@ -56,7 +60,7 @@ class Invoice(models.Model):
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True)
invoice_from = models.TextField()
invoice_to = models.TextField()
date = models.DateField(default=date.today)
date = models.DateField(default=today)
locale = models.CharField(max_length=50, default='en')
introductory_text = models.TextField(blank=True)
additional_text = models.TextField(blank=True)

View File

@@ -72,6 +72,10 @@ def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
def generate_api_token():
return get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
class Team(LoggedModel):
"""
A team is a collection of people given certain access rights to one or more events of an organizer.
@@ -175,6 +179,10 @@ class Team(LoggedModel):
else:
return self.limit_events.filter(pk=event.pk).exists()
@property
def active_tokens(self):
return self.tokens.filter(active=True)
class Meta:
verbose_name = _("Team")
verbose_name_plural = _("Teams")
@@ -200,3 +208,81 @@ class TeamInvite(models.Model):
return _("Invite to team '{team}' for '{email}'").format(
team=str(self.team), email=self.email
)
class TeamAPIToken(models.Model):
"""
A TeamAPIToken represents an API token that has the same access level as the team it belongs to.
:param team: The team the person is invited to
:type team: Team
:param name: A human-readable name for the token
:type name: str
:param active: Whether or not this token is active
:type active: bool
:param token: The secret required to submit to the API
:type token: str
"""
team = models.ForeignKey(Team, related_name="tokens", on_delete=models.CASCADE)
name = models.CharField(max_length=190)
active = models.BooleanField(default=True)
token = models.CharField(default=generate_api_token, max_length=64)
def get_event_permission_set(self, organizer, event) -> set:
"""
Gets a set of permissions (as strings) that a token holds for a particular event
:param organizer: The organizer of the event
:param event: The event to check
:return: set of permissions
"""
has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
event in self.team.limit_events.all()
)
return self.team.permission_set() if has_event_access else set()
def get_organizer_permission_set(self, organizer) -> set:
"""
Gets a set of permissions (as strings) that a token holds for a particular organizer
:param organizer: The organizer of the event
:return: set of permissions
"""
return self.team.permission_set() if self.team.organizer == organizer else set()
def has_event_permission(self, organizer, event, perm_name=None) -> bool:
"""
Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``.
:param organizer: The organizer of the event
:param event: The event to check
:param perm_name: The permission, e.g. ``can_change_teams``
:return: bool
"""
has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
event in self.team.limit_events.all()
)
return has_event_access and (not perm_name or self.team.has_permission(perm_name))
def has_organizer_permission(self, organizer, perm_name=None):
"""
Checks if this token is part of a team that grants access of type ``perm_name``
to the organizer ``organizer``.
:param organizer: The organizer to check
:param perm_name: The permission, e.g. ``can_change_teams``
:return: bool
"""
return organizer == self.team.organizer and (not perm_name or self.team.has_permission(perm_name))
def get_events_with_any_permission(self):
"""
Returns a queryset of events the token has any permissions to.
:return: Iterable of Events
"""
if self.team.all_events:
return self.team.organizer.events.all()
else:
return self.team.limit_events.all()

View File

@@ -1,14 +1,13 @@
import copy
import tempfile
from collections import defaultdict
from datetime import date
from decimal import Decimal
from django.contrib.staticfiles import finders
from django.core.files.base import ContentFile
from django.db import transaction
from django.utils import timezone
from django.utils.formats import date_format, localize
from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext as _
from i18nfield.strings import LazyI18nString
from reportlab.lib import pagesizes
@@ -108,7 +107,7 @@ def generate_cancellation(invoice: Invoice):
cancellation.invoice_no = None
cancellation.refers = invoice
cancellation.is_cancellation = True
cancellation.date = date.today()
cancellation.date = timezone.now().date()
cancellation.payment_provider_text = ''
cancellation.save()
@@ -135,7 +134,7 @@ def generate_invoice(order: Order):
invoice = Invoice(
order=order,
event=order.event,
date=date.today(),
date=timezone.now().date(),
locale=locale
)
invoice = build_invoice(invoice)
@@ -430,11 +429,11 @@ def build_preview_invoice_pdf(event):
locale = event.settings.locale
with rolledback_transaction(), language(locale):
order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
expires=now(), code="PREVIEW", total=119)
order = event.orders.create(status=Order.STATUS_PENDING, datetime=timezone.now(),
expires=timezone.now(), code="PREVIEW", total=119)
invoice = Invoice(
order=order, event=event, invoice_no="PREVIEW",
date=date.today(), locale=locale
date=timezone.now().date(), locale=locale
)
invoice.invoice_from = event.settings.get('invoice_address_from')

View File

@@ -1,4 +1,5 @@
import os
from datetime import timedelta
from django.core.files.base import ContentFile
from django.utils.timezone import now
@@ -85,3 +86,43 @@ def preview(event: int, provider: str):
prov = response(event)
if prov.identifier == provider:
return prov.generate(p)
def get_cachedticket_for_position(pos, identifier):
try:
ct = CachedTicket.objects.filter(
order_position=pos, provider=identifier
).last()
except CachedTicket.DoesNotExist:
ct = None
if not ct:
ct = CachedTicket.objects.create(
order_position=pos, provider=identifier,
extension='', type='', file=None)
generate.apply_async(args=(pos.id, identifier))
if not ct.file:
if now() - ct.created > timedelta(minutes=5):
generate.apply_async(args=(pos.id, identifier))
return ct
def get_cachedticket_for_order(order, identifier):
try:
ct = CachedCombinedTicket.objects.filter(
order=order, provider=identifier
).last()
except CachedCombinedTicket.DoesNotExist:
ct = None
if not ct:
ct = CachedCombinedTicket.objects.create(
order=order, provider=identifier,
extension='', type='', file=None)
generate_order.apply_async(args=(order.id, identifier))
if not ct.file:
if now() - ct.created > timedelta(minutes=5):
generate_order.apply_async(args=(order.id, identifier))
return ct

View File

@@ -169,6 +169,12 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
if logentry.action_type == 'pretix.team.invite.deleted':
return _('The invite for {user} has been revoked.').format(user=data.get('email'))
if logentry.action_type == 'pretix.team.token.created':
return _('The token "{name}" has been created.').format(name=data.get('name'))
if logentry.action_type == 'pretix.team.token.deleted':
return _('The token "{name}" has been revoked.').format(name=data.get('name'))
if logentry.action_type == 'pretix.user.settings.changed':
text = str(_('Your account settings have been changed.'))
if 'email' in data:

View File

@@ -232,7 +232,7 @@
{% if messages %}
{% for message in messages %}
<div class="alert {{ message.tags }}">
{{ message }}
{{ message|linebreaksbr }}
</div>
{% endfor %}
{% endif %}

View File

@@ -10,6 +10,7 @@
{% trans "Edit" %}
</a>
</h2>
<h3>{% trans "Team members" %}</h3>
<form action="" method="post">
{% csrf_token %}
<!-- Trick browsers into taking this as a default -->
@@ -18,7 +19,7 @@
<thead>
<tr>
<th>{% trans "Member" %}</th>
<th></th>
<th width="150"></th>
</tr>
</thead>
<tbody>
@@ -70,6 +71,47 @@
</tfoot>
</table>
</form>
<h3>{% trans "API tokens" %}</h3>
<form action="" method="post">
{% csrf_token %}
<!-- Trick browsers into taking this as a default -->
<button type="submit" class="btn btn-primary btn-sm btn-block nearly-gone"></button>
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th width="150"></th>
</tr>
</thead>
<tbody>
{% for t in team.active_tokens %}
<tr>
<td>
{{ t.name }}
</td>
<td class="text-right">
<button type="submit" name="remove-token" value="{{ t.id }}"
class="btn btn-danger btn-sm btn-block">
<i class="fa fa-times"></i> {% trans "Remove" %}
</button>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td>
{% bootstrap_field add_token_form.name layout='inline' %}<br>
</td>
<td class="text-right">
<button type="submit" class="btn btn-primary btn-sm btn-block">
<i class="fa fa-plus"></i> {% trans "Add" %}
</button>
</td>
</tr>
</tfoot>
</table>
</form>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">

View File

@@ -13,6 +13,7 @@ from django.views.generic import (
)
from pretix.base.models import Organizer, Team, TeamInvite, User
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.services.mail import SendMailException, mail
from pretix.control.forms.organizer import (
OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm,
@@ -39,6 +40,10 @@ class InviteForm(forms.Form):
user = forms.EmailField(required=False, label=_('User'))
class TokenForm(forms.Form):
name = forms.CharField(required=False, label=_('Token name'))
class OrganizerDetailViewMixin:
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
@@ -309,11 +314,18 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
@cached_property
def add_form(self):
return InviteForm(data=self.request.POST if self.request.method == "POST" else None)
return InviteForm(data=(self.request.POST
if self.request.method == "POST" and "user" in self.request.POST else None))
@cached_property
def add_token_form(self):
return TokenForm(data=(self.request.POST
if self.request.method == "POST" and "name" in self.request.POST else None))
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['add_form'] = self.add_form
ctx['add_token_form'] = self.add_token_form
return ctx
def _send_invite(self, instance):
@@ -380,7 +392,24 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
messages.success(self.request, _('The invite has been revoked.'))
return redirect(self.get_success_url())
elif self.add_form.is_valid() and self.add_form.has_changed():
elif 'remove-token' in request.POST:
try:
token = self.object.tokens.get(pk=request.POST.get('remove-token'))
except TeamAPIToken.DoesNotExist:
messages.error(self.request, _('Invalid token selected.'))
return redirect(self.get_success_url())
else:
token.active = False
token.save()
self.object.log_action(
'pretix.team.token.deleted', user=self.request.user, data={
'name': token.name
}
)
messages.success(self.request, _('The token has been revoked.'))
return redirect(self.get_success_url())
elif "user" in self.request.POST and self.add_form.is_valid() and self.add_form.has_changed():
try:
user = User.objects.get(email=self.add_form.cleaned_data['user'])
@@ -414,6 +443,18 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
messages.success(self.request, _('The new member has been added to the team.'))
return redirect(self.get_success_url())
elif "name" in self.request.POST and self.add_token_form.is_valid() and self.add_token_form.has_changed():
token = self.object.tokens.create(name=self.add_token_form.cleaned_data['name'])
self.object.log_action(
'pretix.team.token.created', user=self.request.user, data={
'name': self.add_token_form.cleaned_data['name'],
'id': token.pk
}
)
messages.success(self.request, _('A new API token has been created with the following secret: {}\n'
'Please copy this secret to a safe place. You will not be able to '
'view it again here.').format(token.token))
return redirect(self.get_success_url())
else:
messages.error(self.request, _('Your changes could not be saved.'))
return self.get(request, *args, **kwargs)

View File

@@ -1,5 +1,3 @@
from datetime import timedelta
from django.contrib import messages
from django.db import transaction
from django.db.models import Sum
@@ -11,13 +9,15 @@ from django.utils.translation import ugettext_lazy as _
from django.views.generic import TemplateView, View
from pretix.base.models import CachedTicket, Invoice, Order, OrderPosition
from pretix.base.models.orders import CachedCombinedTicket, InvoiceAddress
from pretix.base.models.orders import InvoiceAddress
from pretix.base.payment import PaymentException
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
)
from pretix.base.services.orders import cancel_order
from pretix.base.services.tickets import generate, generate_order
from pretix.base.services.tickets import (
get_cachedticket_for_order, get_cachedticket_for_position,
)
from pretix.base.signals import (
register_payment_providers, register_ticket_outputs,
)
@@ -554,22 +554,7 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View):
return self._download_order()
def _download_order(self):
try:
ct = CachedCombinedTicket.objects.filter(
order=self.order, provider=self.output.identifier
).last()
except CachedCombinedTicket.DoesNotExist:
ct = None
if not ct:
ct = CachedCombinedTicket.objects.create(
order=self.order, provider=self.output.identifier,
extension='', type='', file=None)
generate_order.apply_async(args=(self.order.id, self.output.identifier))
if not ct.file:
if now() - ct.created > timedelta(minutes=5):
generate_order.apply_async(args=(self.order.id, self.output.identifier))
ct = get_cachedticket_for_order(self.order, self.output.identifier)
if 'ajax' in self.request.GET:
return JsonResponse({
@@ -587,22 +572,7 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View):
return resp
def _download_position(self):
try:
ct = CachedTicket.objects.filter(
order_position=self.order_position, provider=self.output.identifier
).last()
except CachedTicket.DoesNotExist:
ct = None
if not ct:
ct = CachedTicket.objects.create(
order_position=self.order_position, provider=self.output.identifier,
extension='', type='', file=None)
generate.apply_async(args=(self.order_position.id, self.output.identifier))
if not ct.file:
if now() - ct.created > timedelta(minutes=5):
generate.apply_async(args=(self.order_position.id, self.output.identifier))
ct = get_cachedticket_for_position(self.order_position, self.output.identifier)
if 'ajax' in self.request.GET:
return JsonResponse({

View File

@@ -189,6 +189,9 @@ INSTALLED_APPS = [
'pretix.control',
'pretix.presale',
'pretix.multidomain',
'pretix.api',
'rest_framework',
'django_filters',
'compressor',
'bootstrap3',
'djangoformsetjs',
@@ -233,6 +236,23 @@ if config.has_option('sentry', 'dsn'):
}
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'pretix.api.auth.permission.EventPermission',
],
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
'PAGE_SIZE': 50,
'DEFAULT_AUTHENTICATION_CLASSES': (
'pretix.api.auth.token.TeamTokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
),
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
),
'UNICODE_JSON': False
}
CORE_MODULES = {
("pretix", "base"),
("pretix", "presale"),

View File

@@ -0,0 +1,2 @@
$font-family-sans-serif: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif !default;
$brand-primary: #8E44B3 !default;

View File

@@ -0,0 +1,10 @@
@import "_variables.scss";
@import "../../pretixbase/scss/colors.scss";
@import "../../bootstrap/scss/_bootstrap.scss";
@import "../../pretixbase/scss/webfont.scss";
.alert-docs-link {
text-align: center;
font-weight: bold;
font-size: 20px;
}

View File

@@ -1,5 +1,6 @@
from django.conf import settings
from django.conf.urls import include, url
from django.views.generic import RedirectView
import pretix.control.urls
import pretix.presale.urls
@@ -15,6 +16,8 @@ base_patterns = [
url(r'^jsi18n/(?P<lang>[a-zA-Z-_]+)/$', js_catalog.js_catalog, name='javascript-catalog'),
url(r'^metrics$', metrics.serve_metrics,
name='metrics'),
url(r'^api/v1/', include('pretix.api.urls', namespace='api-v1')),
url(r'^api/$', RedirectView.as_view(url='/api/v1/'), name='redirect-api-version')
]
control_patterns = [