Refs #654 -- API: Writable event endpoints (#756)

* MKBDIGI-185: Added update/create to events

* MKBDIGI-185: Added validation for 'slug, 'live' on event endpoint

* MKBDIGI-185: Code formatting

* MKBDIGI-185: Added 'plugins' to 'event' endpoint

* MKBDIGI-185: Merge migrations

* MKBDIGI-185: Cleaned up static methods

* EBILL-5: Added delete endpoint for event

* EBILL-5: Merge migrations

* EBILL-5: Fixed imports

* EBILL-5: Changed plugins to only list plugins enabled for the event

* EBILL-5: Added clone event endpoint

* EBILL-5: Removed permissions check API test for events

* EBILL-5: Merged master, updated migrations

* EBILL-5: Updated api permissions check for CRUD on events

* EBILL-5: Removed 'unique_together' constraint on event model

* EBILL-5: Removed call to changed static methods in test

* EBILL-5: Changed Event 'has_paid_things'  to a property for consistency

* EBILL-5: Fixed created response code in documentation

* EBILL-6: Documentation fixes

* EBILL-6: Fixed typo

* EBILL-6: Fixed permissions

* EBILL-6: Added note on copying settings to documentation

* EBILL-6: Created model method for deleting sub objects on event before delete

* EBILL-6: Fixed typo

* EBILL-6: Re-added meta_data as read-only

* EBILL-6: Fixed permissions test

* EBILL-6: Added plugins issues check before live. Moved issues property from form to Event model.

* EBILL-6: Upped version number in documentation

* Add write support for MetaDataField

* EBILL-6: Expanded documentation for the clone endpoint, made behaviour of 'is_public' similar to 'plugins' for consistency

* EBILL-6: Re-added EventCRUDPermission

* EBILL-16: Updated documentation with permission model for the API

* EBILL-16: Added 'has_subevents' validation to ensure it cannot be changed once event is created.

* EBILL-16: Fixed event clone not differentiating between "not set" and "deliberately set to False"

* EBILL-16: Fixed event live validation

* EBILL-16: Added logging of live activated/deactivated

* EBILL-16: Fixed create event bug when no 'meta_data' supplied

* EBILL-16: Typo fixed

* EBILL-16: Added log display for "event created"

* EBILL-16: Enabling a plugin now calls 'installed' if applicable and log entries are added

* EBILL-16: Updated tests for events

* Do not allow enabling restricted plugins via the API

* Remove unused code
This commit is contained in:
Ture Gjørup
2018-04-25 17:13:09 +02:00
committed by Raphael Michel
parent 1a0e2031d2
commit 7bb18f6fad
12 changed files with 1264 additions and 60 deletions

View File

@@ -56,3 +56,18 @@ class EventPermission(BasePermission):
if required_permission and required_permission not in request.orgapermset:
return False
return True
class EventCRUDPermission(EventPermission):
def has_permission(self, request, view):
if not super(EventCRUDPermission, self).has_permission(request, view):
return False
elif view.action == 'create' and 'can_create_events' not in request.orgapermset:
return False
elif view.action == 'destroy' and 'can_change_event_settings' not in request.eventpermset:
return False
elif view.action in ['retrieve', 'update', 'partial_update'] \
and 'can_change_event_settings' not in request.eventpermset:
return False
return True

View File

