Writable API for ticket layouts (#3004)

Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Raphael Michel
2023-01-09 13:44:01 +01:00
committed by GitHub
parent 2e702b87de
commit e5528f7784
8 changed files with 770 additions and 36 deletions

View File

@@ -35,6 +35,7 @@
import copy
import hashlib
import itertools
import json
import logging
import os
import re
@@ -46,12 +47,15 @@ from collections import OrderedDict
from functools import partial
from io import BytesIO
import jsonschema
from arabic_reshaper import ArabicReshaper
from bidi.algorithm import get_display
from django.conf import settings
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db.models import Max, Min
from django.dispatch import receiver
from django.utils.deconstruct import deconstructible
from django.utils.formats import date_format
from django.utils.functional import SimpleLazyObject
from django.utils.html import conditional_escape
@@ -740,9 +744,9 @@ class Renderer:
if o['content'] == 'other' or o['content'] == 'other_i18n':
if o['content'] == 'other_i18n':
text = str(LazyI18nString(o['text_i18n']))
text = str(LazyI18nString(o.get('text_i18n', {})))
else:
text = o['text']
text = o.get('text', '')
def replace(x):
if x.group(1).startswith('itemmeta:'):
@@ -975,3 +979,22 @@ class Renderer:
output.write(outbuffer)
outbuffer.seek(0)
return outbuffer
@deconstructible
class PdfLayoutValidator:
def __call__(self, value):
if not isinstance(value, dict):
try:
val = json.loads(value)
except ValueError:
raise ValidationError(_('Your layout file is not a valid JSON file.'))
else:
val = value
with open(finders.find('schema/pdf-layout.schema.json'), 'r') as f:
schema = json.loads(f.read())
try:
jsonschema.validate(val, schema)
except jsonschema.ValidationError as e:
e = str(e).replace('%', '%%')
raise ValidationError(_('Your layout file is not a valid layout. Error message: {}').format(e))

View File

@@ -458,7 +458,9 @@
</form>
</div>
</div>
<script type="text/plain" id="schema-url">{% static "schema/pdf-layout.schema.json" %}</script>
<script type="text/javascript" src="{% static "pdfjs/pdf.js" %}"></script>
<script type="text/javascript" src="{% static "ajv/ajv2020.bundle.min.js" %}"></script>
<script type="text/javascript" src="{% static "fabric/fabric.min.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/editor.js" %}"></script>
<img src="{% static 'pretixpresale/pdf/powered_by_pretix_dark.png' %}" id="poweredby-dark" class="sr-only">

View File

@@ -19,11 +19,16 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.conf import settings
from django.db import transaction
from rest_framework import viewsets
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField
from ...api.serializers.fields import UploadedFileField
from ...base.pdf import PdfLayoutValidator
from ...multidomain.utils import static_absolute
from .models import TicketLayout, TicketLayoutItem
@@ -43,8 +48,13 @@ class NestedItemAssignmentSerializer(I18nAwareModelSerializer):
class TicketLayoutSerializer(I18nAwareModelSerializer):
layout = CompatibleJSONField()
item_assignments = NestedItemAssignmentSerializer(many=True)
layout = CompatibleJSONField(
validators=[PdfLayoutValidator()]
)
item_assignments = NestedItemAssignmentSerializer(many=True, read_only=True)
background = UploadedFileField(required=False, allow_null=True, allowed_types=(
'application/pdf',
), max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
class Meta:
model = TicketLayout
@@ -56,8 +66,17 @@ class TicketLayoutSerializer(I18nAwareModelSerializer):
d['background'] = static_absolute(instance.event, "pretixpresale/pdf/ticket_default_a4.pdf")
return d
def validate(self, attrs):
if attrs.get('default') and self.context['event'].ticket_layouts.filter(default=True).exists:
raise ValidationError('You cannot have two layouts with default = True')
return attrs
class TicketLayoutViewSet(viewsets.ReadOnlyModelViewSet):
def create(self, validated_data):
validated_data["event"] = self.context["event"]
return super().create(validated_data)
class TicketLayoutViewSet(viewsets.ModelViewSet):
serializer_class = TicketLayoutSerializer
queryset = TicketLayout.objects.none()
lookup_field = 'id'
@@ -65,6 +84,45 @@ class TicketLayoutViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
return self.request.event.ticket_layouts.all()
def get_serializer_context(self):
return {
**super().get_serializer_context(),
'event': self.request.event,
}
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action(
action='pretix.plugins.ticketoutputpdf.layout.deleted',
user=self.request.user, auth=self.request.auth
)
super().perform_destroy(instance)
if not self.request.event.ticket_layouts.filter(default=True).exists():
f = self.request.event.ticket_layouts.first()
if f:
f.default = True
f.save(update_fields=['default'])
@transaction.atomic()
def perform_create(self, serializer):
super().perform_create(serializer)
serializer.instance.log_action(
action='pretix.plugins.ticketoutputpdf.layout.added',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
@transaction.atomic()
def perform_update(self, serializer):
super().perform_update(serializer)
serializer.instance.log_action(
action='pretix.plugins.ticketoutputpdf.layout.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
class TicketLayoutItemViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = ItemAssignmentSerializer

File diff suppressed because one or more lines are too long

View File

@@ -118,6 +118,7 @@ var editor = {
uploaded_file_id: null,
_window_loaded: false,
_fabric_loaded: false,
schema: null,
_px2mm: function (v) {
return v / editor.pdf_scale / 72 * editor.pdf_page.userUnit * 25.4;
@@ -988,8 +989,26 @@ var editor = {
},
_source_save: function () {
editor.load(JSON.parse($("#source-textarea").val()));
$("#source-container").hide();
try {
var Ajv = window.ajv2020
var ajv = new Ajv()
var validate = ajv.compile(editor.schema)
var data = JSON.parse($("#source-textarea").val())
var valid = validate(data)
if (!valid) {
console.log(validate.errors)
alert("Invalid input syntax. If you're familiar with this, check out the developer console for a full " +
"error log. Otherwise, please contact support.")
} else {
editor.load(data);
$("#source-container").hide();
}
} catch (e) {
console.error(e)
alert("Parsing error. If you're familiar with this, check out the developer console for a full " +
"error log. Otherwise, please contact support.")
}
},
_create_empty_background: function () {
@@ -1098,6 +1117,10 @@ var editor = {
$("#toolbox-source").bind('click', editor._source_show);
$("#source-close").bind('click', editor._source_close);
$("#source-save").bind('click', editor._source_save);
$.getJSON($("#schema-url").text(), function (data) {
editor.schema = data;
})
}
};

View File

@@ -0,0 +1,371 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Ticket Layout",
"description": "Dynamic elements for a PDF layout",
"type": "array",
"items": {
"type": "object",
"description": "Layout object",
"oneOf": [
{
"required": [
"type",
"left",
"bottom",
"size"
],
"properties": {
"type": {
"type": "string",
"const": "barcodearea"
},
"page": {
"description": "Page number this will be shown on, defaults to 1.",
"type": "number"
},
"left": {
"description": "Position of the element on the x axis in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
},
"bottom": {
"description": "Position of the element on the y axis in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
},
"content": {
"description": "Name of a variable to use. The available values depend on event configuration and installed plugins. Defaults to 'secret'.",
"type": "string"
},
"text": {
"description": "Custom text. Only used when 'content' is set to 'other'.",
"type": "string"
},
"text_i18n": {
"description": "Custom text in multiple languages. Only used when 'content' is set to 'other_i18n'.",
"type": "object",
"patternProperties": {
"[a-zA-Z-]+": {
"type": "string"
}
},
"additionalProperties": false
},
"size": {
"description": "Size of the barcode in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
},
"nowhitespace": {
"description": "Whether a barcode should be rendered without margins. Only used for type 'barcodearea'.",
"type": "boolean"
}
},
"additionalProperties": false
},
{
"required": [
"type",
"left",
"bottom",
"content",
"width",
"height"
],
"properties": {
"type": {
"type": "string",
"const": "imagearea"
},
"page": {
"description": "Page number this will be shown on, defaults to 1.",
"type": "number"
},
"left": {
"description": "Position of the element on the x axis in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
},
"bottom": {
"description": "Position of the element on the y axis in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
},
"content": {
"description": "Name of a variable to use. The available values depend on event configuration and installed plugins.",
"type": "string"
},
"width": {
"description": "Width of the element in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
},
"height": {
"description": "Height of the element in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
}
},
"additionalProperties": false
},
{
"required": [
"type",
"left",
"bottom",
"content",
"width",
"fontsize",
"fontfamily",
"bold",
"italic",
"align",
"color"
],
"properties": {
"type": {
"type": "string",
"const": "textarea"
},
"page": {
"description": "Page number this will be shown on, defaults to 1.",
"type": "number"
},
"left": {
"description": "Position of the element on the x axis in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
},
"bottom": {
"description": "Position of the element on the y axis in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
},
"width": {
"description": "Width of the element in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
},
"content": {
"description": "Name of a variable to use. The available values depend on event configuration and installed plugins.",
"type": "string"
},
"text": {
"description": "Custom text. Only used when 'content' is set to 'other'.",
"type": "string"
},
"text_i18n": {
"description": "Custom text in multiple languages. Only used when 'content' is set to 'other_i18n'.",
"type": "object",
"patternProperties": {
"[a-zA-Z-]+": {
"type": "string"
}
},
"additionalProperties": false
},
"locale": {
"description": "Locale to render the text in.",
"type": ["string", "null"],
"pattern": "[a-zA-Z-]*"
},
"fontsize": {
"description": "Font size.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
},
"fontfamily": {
"description": "Font family. The available values depend on installed plugins.",
"type": "string"
},
"bold": {
"description": "Use bold font variant.",
"type": "boolean"
},
"italic": {
"description": "Use italic font variant.",
"type": "boolean"
},
"align": {
"description": "Text alignment.",
"type": "string",
"enum": [
"left",
"center",
"right"
]
},
"color": {
"description": "Text color as a tuple of three integers in the 0-255 range and one float in the 0-1 range. The last value (alpha) is ignored by the current implementation but might be used in the future.",
"type": "array",
"items": {
"type": "number",
"minimum": 0,
"maximum": 255
},
"minItems": 3,
"maxItems": 4
},
"downward": {
"description": "Downward rendering of text (recommended for new layouts), but default is false for backwards-compatibility.",
"type": "boolean"
},
"rotation": {
"description": "Rotation in degrees.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
}
},
"additionalProperties": false
},
{
"required": [
"type",
"left",
"bottom",
"content",
"size"
],
"properties": {
"type": {
"type": "string",
"const": "poweredby"
},
"page": {
"description": "Page number this will be shown on, defaults to 1.",
"type": "number"
},
"left": {
"description": "Position of the element on the x axis in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
},
"bottom": {
"description": "Position of the element on the y axis in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
},
"content": {
"description": "Name of a style to use. The available values currently are 'dark' and 'white'.",
"type": "string",
"enum": [
"dark",
"white"
]
},
"size": {
"description": "Size of the logo in millimeters.",
"oneOf": [
{
"type": "number"
},
{
"type": "string",
"pattern": "^[0-9]+(\\.[0-9]+)?$"
}
]
}
},
"additionalProperties": false
}
]
}
}

View File

@@ -23,9 +23,12 @@ import copy
import json
import pytest
from django.core.files.base import ContentFile
from django.utils.timezone import now
from django_scopes import scopes_disabled
from rest_framework.test import APIClient
from pretix.base.models import Event, Item, Organizer, Team, User
from pretix.base.models import Event, Item, Organizer, Team
from pretix.plugins.ticketoutputpdf.models import TicketLayoutItem
@@ -36,14 +39,25 @@ def env():
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer'
)
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
t = Team.objects.create(organizer=event.organizer)
t.members.add(user)
t.limit_events.add(event)
item1 = Item.objects.create(event=event, name="Ticket", default_price=23)
tl = event.ticket_layouts.create(name="Foo", default=True, layout='[{"a": 2}]')
TicketLayoutItem.objects.create(layout=tl, item=item1)
return event, user, tl, item1
return event, tl, item1
@pytest.fixture
def client():
return APIClient()
@pytest.fixture
@scopes_disabled()
def token_client(client, env):
t = env[0].organizer.teams.get().tokens.create(name="Foo")
client.credentials(HTTP_AUTHORIZATION="Token " + t.token)
return client
RES_LAYOUT = {
@@ -57,32 +71,145 @@ RES_LAYOUT = {
@pytest.mark.django_db
def test_api_list(env, client):
def test_api_list(env, token_client):
res = copy.copy(RES_LAYOUT)
res['id'] = env[2].pk
res['item_assignments'][0]['item'] = env[3].pk
client.login(email='dummy@dummy.dummy', password='dummy')
r = json.loads(
client.get('/api/v1/organizers/{}/events/{}/ticketlayouts/'.format(
env[0].slug, env[0].organizer.slug)).content.decode('utf-8')
)
res['id'] = env[1].pk
res['item_assignments'][0]['item'] = env[2].pk
r = token_client.get('/api/v1/organizers/{}/events/{}/ticketlayouts/'.format(
env[0].organizer.slug, env[0].slug)).data
assert r['results'] == [res]
r = json.loads(
client.get('/api/v1/organizers/{}/events/{}/ticketlayoutitems/'.format(
env[0].slug, env[0].organizer.slug)).content.decode('utf-8')
)
assert r['results'] == [{'item': env[3].pk, 'layout': env[2].pk, 'id': env[2].item_assignments.first().pk,
r = token_client.get('/api/v1/organizers/{}/events/{}/ticketlayoutitems/'.format(
env[0].organizer.slug, env[0].slug)).data
assert r['results'] == [{'item': env[2].pk, 'layout': env[1].pk, 'id': env[1].item_assignments.first().pk,
'sales_channel': 'web'}]
@pytest.mark.django_db
def test_api_detail(env, client):
def test_api_detail(env, token_client):
res = copy.copy(RES_LAYOUT)
res['id'] = env[2].pk
res['item_assignments'][0]['item'] = env[3].pk
client.login(email='dummy@dummy.dummy', password='dummy')
r = json.loads(
client.get('/api/v1/organizers/{}/events/{}/ticketlayouts/{}/'.format(
env[0].slug, env[0].organizer.slug, env[2].pk)).content.decode('utf-8')
)
res['id'] = env[1].pk
res['item_assignments'][0]['item'] = env[2].pk
r = token_client.get('/api/v1/organizers/{}/events/{}/ticketlayouts/{}/'.format(
env[0].organizer.slug, env[0].slug, env[1].pk)).data
assert r == res
@pytest.mark.django_db
def test_api_create(env, token_client):
r = token_client.post(
'/api/v1/upload',
data={
'media_type': 'application/pdf',
'file': ContentFile('file.pdf', 'invalid pdf content')
},
format='upload',
HTTP_CONTENT_DISPOSITION='attachment; filename="file.pdf"',
)
assert r.status_code == 201
file_id_png = r.data['id']
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/ticketlayouts/'.format(env[0].slug, env[0].slug),
{
'name': 'Foo',
'default': False,
"background": file_id_png,
'layout': [],
},
format='json'
)
assert resp.status_code == 201
tl = env[0].ticket_layouts.get(pk=resp.data["id"])
assert tl.background
@pytest.mark.django_db
def test_api_create_validate_default(env, token_client):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/ticketlayouts/'.format(env[0].slug, env[0].slug),
{
'name': 'Foo',
'default': True,
'layout': [],
},
format='json'
)
assert resp.status_code == 400
assert resp.data == {"non_field_errors": ["You cannot have two layouts with default = True"]}
@pytest.mark.django_db
def test_api_create_validate_layout(env, token_client):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/ticketlayouts/'.format(env[0].slug, env[0].slug),
{
'name': 'Foo',
'default': True,
'layout': [
{
"foo": "bar"
}
],
},
format='json'
)
assert resp.status_code == 400
assert resp.data["layout"][0].startswith("Your layout file is not a valid layout. Error message:")
@pytest.mark.django_db
def test_api_update(env, token_client):
r = token_client.post(
'/api/v1/upload',
data={
'media_type': 'application/pdf',
'file': ContentFile('file.pdf', 'invalid pdf content')
},
format='upload',
HTTP_CONTENT_DISPOSITION='attachment; filename="file.pdf"',
)
assert r.status_code == 201
file_id_png = r.data['id']
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/ticketlayouts/{}/'.format(env[0].slug, env[0].slug, env[1].pk),
{
"name": "Bar",
"background": file_id_png,
"layout": [
{"type": "barcodearea", "left": "7.00", "bottom": "11.15", "size": "45.00", "content": "secret"}
]
},
format='json'
)
assert resp.status_code == 200
env[1].refresh_from_db()
assert env[1].name == "Bar"
assert env[1].background
assert json.loads(env[1].layout) == [
{"type": "barcodearea", "left": "7.00", "bottom": "11.15", "size": "45.00", "content": "secret"}
]
@pytest.mark.django_db
def test_api_update_validate_default(env, token_client):
tl2 = env[0].ticket_layouts.create(name="Foo", default=False, layout='[{"a": 2}]')
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/ticketlayouts/{}/'.format(env[0].slug, env[0].slug, tl2.pk),
{
"default": True,
},
format='json'
)
assert resp.status_code == 400
assert resp.data == {"non_field_errors": ["You cannot have two layouts with default = True"]}
@pytest.mark.django_db
def test_api_delete(env, token_client):
resp = token_client.delete(
'/api/v1/organizers/{}/events/{}/ticketlayouts/{}/'.format(env[0].slug, env[0].slug, env[1].pk),
)
assert resp.status_code == 204
assert not env[0].ticket_layouts.exists()