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
+19
View File
@@ -44,6 +44,25 @@ like the following:
adding OAuth2 support in the future for user-level authentication. If you want
to use session authentication, be sure to comply with Django's `CSRF policies`_.
Permissions
-----------
The API follows pretix team based permissions model. Each organizer can have several teams
each with it's own set of permissions. Each team can have any number of API keys attached.
To access a given endpoint the team the API key belongs to needs to have the corresponding
permission for the organizer/event being accessed.
Possible permissions are:
* Can create events
* Can change event settings
* Can change product settings
* Can view orders
* Can change orders
* Can view vouchers
* Can change vouchers
Compatibility
-------------
+266 -3
View File
@@ -25,14 +25,22 @@ presale_start datetime The date at whi
presale_end datetime The date at which the ticket shop closes (or ``null``)
location multi-lingual string The event location (or ``null``)
has_subevents boolean ``True`` if the event series feature is active for this
event
event. Cannot change after event is created.
meta_data dict Values set for organizer-specific meta data parameters.
plugins list A list of package names of the enabled plugins for this
event.
===================================== ========================== =======================================================
.. versionchanged:: 1.7
The ``meta_data`` field has been added.
.. versionchanged:: 1.15
The ``plugins`` field has been added.
The operations POST, PATCH, PUT and DELETE have been added.
Endpoints
---------
@@ -40,6 +48,8 @@ Endpoints
Returns a list of all events within a given organizer the authenticated user/token has access to.
Permission required: "Can change event settings"
**Example request**:
.. sourcecode:: http
@@ -74,7 +84,13 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {}
"meta_data": {},
"plugins": [
"pretix.plugins.banktransfer"
"pretix.plugins.stripe"
"pretix.plugins.paypal"
"pretix.plugins.ticketoutputpdf"
]
}
]
}
@@ -89,6 +105,8 @@ Endpoints
Returns information on one event, identified by its slug.
Permission required: "Can change event settings"
**Example request**:
.. sourcecode:: http
@@ -118,7 +136,13 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {}
"meta_data": {},
"plugins": [
"pretix.plugins.banktransfer"
"pretix.plugins.stripe"
"pretix.plugins.paypal"
"pretix.plugins.ticketoutputpdf"
]
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -126,3 +150,242 @@ Endpoints
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/
Creates a new event
Please note that events cannot be created as 'live' using this endpoint. Quotas and payment must be added to the
event before sales can go live.
Permission required: "Can create events"
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"is_public": false,
"presale_start": null,
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"is_public": false,
"presale_start": null,
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
]
}
:param organizer: The ``slug`` field of the organizer of the event to create.
:statuscode 201: no error
:statuscode 400: The event could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/clone/
Creates a new event with properties as set in the request body. The properties that are copied are: 'is_public',
settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
If the 'plugins' and/or 'is_public' fields are present in the post body this will determine their value. Otherwise
their value will be copied from the existing event.
Please note that you can only copy from events under the same organizer.
Permission required: "Can create events"
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/clone/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"is_public": false,
"presale_start": null,
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"is_public": false,
"presale_start": null,
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {},
"plugins": [
"pretix.plugins.stripe",
"pretix.plugins.paypal"
]
}
:param organizer: The ``slug`` field of the organizer of the event to create.
:param event: The ``slug`` field of the event to copy settings and items from.
:statuscode 201: no error
:statuscode 400: The event could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/
Updates an event
Permission required: "Can change event settings"
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.pretixdroid"
]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"name": {"en": "Sample Conference"},
"slug": "sampleconf",
"live": false,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"is_public": false,
"presale_start": null,
"presale_end": null,
"location": null,
"has_subevents": false,
"meta_data": {},
"plugins": [
"pretix.plugins.banktransfer",
"pretix.plugins.stripe",
"pretix.plugins.paypal",
"pretix.plugins.pretixdroid"
]
}
:param organizer: The ``slug`` field of the organizer of the event to update
:param event: The ``slug`` field of the event to update
:statuscode 201: no error
:statuscode 400: The event could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/
Delete an event. Note that events with orders cannot be deleted to ensure data integrity.
Permission required: "Can change event settings"
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
+15
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
+152 -2
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):
+1
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)
+101 -2
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:
+103
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):
"""
+1
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.'),
+11 -44
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())
+3 -1
View File
@@ -27,7 +27,8 @@ def event(organizer, meta_prop):
e = Event.objects.create(
organizer=organizer, name='Dummy', slug='dummy',
date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC),
plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf'
plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf',
is_public=True
)
e.meta_values.create(property=meta_prop, value="Conference")
return e
@@ -60,6 +61,7 @@ def team(organizer):
return Team.objects.create(
organizer=organizer,
can_change_items=True,
can_create_events=True,
can_change_event_settings=True,
can_change_vouchers=True,
can_view_vouchers=True,
+552 -3
View File
@@ -1,4 +1,77 @@
from datetime import datetime, timedelta
from decimal import Decimal
from unittest import mock
import pytest
from django_countries.fields import Country
from pytz import UTC
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Order, OrderPosition,
)
from pretix.base.models.orders import OrderFee
@pytest.fixture
def variations(item):
v = list()
v.append(item.variations.create(value="ChildA1"))
v.append(item.variations.create(value="ChildA2"))
return v
@pytest.fixture
def order(event, item, taxrule):
testtime = datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
o = Order.objects.create(
code='FOO', event=event, email='dummy@dummy.test',
status=Order.STATUS_PENDING, secret="k24fiuwvu8kxz3y1",
datetime=datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC),
expires=datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC),
total=23, payment_provider='banktransfer', locale='en'
)
o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'),
tax_value=Decimal('0.05'), tax_rule=taxrule)
InvoiceAddress.objects.create(order=o, company="Sample company", country=Country('NZ'))
return o
@pytest.fixture
def order_position(item, order, taxrule, variations):
op = OrderPosition.objects.create(
order=order,
item=item,
variation=variations[0],
tax_rule=taxrule,
tax_rate=taxrule.rate,
tax_value=Decimal("3"),
price=Decimal("23"),
attendee_name="Peter",
secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w"
)
return op
@pytest.fixture
def cart_position(event, item, variations):
testtime = datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC)
with mock.patch('django.utils.timezone.now') as mock_now:
mock_now.return_value = testtime
c = CartPosition.objects.create(
event=event,
item=item,
datetime=datetime.now(),
expires=datetime.now() + timedelta(days=1),
variation=variations[0],
price=Decimal("23"),
cart_id="z3fsn8jyufm5kpk768q69gkbyr5f4h6w"
)
return c
TEST_EVENT_RES = {
"name": {"en": "Dummy"},
@@ -7,22 +80,473 @@ TEST_EVENT_RES = {
"date_from": "2017-12-27T10:00:00Z",
"date_to": None,
"date_admission": None,
"is_public": False,
"is_public": True,
"presale_start": None,
"presale_end": None,
"location": None,
"slug": "dummy",
"has_subevents": False,
"meta_data": {"type": "Conference"}
"meta_data": {"type": "Conference"},
'plugins': {
'pretix.plugins.banktransfer',
'pretix.plugins.ticketoutputpdf'
}
}
@pytest.fixture
def item(event):
return event.items.create(name="Budget Ticket", default_price=23)
@pytest.fixture
def free_item(event):
return event.items.create(name="Free Ticket", default_price=0)
@pytest.fixture
def free_quota(event, free_item):
q = event.quotas.create(name="Budget Quota", size=200)
q.items.add(free_item)
return q
@pytest.mark.django_db
def test_event_list(token_client, organizer, event):
resp = token_client.get('/api/v1/organizers/{}/events/'.format(organizer.slug))
assert resp.status_code == 200
print(resp.data)
assert TEST_EVENT_RES == dict(resp.data['results'][0])
assert TEST_EVENT_RES == resp.data['results'][0]
@pytest.mark.django_db
def test_event_get(token_client, organizer, event):
resp = token_client.get('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
assert resp.status_code == 200
print(resp.data)
assert TEST_EVENT_RES == resp.data
@pytest.mark.django_db
def test_event_create(token_client, organizer, event, meta_prop):
resp = token_client.post(
'/api/v1/organizers/{}/events/'.format(organizer.slug),
{
"name": {
"de": "Demo Konference 2020 Test",
"en": "Demo Conference 2020 Test"
},
"live": False,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": "2017-12-28T10:00:00Z",
"date_admission": None,
"is_public": False,
"presale_start": None,
"presale_end": None,
"location": None,
"slug": "2030",
"meta_data": {
meta_prop.name: "Conference"
}
},
format='json'
)
assert resp.status_code == 201
assert organizer.events.get(slug="2030").meta_values.filter(
property__name=meta_prop.name, value="Conference"
).exists()
resp = token_client.post(
'/api/v1/organizers/{}/events/'.format(organizer.slug),
{
"name": {
"de": "Demo Konference 2020 Test",
"en": "Demo Conference 2020 Test"
},
"live": False,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": "2017-12-28T10:00:00Z",
"date_admission": None,
"is_public": False,
"presale_start": None,
"presale_end": None,
"location": None,
"slug": "2020",
"meta_data": {
"foo": "bar"
}
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"meta_data":["Meta data property \'foo\' does not exist."]}'
resp = token_client.post(
'/api/v1/organizers/{}/events/'.format(organizer.slug),
{
"name": {
"de": "Demo Konference 2020 Test",
"en": "Demo Conference 2020 Test"
},
"live": False,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": "2017-12-28T10:00:00Z",
"date_admission": None,
"is_public": False,
"presale_start": None,
"presale_end": None,
"location": None,
"slug": event.slug,
"meta_data": {
"type": "Conference"
}
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"slug":["This slug has already been used for a different event."]}'
resp = token_client.post(
'/api/v1/organizers/{}/events/'.format(organizer.slug),
{
"name": {
"de": "Demo Konference 2020 Test",
"en": "Demo Conference 2020 Test"
},
"live": True,
"currency": "EUR",
"date_from": "2017-12-27T10:00:00Z",
"date_to": "2017-12-28T10:00:00Z",
"date_admission": None,
"is_public": False,
"presale_start": None,
"presale_end": None,
"location": None,
"slug": "2031",
"meta_data": {
"type": "Conference"
}
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"live":["Events cannot be created as \'live\'. Quotas and payment must be added ' \
'to the event before sales can go live."]}'
@pytest.mark.django_db
def test_event_create_with_clone(token_client, organizer, event, meta_prop):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/clone/'.format(organizer.slug, event.slug),
{
"name": {
"de": "Demo Konference 2020 Test",
"en": "Demo Conference 2020 Test"
},
"live": False,
"currency": "EUR",
"date_from": "2018-12-27T10:00:00Z",
"date_to": "2018-12-28T10:00:00Z",
"date_admission": None,
"is_public": False,
"presale_start": None,
"presale_end": None,
"location": None,
"slug": "2030",
"meta_data": {
"type": "Conference"
},
"plugins": [
"pretix.plugins.ticketoutputpdf"
]
},
format='json'
)
assert resp.status_code == 201
cloned_event = Event.objects.get(organizer=organizer.pk, slug='2030')
assert cloned_event.plugins == 'pretix.plugins.ticketoutputpdf'
assert cloned_event.is_public is False
assert organizer.events.get(slug="2030").meta_values.filter(
property__name=meta_prop.name, value="Conference"
).exists()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/clone/'.format(organizer.slug, event.slug),
{
"name": {
"de": "Demo Konference 2020 Test",
"en": "Demo Conference 2020 Test"
},
"live": False,
"currency": "EUR",
"date_from": "2018-12-27T10:00:00Z",
"date_to": "2018-12-28T10:00:00Z",
"date_admission": None,
"presale_start": None,
"presale_end": None,
"location": None,
"slug": "2031",
"meta_data": {
"type": "Conference"
}
},
format='json'
)
assert resp.status_code == 201
cloned_event = Event.objects.get(organizer=organizer.pk, slug='2031')
assert cloned_event.plugins == "pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf"
assert cloned_event.is_public is True
assert organizer.events.get(slug="2031").meta_values.filter(
property__name=meta_prop.name, value="Conference"
).exists()
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/clone/'.format(organizer.slug, event.slug),
{
"name": {
"de": "Demo Konference 2020 Test",
"en": "Demo Conference 2020 Test"
},
"live": False,
"currency": "EUR",
"date_from": "2018-12-27T10:00:00Z",
"date_to": "2018-12-28T10:00:00Z",
"date_admission": None,
"presale_start": None,
"presale_end": None,
"location": None,
"slug": "2032",
"plugins": []
},
format='json'
)
assert resp.status_code == 201
cloned_event = Event.objects.get(organizer=organizer.pk, slug='2032')
assert cloned_event.plugins == ""
@pytest.mark.django_db
def test_event_put_with_clone(token_client, organizer, event, meta_prop):
resp = token_client.put(
'/api/v1/organizers/{}/events/{}/clone/'.format(organizer.slug, event.slug),
{},
format='json'
)
assert resp.status_code == 405
@pytest.mark.django_db
def test_event_patch_with_clone(token_client, organizer, event, meta_prop):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/clone/'.format(organizer.slug, event.slug),
{},
format='json'
)
assert resp.status_code == 405
@pytest.mark.django_db
def test_event_delete_with_clone(token_client, organizer, event, meta_prop):
resp = token_client.delete(
'/api/v1/organizers/{}/events/{}/clone/'.format(organizer.slug, event.slug),
{},
format='json'
)
assert resp.status_code == 405
@pytest.mark.django_db
def test_event_update(token_client, organizer, event, item, meta_prop):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"date_from": "2018-12-27T10:00:00Z",
"date_to": "2018-12-28T10:00:00Z",
"currency": "DKK",
},
format='json'
)
assert resp.status_code == 200
event = Event.objects.get(organizer=organizer.pk, slug=resp.data['slug'])
assert event.currency == "DKK"
assert organizer.events.get(slug=resp.data['slug']).meta_values.filter(
property__name=meta_prop.name, value="Conference"
).exists()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"date_from": "2017-12-27T10:00:00Z",
"date_to": "2017-12-26T10:00:00Z"
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["The event cannot end before it starts."]}'
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"presale_start": "2017-12-27T10:00:00Z",
"presale_end": "2017-12-26T10:00:00Z"
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["The event\'s presale cannot end before it starts."]}'
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"slug": "testing"
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"slug":["The event slug cannot be changed."]}'
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"has_subevents": True
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"has_subevents":["Once created an event cannot change between an series and a ' \
'single event."]}'
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
meta_prop.name: "Workshop"
}
},
format='json'
)
assert resp.status_code == 200
assert organizer.events.get(slug=resp.data['slug']).meta_values.filter(
property__name=meta_prop.name, value="Workshop"
).exists()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
}
},
format='json'
)
assert resp.status_code == 200
assert not organizer.events.get(slug=resp.data['slug']).meta_values.filter(
property__name=meta_prop.name
).exists()
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"meta_data": {
"test": "test"
}
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"meta_data":["Meta data property \'test\' does not exist."]}'
@pytest.mark.django_db
def test_event_update_live_no_product(token_client, organizer, event):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"live": True
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"live":["You need to configure at least one quota to sell anything."]}'
@pytest.mark.django_db
def test_event_update_live_no_payment_method(token_client, organizer, event, item):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"live": True
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"live":["You have configured at least one paid product but have not enabled any ' \
'payment methods."]}'
@pytest.mark.django_db
def test_event_update_live_free_product(token_client, organizer, event, free_item, free_quota):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"live": True
},
format='json'
)
assert resp.status_code == 200
@pytest.mark.django_db
def test_event_update_plugins(token_client, organizer, event, free_item, free_quota):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"plugins": [
"pretix.plugins.ticketoutputpdf",
"pretix.plugins.pretixdroid"
]
},
format='json'
)
assert resp.status_code == 200
assert resp.data.get('plugins') == {
"pretix.plugins.ticketoutputpdf",
"pretix.plugins.pretixdroid"
}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"plugins": {
"pretix.plugins.banktransfer"
}
},
format='json'
)
assert resp.status_code == 200
assert resp.data.get('plugins') == {
"pretix.plugins.banktransfer"
}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"plugins": {
"pretix.plugins.test"
}
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"plugins":["Unknown plugin: \'pretix.plugins.test\'."]}'
@pytest.mark.django_db
@@ -32,3 +556,28 @@ def test_event_detail(token_client, organizer, event, team):
resp = token_client.get('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
assert resp.status_code == 200
assert TEST_EVENT_RES == resp.data
@pytest.mark.django_db
def test_event_delete(token_client, organizer, event):
resp = token_client.delete('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
assert resp.status_code == 204
assert not organizer.events.filter(pk=event.id).exists()
@pytest.mark.django_db
def test_event_with_order_position_not_delete(token_client, organizer, event, item, order_position):
resp = token_client.delete('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
assert resp.status_code == 403
assert resp.content.decode() == '{"detail":"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."}'
assert organizer.events.filter(pk=event.id).exists()
@pytest.mark.django_db
def test_event_with_cart_position_not_delete(token_client, organizer, event, item, cart_position):
resp = token_client.delete('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
assert resp.status_code == 403
assert resp.content.decode() == '{"detail":"The event could not be deleted as some constraints (e.g. data ' \
'created by plug-ins) do not allow it."}'
assert organizer.events.filter(pk=event.id).exists()
+40 -5
View File
@@ -20,7 +20,7 @@ event_urls = [
'checkinlists/',
]
event_permission_urls = [
event_permission_sub_urls = [
('get', 'can_view_orders', 'orders/', 200),
('get', 'can_view_orders', 'orderpositions/', 200),
('get', 'can_view_vouchers', 'vouchers/', 200),
@@ -68,6 +68,15 @@ event_permission_urls = [
('put', 'can_change_event_settings', 'checkinlists/1/', 404),
('patch', 'can_change_event_settings', 'checkinlists/1/', 404),
('delete', 'can_change_event_settings', 'checkinlists/1/', 404),
('post', 'can_create_events', 'clone/', 400),
]
event_permission_root_urls = [
('post', 'can_create_events', 400),
('put', 'can_change_event_settings', 400),
('patch', 'can_change_event_settings', 200),
('delete', 'can_change_event_settings', 204),
]
@@ -137,8 +146,8 @@ def test_event_not_existing(token_client, organizer, url, event):
@pytest.mark.django_db
@pytest.mark.parametrize("urlset", event_permission_urls)
def test_token_event_permission_allowed(token_client, team, organizer, event, urlset):
@pytest.mark.parametrize("urlset", event_permission_sub_urls)
def test_token_event_subresources_permission_allowed(token_client, team, organizer, event, urlset):
team.all_events = True
setattr(team, urlset[1], True)
team.save()
@@ -148,8 +157,8 @@ def test_token_event_permission_allowed(token_client, team, organizer, event, ur
@pytest.mark.django_db
@pytest.mark.parametrize("urlset", event_permission_urls)
def test_token_event_permission_not_allowed(token_client, team, organizer, event, urlset):
@pytest.mark.parametrize("urlset", event_permission_sub_urls)
def test_token_event_subresources_permission_not_allowed(token_client, team, organizer, event, urlset):
team.all_events = True
setattr(team, urlset[1], False)
team.save()
@@ -161,6 +170,32 @@ def test_token_event_permission_not_allowed(token_client, team, organizer, event
assert resp.status_code in (404, 403)
@pytest.mark.django_db
@pytest.mark.parametrize("urlset", event_permission_root_urls)
def test_token_event_permission_allowed(token_client, team, organizer, event, urlset):
team.all_events = True
setattr(team, urlset[1], True)
team.save()
if urlset[0] == 'post':
resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/'.format(organizer.slug))
else:
resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
assert resp.status_code == urlset[2]
@pytest.mark.django_db
@pytest.mark.parametrize("urlset", event_permission_root_urls)
def test_token_event_permission_not_allowed(token_client, team, organizer, event, urlset):
team.all_events = True
setattr(team, urlset[1], False)
team.save()
if urlset[0] == 'post':
resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/'.format(organizer.slug))
else:
resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug))
assert resp.status_code == 403
@pytest.mark.django_db
def test_log_out_after_absolute_timeout(user_client, team, organizer, event):
session = user_client.session