@@ -1,3 +1,7 @@
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.functional import cached_property
from django.utils.translation import ugettext as _
from django_countries.serializers import CountryFieldMixin
from rest_framework.fields import Field
@@ -14,15 +18,161 @@ class MetaDataField(Field):
v.property.name: v.value for v in value.meta_values.all()
}
def to_internal_value(self, data):
return {
'meta_data': data
}
class PluginsField(Field):
def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins
return {
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
}
def to_internal_value(self, data):
return {
'plugins': data
}
class EventSerializer(I18nAwareModelSerializer):
meta_data = MetaDataField(source='*')
meta_data = MetaDataField(required=False, source='*')
plugins = PluginsField(required=False, source='*')
class Meta:
model = Event
fields = ('name', 'slug', 'live', 'currency', 'date_from',
'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location', 'has_subevents', 'meta_data')
'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins')
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
Event.clean_dates(data.get('date_from'), data.get('date_to'))
Event.clean_presale(data.get('presale_start'), data.get('presale_end'))
return data
def validate_has_subevents(self, value):
Event.clean_has_subevents(self.instance, value)
return value
def validate_slug(self, value):
Event.clean_slug(self.context['request'].organizer, self.instance, value)
return value
def validate_live(self, value):
if value:
if self.instance is None:
raise ValidationError(_('Events cannot be created as \'live\'. Quotas and payment must be added to the '
'event before sales can go live.'))
else:
self.instance.clean_live()
return value
@cached_property
def meta_properties(self):
return {
p.name: p for p in self.context['request'].organizer.meta_properties.all()
}
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.meta_properties:
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
return value
def validate_plugins(self, value):
from pretix.base.plugins import get_all_plugins
plugins_available = {
p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
for plugin in value.get('plugins'):
if plugin not in plugins_available:
raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin))
return value
@transaction.atomic
def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None)
plugins = validated_data.pop('plugins', None)
event = super().create(validated_data)
# Meta data
if meta_data is not None:
for key, value in meta_data.items():
event.meta_values.create(
property=self.meta_properties.get(key),
value=value
)
# Plugins
if plugins is not None:
event.set_active_plugins(plugins)
return event
@transaction.atomic
def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None)
plugins = validated_data.pop('plugins', None)
event = super().update(instance, validated_data)
# Meta data
if meta_data is not None:
current = {mv.property: mv for mv in event.meta_values.select_related('property')}
for key, value in meta_data.items():
prop = self.meta_properties.get(key)
if prop in current:
current[prop].value = value
current[prop].save()
else:
event.meta_values.create(
property=self.meta_properties.get(key),
value=value
)
for prop, current_object in current.items():
if prop.name not in meta_data:
current_object.delete()
# Plugins
if plugins is not None:
event.set_active_plugins(plugins)
event.save()
return event
class CloneEventSerializer(EventSerializer):
@transaction.atomic
def create(self, validated_data):
plugins = validated_data.pop('plugins', None)
is_public = validated_data.pop('is_public', None)
new_event = super().create(validated_data)
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
new_event.copy_data_from(event)
if plugins is not None:
new_event.set_active_plugins(plugins)
if is_public is not None:
new_event.is_public = is_public
new_event.save()
return new_event
class SubEventItemSerializer(I18nAwareModelSerializer):

View File

@@ -14,6 +14,7 @@ orga_router.register(r'events', event.EventViewSet)
event_router = routers.DefaultRouter()
event_router.register(r'subevents', event.SubEventViewSet)
event_router.register(r'clone', event.CloneEventViewSet)
event_router.register(r'items', item.ItemViewSet)
event_router.register(r'categories', item.ItemCategoryViewSet)
event_router.register(r'questions', item.QuestionViewSet)

View File

@@ -1,24 +1,123 @@
from django.db import transaction
from django.db.models import ProtectedError
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import filters, viewsets
from rest_framework.exceptions import PermissionDenied
from pretix.api.auth.permission import EventCRUDPermission
from pretix.api.serializers.event import (
EventSerializer, SubEventSerializer, TaxRuleSerializer,
CloneEventSerializer, EventSerializer, SubEventSerializer,
TaxRuleSerializer,
)
from pretix.base.models import Event, ItemCategory, TaxRule
from pretix.base.models.event import SubEvent
from pretix.base.models.organizer import TeamAPIToken
from pretix.helpers.dicts import merge_dicts
class EventViewSet(viewsets.ReadOnlyModelViewSet):
class EventViewSet(viewsets.ModelViewSet):
serializer_class = EventSerializer
queryset = Event.objects.none()
lookup_field = 'slug'
lookup_url_kwarg = 'event'
permission_classes = (EventCRUDPermission,)
def get_queryset(self):
return self.request.organizer.events.prefetch_related('meta_values', 'meta_values__property')
def perform_update(self, serializer):
current_live_value = serializer.instance.live
updated_live_value = serializer.validated_data.get('live', None)
current_plugins_value = serializer.instance.get_plugins()
updated_plugins_value = serializer.validated_data.get('plugins', None)
super().perform_update(serializer)
if updated_live_value is not None and updated_live_value != current_live_value:
log_action = 'pretix.event.live.activated' if updated_live_value else 'pretix.event.live.deactivated'
serializer.instance.log_action(
log_action,
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
data=self.request.data
)
if updated_plugins_value is not None and set(updated_plugins_value) != set(current_plugins_value):
enabled = {m: 'enabled' for m in updated_plugins_value if m not in current_plugins_value}
disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value}
changed = merge_dicts(enabled, disabled)
for module, action in changed.items():
serializer.instance.log_action(
'pretix.event.plugins.' + action,
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
data={'plugin': module}
)
other_keys = {k: v for k, v in serializer.validated_data.items() if k not in ['plugins', 'live']}
if other_keys:
serializer.instance.log_action(
'pretix.event.changed',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
data=self.request.data
)
def perform_create(self, serializer):
serializer.save(organizer=self.request.organizer)
serializer.instance.log_action(
'pretix.event.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
data=self.request.data
)
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied('The event can not be deleted as it already contains orders. Please set \'live\''
' to false to hide the event and take the shop offline instead.')
try:
with transaction.atomic():
instance.organizer.log_action(
'pretix.event.deleted', user=self.request.user,
data={
'event_id': instance.pk,
'name': str(instance.name),
'logentries': list(instance.logentry_set.values_list('pk', flat=True))
}
)
instance.delete_sub_objects()
super().perform_destroy(instance)
except ProtectedError:
raise PermissionDenied('The event could not be deleted as some constraints (e.g. data created by plug-ins) '
'do not allow it.')
class CloneEventViewSet(viewsets.ModelViewSet):
serializer_class = CloneEventSerializer
queryset = Event.objects.none()
lookup_field = 'slug'
lookup_url_kwarg = 'event'
http_method_names = ['post']
write_permission = 'can_create_events'
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.kwargs['event']
ctx['organizer'] = self.request.organizer
return ctx
def perform_create(self, serializer):
serializer.save(organizer=self.request.organizer)
serializer.instance.log_action(
'pretix.event.added',
user=self.request.user,
api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None),
data=self.request.data
)
class SubEventFilter(FilterSet):
class Meta:

