>1,e+=I(e/t);o*T>>1I((R-a)/d))&&D("overflow"),a+=m*d,u<=s?1:s+T<=u?T:u-s);if(mI(R/m)&&D("overflow"),d*=m}var f=t.length+1,s=V(a-l,f,0==l);I(a/f)>R-o&&D("overflow"),o+=I(a/f),a%=f,t.splice(a++,0,o)}return String.fromCodePoint.apply(String,t)}function L(e){var t=[],r=(e=z(e)).length,a=128,o=0,s=72,n=!0,i=!1,c=void 0;try{for(var l,d=e[Symbol.iterator]();!(n=(l=d.next()).done);n=!0){var u=l.value;u<128&&t.push(A(u))}}catch(e){i=!0,c=e}finally{try{!n&&d.return&&d.return()}finally{if(i)throw c}}var m=t.length,p=m;for(m&&t.push("-");pI((R-o)/b)&&D("overflow"),o+=(f-a)*b,a=f,!0),y=!1,v=void 0;try{for(var E,P=e[Symbol.iterator]();!(w=(E=P.next()).done);w=!0){var S=E.value;if(SR&&D("overflow"),S==a){for(var j=o,N=x;;N+=x){var k=N<=s?1:s+T<=N?T:N-s;if(j>6|192).toString(16).toUpperCase()+"%"+(63&e|128).toString(16).toUpperCase():"%"+(e>>12|224).toString(16).toUpperCase()+"%"+(e>>6&63|128).toString(16).toUpperCase()+"%"+(63&e|128).toString(16).toUpperCase()}function d(e){for(var t="",r=0,a=e.length;rA-Z\\x5E-\\x7E]",'[\\"\\\\]')),oe=new RegExp(w,"g"),j=new RegExp("(?:(?:%[EFef][0-9A-Fa-f]%[0-9A-Fa-f][0-9A-Fa-f]%[0-9A-Fa-f][0-9A-Fa-f])|(?:%[89A-Fa-f][0-9A-Fa-f]%[0-9A-Fa-f][0-9A-Fa-f])|(?:%[0-9A-Fa-f][0-9A-Fa-f]))","g"),se=new RegExp(P("[^]","[A-Za-z0-9\\!\\$\\%\\'\\*\\+\\-\\^\\_\\`\\{\\|\\}\\~]","[\\.]",'[\\"]',E),"g"),ne=new RegExp(P("[^]",w,"[\\!\\$\\'\\(\\)\\*\\+\\,\\;\\:\\@]"),"g"),ie=ne;function N(e){var t=d(e);return t.match(oe)?t:e}var E={scheme:"mailto",parse:function(e,t){var r=e,a=r.to=r.path?r.path.split(","):[];if(r.path=void 0,r.query){for(var o=!1,s={},n=r.query.split("&"),i=0,c=n.length;ithis.addVocabulary(e)),this.opts.discriminator&&this.addKeyword(s.default)}_addDefaultMetaSchema(){super._addDefaultMetaSchema();var{$data:e,meta:t}=this.opts;t&&(n.default.call(this,e),this.refs["http://json-schema.org/schema"]=i)}defaultMeta(){return this.opts.defaultMeta=super.defaultMeta()||(this.getSchema(i)?i:void 0)}}t.exports=r=c,Object.defineProperty(r,"__esModule",{value:!0}),r.default=c;var l=e("./compile/validate"),d=(Object.defineProperty(r,"KeywordCxt",{enumerable:!0,get:function(){return l.KeywordCxt}}),e("./compile/codegen")),u=(Object.defineProperty(r,"_",{enumerable:!0,get:function(){return d._}}),Object.defineProperty(r,"str",{enumerable:!0,get:function(){return d.str}}),Object.defineProperty(r,"stringify",{enumerable:!0,get:function(){return d.stringify}}),Object.defineProperty(r,"nil",{enumerable:!0,get:function(){return d.nil}}),Object.defineProperty(r,"Name",{enumerable:!0,get:function(){return d.Name}}),Object.defineProperty(r,"CodeGen",{enumerable:!0,get:function(){return d.CodeGen}}),e("./runtime/validation_error")),m=(Object.defineProperty(r,"ValidationError",{enumerable:!0,get:function(){return u.default}}),e("./compile/ref_error"));Object.defineProperty(r,"MissingRefError",{enumerable:!0,get:function(){return m.default}})},{"./compile/codegen":2,"./compile/ref_error":7,"./compile/validate":15,"./core":18,"./refs/json-schema-2020-12":20,"./runtime/validation_error":32,"./vocabularies/discriminator":55,"./vocabularies/draft2020":57}]},{},[])("2020")});
\ No newline at end of file
diff --git a/src/pretix/static/pretixcontrol/js/ui/editor.js b/src/pretix/static/pretixcontrol/js/ui/editor.js
index ddb1687bb..da4509eb8 100644
--- a/src/pretix/static/pretixcontrol/js/ui/editor.js
+++ b/src/pretix/static/pretixcontrol/js/ui/editor.js
@@ -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;
+ })
}
};
diff --git a/src/pretix/static/schema/pdf-layout.schema.json b/src/pretix/static/schema/pdf-layout.schema.json
new file mode 100644
index 000000000..d205881fc
--- /dev/null
+++ b/src/pretix/static/schema/pdf-layout.schema.json
@@ -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
+ }
+ ]
+ }
+}
diff --git a/src/tests/plugins/ticketoutputpdf/test_api.py b/src/tests/plugins/ticketoutputpdf/test_api.py
index 9eea0b7b3..f22f84229 100644
--- a/src/tests/plugins/ticketoutputpdf/test_api.py
+++ b/src/tests/plugins/ticketoutputpdf/test_api.py
@@ -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()