View File

@@ -525,6 +525,40 @@ class Event(EventMixin, LoggedModel):
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
return data
@property
def has_payment_provider(self):
result = False
for provider in self.get_payment_providers().values():
if provider.is_enabled and provider.identifier != 'free':
result = True
break
return result
@property
def has_paid_things(self):
from .items import Item, ItemVariation
return Item.objects.filter(event=self, default_price__gt=0).exists()\
or ItemVariation.objects.filter(item__event=self, default_price__gt=0).exists()
@cached_property
def live_issues(self):
from pretix.base.signals import event_live_issues
issues = []
if self.has_paid_things and not self.has_payment_provider:
issues.append(_('You have configured at least one paid product but have not enabled any payment methods.'))
if not self.quotas.exists():
issues.append(_('You need to configure at least one quota to sell anything.'))
responses = event_live_issues.send(self)
for receiver, response in sorted(responses, key=lambda r: str(r[0])):
if response:
issues.append(response)
return issues
def get_users_with_any_permission(self):
"""
Returns a queryset of users who have any permission to this event.
@@ -556,9 +590,78 @@ class Event(EventMixin, LoggedModel):
return User.objects.annotate(twp=Exists(team_with_perm)).filter(twp=True)
def clean_live(self):
for issue in self.live_issues:
if issue:
raise ValidationError(issue)
def allow_delete(self):
return not self.orders.exists() and not self.invoices.exists()
def delete_sub_objects(self):
self.items.all().delete()
self.subevents.all().delete()
def set_active_plugins(self, modules, allow_restricted=False):
from pretix.base.plugins import get_all_plugins
plugins_active = self.get_plugins()
plugins_available = {
p.module: p for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
enable = [m for m in modules if m not in plugins_active and m in plugins_available]
for module in enable:
if getattr(plugins_available[module].app, 'restricted', False) and not allow_restricted:
modules.remove(module)
elif hasattr(plugins_available[module].app, 'installed'):
getattr(plugins_available[module].app, 'installed')(self)
self.plugins = ",".join(modules)
def enable_plugin(self, module, allow_restricted=False):
plugins_active = self.get_plugins()
if module not in plugins_active:
plugins_active.append(module)
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
def disable_plugin(self, module):
plugins_active = self.get_plugins()
if module in plugins_active:
plugins_active.remove(module)
self.set_active_plugins(plugins_active)
@staticmethod
def clean_has_subevents(event, has_subevents):
if event is not None and event.has_subevents is not None:
if event.has_subevents != has_subevents:
raise ValidationError(_('Once created an event cannot change between an series and a single event.'))
@staticmethod
def clean_slug(organizer, event, slug):
if event is not None and event.slug is not None:
if event.slug != slug:
raise ValidationError(_('The event slug cannot be changed.'))
else:
if Event.objects.filter(slug=slug, organizer=organizer).exists():
raise ValidationError(_('This slug has already been used for a different event.'))
@staticmethod
def clean_dates(date_from, date_to):
if date_from is not None and date_to is not None:
if date_from > date_to:
raise ValidationError(_('The event cannot end before it starts.'))
@staticmethod
def clean_presale(presale_start, presale_end):
if presale_start is not None and presale_end is not None:
if presale_start > presale_end:
raise ValidationError(_('The event\'s presale cannot end before it starts.'))
class SubEvent(EventMixin, LoggedModel):
"""

View File

@@ -229,6 +229,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.plugins.disabled': _('A plugin has been disabled.'),
'pretix.event.live.activated': _('The shop has been taken live.'),
'pretix.event.live.deactivated': _('The shop has been taken offline.'),
'pretix.event.added': _('The event has been created.'),
'pretix.event.changed': _('The event settings have been changed.'),
'pretix.event.question.option.added': _('An answer option has been added to the question.'),
'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'),

View File

@@ -30,13 +30,13 @@ from pytz import timezone
from pretix.base.i18n import LazyCurrencyNumber
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, Item, ItemVariation, LogEntry,
Order, RequiredAction, TaxRule, Voucher,
CachedCombinedTicket, CachedTicket, Event, LogEntry, Order, RequiredAction,
TaxRule, Voucher,
)
from pretix.base.models.event import EventMetaValue
from pretix.base.services import tickets
from pretix.base.services.invoices import build_preview_invoice_pdf
from pretix.base.signals import event_live_issues, register_ticket_outputs
from pretix.base.signals import register_ticket_outputs
from pretix.base.templatetags.money import money_filter
from pretix.control.forms.event import (
CommentForm, DisplaySettingsForm, EventDeleteForm, EventMetaValueForm,
@@ -200,34 +200,29 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
self.object = self.get_object()
plugins_active = self.object.get_plugins()
plugins_available = {
p.module: p for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True)
}
with transaction.atomic():
allow_restricted = request.user.has_active_staff_session(request.session.session_key)
for key, value in request.POST.items():
if key.startswith("plugin:"):
module = key.split(":")[1]
if value == "enable" and module in plugins_available:
if getattr(plugins_available[module], 'restricted', False):
if not request.user.has_active_staff_session(request.session.session_key):
if not allow_restricted:
continue
if hasattr(plugins_available[module].app, 'installed'):
getattr(plugins_available[module].app, 'installed')(self.request.event)
self.request.event.log_action('pretix.event.plugins.enabled', user=self.request.user,
data={'plugin': module})
if module not in plugins_active:
plugins_active.append(module)
self.object.enable_plugin(module, allow_restricted=allow_restricted)
else:
self.request.event.log_action('pretix.event.plugins.disabled', user=self.request.user,
data={'plugin': module})
if module in plugins_active:
plugins_active.remove(module)
self.object.plugins = ",".join(plugins_active)
self.object.disable_plugin(module)
self.object.save()
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
@@ -749,38 +744,11 @@ class EventLive(EventPermissionRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['issues'] = self.issues
ctx['issues'] = self.request.event.live_issues
return ctx
@cached_property
def issues(self):
issues = []
has_paid_things = (
Item.objects.filter(event=self.request.event, default_price__gt=0).exists()
or ItemVariation.objects.filter(item__event=self.request.event, default_price__gt=0).exists()
)
has_payment_provider = False
for provider in self.request.event.get_payment_providers().values():
if provider.is_enabled and provider.identifier != 'free':
has_payment_provider = True
break
if has_paid_things and not has_payment_provider:
issues.append(_('You have configured at least one paid product but have not enabled any payment methods.'))
if not self.request.event.quotas.exists():
issues.append(_('You need to configure at least one quota to sell anything.'))
responses = event_live_issues.send(self.request.event)
for receiver, response in sorted(responses, key=lambda r: str(r[0])):
if response:
issues.append(response)
return issues
def post(self, request, *args, **kwargs):
if request.POST.get("live") == "true" and not self.issues:
if request.POST.get("live") == "true" and not self.request.event.live_issues:
request.event.live = True
request.event.save()
self.request.event.log_action(
@@ -831,8 +799,7 @@ class EventDelete(EventPermissionRequiredMixin, FormView):
'logentries': list(self.request.event.logentry_set.values_list('pk', flat=True))
}
)
self.request.event.items.all().delete()
self.request.event.subevents.all().delete()
self.request.event.delete_sub_objects()
self.request.event.delete()
messages.success(self.request, _('The event has been deleted.'))
return redirect(self.get_success_url())