diff --git a/doc/_templates/index.html b/doc/_templates/index.html index b075e43eaa..c6964b43d5 100644 --- a/doc/_templates/index.html +++ b/doc/_templates/index.html @@ -38,6 +38,22 @@
+
+
+ + + +
+
+ + REST API + +

+ Documentation and reference of the RESTful API exposed by pretix for interaction with external + components. +

+
+
@@ -52,6 +68,7 @@ pretix.

+
@@ -65,7 +82,6 @@

Documentation and details on plugins that ship with pretix or are officially supported.

-
diff --git a/doc/_themes/pretix_theme/static/css/pretix.css b/doc/_themes/pretix_theme/static/css/pretix.css index 2ac7709877..06955154ce 100644 --- a/doc/_themes/pretix_theme/static/css/pretix.css +++ b/doc/_themes/pretix_theme/static/css/pretix.css @@ -4183,11 +4183,15 @@ input[type="radio"][disabled], input[type="checkbox"][disabled] { } .wy-table td, .rst-content table.docutils td, .rst-content table.field-list td, .wy-table th, .rst-content table.docutils th, .rst-content table.field-list th { - font-size: 90%; + font-size: 14px; margin: 0; overflow: visible; padding: 8px 16px } +.rst-content table td p, .rst-content .section table td ul { + font-size: 14px; + margin-bottom: 12px; +} .wy-table td:first-child, .rst-content table.docutils td:first-child, .rst-content table.field-list td:first-child, .wy-table th:first-child, .rst-content table.docutils th:first-child, .rst-content table.field-list th:first-child { border-left-width: 0 @@ -6052,3 +6056,10 @@ url('../opensans_regular_macroman/OpenSans-Regular-webfont.svg#open_sansregular' float: none; } } + +/* REST */ +@media screen and (min-width: 480px) { + .wy-table-responsive table.rest-resource-table td, .wy-table-responsive table.rest-resource-table th { + white-space: normal; + } +} diff --git a/doc/api/fundamentals.rst b/doc/api/fundamentals.rst new file mode 100644 index 0000000000..fa0f1b66ef --- /dev/null +++ b/doc/api/fundamentals.rst @@ -0,0 +1,145 @@ +Basic concepts +============== + +This page describes basic concepts and definition that you need to know to interact +with pretix' REST API, such as authentication, pagination and similar definitions. + +Obtaining an API token +---------------------- + +To authenticate your API requests, you need to obtain an API token. You can create a +token in the pretix web interface on the level of organizer teams. Create a new team +or choose an existing team that has the level of permissions the token should have and +create a new token using the form below the list of team members: + +.. image:: img/token_form.png + +You can enter a description for the token to distinguish from other tokens later on. +Once you click "Add", you will be provided with an API token in the success message. +Copy this token, as you won't be able to retrieve it again. + +.. image:: img/token_success.png + +Authentication +-------------- + +You need to include the API token with every request to pretix' API in the ``Authorization`` header +like the following: + +.. sourcecode:: http + :emphasize-lines: 3 + + GET /api/v1/organizers/ HTTP/1.1 + Host: pretix.eu + Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k + +.. note:: The API currently also supports authentication via browser sessions, i.e. the + same way that you authenticate with pretix when using the browser interface. + Using this type of authentication is *not* officially supported for use by + third-party clients and might change or be removed at any time. We plan on + 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`_. + +Compatibility +------------- + +We currently see pretix' API as a beta-stage feature. We therefore do not give any guarantees +for compatibility between feature releases of pretix (such as 1.5 and 1.6). However, as always, +we try not to break things when we don't need to. Any backwards-incompatible changes will be +prominently noted in the release notes. + +We treat the following types of changes as *backwards-compatible* so we ask you to make sure +that your clients can deal with them properly: + +* Support of new API endpoints +* Support of new HTTP methods for a given API endpoint +* Support of new query parameters for a given API endpoint +* New fields contained in API responses + +We treat the following types of changes as *backwards-incompatible*: + +* Type changes of fields in API responses +* New required input fields for an API endpoint +* New required type for input fields of an API endpoint +* Removal of endpoints, API methods or fields + +Pagination +---------- + +Most lists of objects returned by pretix' API will be paginated. The response will take +the form of: + +.. sourcecode:: javascript + + { + "count": 117, + "next": "https://pretix.eu/api/v1/organizers/?page=2", + "previous": null, + "results": […], + } + +As you can see, the response contains the total number of results in the field ``count``. +The fields ``next`` and ``previous`` contain links to the next and previous page of results, +respectively, or ``null`` if there is no such page. You can use those URLs to retrieve the +respective page. + +The field ``results`` contains a list of objects representing the first results. For most +objects, every page contains 50 results. + +Errors +------ + +Error responses (of type 400-499) are returned in one of the following forms, depending on +the type of error. General errors look like: + +.. sourcecode:: http + + HTTP/1.1 405 Method Not Allowed + Content-Type: application/json + Content-Length: 42 + + {"detail": "Method 'DELETE' not allowed."} + +Field specific input errors include the name of the offending fields as keys in the response: + +.. sourcecode:: http + + HTTP/1.1 400 Bad Request + Content-Type: application/json + Content-Length: 94 + + {"amount": ["A valid integer is required."], "description": ["This field may not be blank."]} + + +Data types +---------- + +All structured API responses are returned in JSON format using standard JSON data types such +as integers, floating point numbers, strings, lists, objects and booleans. Most fields can +be ``null`` as well. + +The following table shows some data types that have no native JSON representation and how +we serialize them to JSON. + +===================== ============================ =================================== +Internal pretix type JSON representation Examples +===================== ============================ =================================== +Datetime String in ISO 8601 format ``"2017-12-27T10:00:00Z"`` + with timezone (normally UTC) ``"2017-12-27T10:00:00.596934Z"``, + ``"2017-12-27T10:00:00+02:00"`` +Date String in ISO 8601 format ``2017-12-27`` +Multi-lingual string Object of strings ``{"en": "red", "de": "rot", "de_Informal": "rot"}`` +Money String with decimal number ``"23.42"`` +Currency String with ISO 4217 code ``"EUR"``, ``"USD"`` +===================== ============================ =================================== + +Query parameters +^^^^^^^^^^^^^^^^ + +Most list endpoints allow a filtering of the results using query parameters. In this case, booleans should be passed +as the string values ``true`` and ``false``. + +If the ``ordering`` parameter is documented for a resource, you can use it to sort the result set by one of the allowed +fields. Prepend a ``-`` to the field name to reverse the sort order. + +.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax \ No newline at end of file diff --git a/doc/api/img/token_form.png b/doc/api/img/token_form.png new file mode 100644 index 0000000000..f4f24a1c7c Binary files /dev/null and b/doc/api/img/token_form.png differ diff --git a/doc/api/img/token_success.png b/doc/api/img/token_success.png new file mode 100644 index 0000000000..c0551d2fc9 Binary files /dev/null and b/doc/api/img/token_success.png differ diff --git a/doc/api/index.rst b/doc/api/index.rst new file mode 100644 index 0000000000..06fa8711d4 --- /dev/null +++ b/doc/api/index.rst @@ -0,0 +1,17 @@ +.. _`rest-api`: + +REST API +======== + +This part of the documentation contains information about the REST-style API +exposed by pretix since version 1.5 that can be used by third-party programs +to interact with pretix and its data structures. + +Currently, the API provides mostly read-only capabilities, but it will be extended +in functionality over time. + +.. toctree:: + :maxdepth: 2 + + fundamentals + resources/index diff --git a/doc/api/resources/categories.rst b/doc/api/resources/categories.rst new file mode 100644 index 0000000000..d0f224685e --- /dev/null +++ b/doc/api/resources/categories.rst @@ -0,0 +1,108 @@ +Item categories +=============== + +Resource description +-------------------- + +Categories provide grouping for items (better known as products). +The category resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the category +name multi-lingual string The category's visible name +description multi-lingual string A public description (might include markdown, can + be ``null``) +position integer An integer, used for sorting the categories +is_addon boolean If ``True``, items within this category are not on sale + on their own but the category provides a source for + defining add-ons for other products. +===================================== ========================== ======================================================= + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/categories/ + + Returns a list of all categories within a given event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/categories/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "name": {"en": "Tickets"}, + "description": {"en": "Tickets are what you need to get in."}, + "position": 1, + "is_addon": false + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query boolean is_addon: If set to ``true`` or ``false``, only categories with this value for the field ``is_addon`` will be + returned. + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``. + Default: ``position`` + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/categories/(id)/ + + Returns information on one category, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/categories/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "id": 1, + "name": {"en": "Tickets"}, + "description": {"en": "Tickets are what you need to get in."}, + "position": 1, + "is_addon": false + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``id`` field of the category to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst new file mode 100644 index 0000000000..5cd61772cd --- /dev/null +++ b/doc/api/resources/events.rst @@ -0,0 +1,118 @@ +Events +====== + +Resource description +-------------------- + +The event resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +name multi-lingual string The event's full name +slug string A short form of the name, used e.g. in URLs. +live boolean If ``true``, the event ticket shop is publicly + available. +currency string The currency this event is handled in. +date_from datetime The event's start date +date_to datetime The event's end date (or ``null``) +date_admission datetime The event's admission date (or ``null``) +is_public boolean If ``true``, the event shows up in places like the + organizer's public list of events +presale_start datetime The date at which the ticket shop opens (or ``null``) +presale_end datetime The date at which the ticket shop closes (or ``null``) +location multi-lingual string The event location (or ``null``) +===================================== ========================== ======================================================= + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/ + + Returns a list of all events within a given organizer the authenticated user/token has access to. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "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": null, + "presale_start": null, + "presale_end": null, + "location": null, + } + ] + } + + :query page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of a valid organizer + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view it. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/ + + Returns information on one event, identified by its slug. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "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, + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :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. diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst new file mode 100644 index 0000000000..98e9882819 --- /dev/null +++ b/doc/api/resources/index.rst @@ -0,0 +1,16 @@ +Resources and endpoints +======================= + +.. toctree:: + :maxdepth: 2 + + organizers + events + categories + items + questions + quotas + orders + invoices + vouchers + waitinglist diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst new file mode 100644 index 0000000000..7d6b83dcaa --- /dev/null +++ b/doc/api/resources/invoices.rst @@ -0,0 +1,187 @@ +Invoices +======== + +Resource description +-------------------- + +The invoice resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +invoice_no string Invoice number (without prefix) +order string Order code of the order this invoice belongs to +is_cancellation boolean ``True``, if this invoice is the cancellation of a + different invoice. +invoice_from string Sender address +invoice_to string Receiver address +date date Invoice date +refers string Invoice number of an invoice this invoice refers to + (for example a cancellation refers to the invoice it + cancels) or ``null``. +locale string Invoice locale +introductory_text string Text to be printed above the product list +additional_text string Text to be printed below the product list +payment_provider_text string Text to be printed below the product list with + payment information +footer_text string Text to be printed in the page footer area +lines list of objects The actual invoice contents +├ description string Text representing the invoice line (e.g. product name) +├ gross_value money (string) Price including VAT +├ tax_value money (string) VAT amount +└ tax_rate decimal (string) Used VAT rate +===================================== ========================== ======================================================= + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/ + + Returns a list of all invoices within a given event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/invoices/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "invoice_no": "00001", + "order": "ABC12", + "is_cancellation": false, + "invoice_from": "Big Events LLC\nDemo street 12\nDemo town", + "invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT ID: EU123456789", + "date": "2017-12-01", + "refers": null, + "locale": "en", + "introductory_text": "thank you for your purchase of the following items:", + "additional_text": "We are looking forward to see you on our conference!", + "payment_provider_text": "Please transfer the money to our account ABC…", + "footer_text": "Big Events LLC - Registration No. 123456 - VAT ID: EU0987654321", + "lines": [ + { + "description": "Budget Ticket", + "gross_value": "23.00", + "tax_value": "0.00", + "tax_rate": "0.00" + } + ] + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query boolean is_cancellation: If set to ``true`` or ``false``, only invoices with this value for the field + ``is_cancellation`` will be returned. + :query string order: If set, only invoices belonging to the order with the given order code will be returned. + :query string refers: If set, only invoices refering to the given invoice will be returned. + :query string locale: If set, only invoices with the given locale will be returned. + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``date`` and + ``invoice_no``. Default: ``invoice_no`` + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/ + + Returns information on one invoice, identified by its invoice number. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "invoice_no": "00001", + "order": "ABC12", + "is_cancellation": false, + "invoice_from": "Big Events LLC\nDemo street 12\nDemo town", + "invoice_to": "Sample company\nJohn Doe\nTest street 12\n12345 Testington\nTestikistan\nVAT ID: EU123456789", + "date": "2017-12-01", + "refers": null, + "locale": "en", + "introductory_text": "thank you for your purchase of the following items:", + "additional_text": "We are looking forward to see you on our conference!", + "payment_provider_text": "Please transfer the money to our account ABC…", + "footer_text": "Big Events LLC - Registration No. 123456 - VAT ID: EU0987654321", + "lines": [ + { + "description": "Budget Ticket", + "gross_value": "23.00", + "tax_value": "0.00", + "tax_rate": "0.00" + } + ] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param invoice_no: The ``invoice_no`` field of the invoice to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/download/ + + Download an invoice in PDF format. + + Note that in some cases the PDF file might not yet have been created. In that case, you will receive a status + code :http:statuscode:`409` and you are expected to retry the request after a short period of waiting. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/invoices/00001/download/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/pdf + + ... + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param invoice_no: The ``invoice_no`` field of the invoice to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting vor a few + seconds. diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst new file mode 100644 index 0000000000..3737c68e3f --- /dev/null +++ b/doc/api/resources/items.rst @@ -0,0 +1,228 @@ +Items +===== + +Resource description +-------------------- + +Items (better known as products) are the things that can be sold using pretix. +The item resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the item +name multi-lingual string The item's visible name +default_price money (string) The item price that is applied if the price is not + overwritten by variations or other options. +category integer The ID of the category this item belongs to + (or ``null``). +active boolean If ``False``, the item is hidden from all public lists + and will not be sold. +description multi-lingual string A public description of the item. May contain Markdown + syntax or can be ``null``. +free_price boolean If ``True``, customers can change the price at which + they buy the product (however, the price can't be set + lower than the price defined by ``default_price`` or + otherwise). +tax_rate decimal (string) The VAT rate to be applied for this item. +admission boolean ``True`` for items that grant admission to the event + (such as primary tickets) and ``False`` for others + (such as add-ons or merchandise). +position integer An integer, used for sorting +picture string A product picture to be displayed in the shop +available_from datetime The first date time at which this item can be bought + (or ``null``). +available_until datetime The last date time at which this item can be bought + (or ``null``). +require_voucher boolean If ``True``, this item can only be bought using a + voucher that is specifically assigned to this item. +hide_without_voucher boolean If ``True``, this item is only shown during the voucher + redemption process, but not in the normal shop + frontend. +allow_cancel boolean If ``False``, customers cannot cancel orders containing + this item. +min_per_order integer This product can only be bought if it is included at + least this many times in the order (or ``null`` for no + limitation). +max_per_order integer This product can only be bought if it is included at + most this many times in the order (or ``null`` for no + limitation). +has_variations boolean Shows whether or not this item has variations + (read-only). +variations list of objects A list with one object for each variation of this item. + Can be empty. +├ id integer Internal ID of the variation +├ default_price money (string) The price set directly for this variation or ``null`` +├ price money (string) The price used for this variation. This is either the + same as ``default_price`` if that value is set or equal + to the item's ``default_price``. +├ active boolean If ``False``, this variation will not be sold or shown. +├ description multi-lingual string A public description of the variation. May contain + Markdown syntax or can be ``null``. +└ position integer An integer, used for sorting +addons list of objects Definition of add-ons that can be chosen for this item +├ addon_category integer Internal ID of the item category the add-on can be + chosen from. +├ min_count integer The minimal number of add-ons that need to be chosen. +├ max_count integer The maxima number of add-ons that can be chosen. +└ position integer An integer, used for sorting +===================================== ========================== ======================================================= + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/ + + Returns a list of all items within a given event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/items/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "name": {"en": "Standard ticket"}, + "default_price": "23.00", + "category": null, + "active": true, + "description": null, + "free_price": false, + "tax_rate": "0.00", + "admission": false, + "position": 0, + "picture": null, + "available_from": null, + "available_until": null, + "require_voucher": false, + "hide_without_voucher": false, + "allow_cancel": true, + "min_per_order": null, + "max_per_order": null, + "has_variations": false, + "variations": [ + { + "value": {"en": "Student"}, + "default_price": "10.00", + "price": "10.00", + "active": true, + "description": null, + "position": 0 + }, + { + "value": {"en": "Regular"}, + "default_price": null, + "price": "23.00", + "active": true, + "description": null, + "position": 1 + } + ], + "addons": [] + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query boolean active: If set to ``true`` or ``false``, only items with this value for the field ``active`` will be + returned. + :query integer category: If set to the ID of a category, only items within that category will be returned. + :query boolean admission: If set to ``true`` or ``false``, only items with this value for the field ``admission`` + will be returned. + :query string tax_rate: If set to a decimal value, only items with this tax rate will be returned. + :query boolean free_price: If set to ``true`` or ``false``, only items with this value for the field ``free_price`` + will be returned. + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``. + Default: ``position`` + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/ + + Returns information on one item, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/items/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "id": 1, + "name": {"en": "Standard ticket"}, + "default_price": "23.00", + "category": null, + "active": true, + "description": null, + "free_price": false, + "tax_rate": "0.00", + "admission": false, + "position": 0, + "picture": null, + "available_from": null, + "available_until": null, + "require_voucher": false, + "hide_without_voucher": false, + "allow_cancel": true, + "min_per_order": null, + "max_per_order": null, + "has_variations": false, + "variations": [ + { + "value": {"en": "Student"}, + "default_price": "10.00", + "price": "10.00", + "active": true, + "description": null, + "position": 0 + }, + { + "value": {"en": "Regular"}, + "default_price": null, + "price": "23.00", + "active": true, + "description": null, + "position": 1 + } + ], + "addons": [] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``id`` field of the item to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst new file mode 100644 index 0000000000..d42d96058f --- /dev/null +++ b/doc/api/resources/orders.rst @@ -0,0 +1,485 @@ +Orders +====== + +Order resource +-------------- + +The order resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +code string Order code +status string Order status, one of: + + * ``n`` – pending + * ``p`` – paid + * ``e`` – expired + * ``c`` – canceled + * ``r`` – refunded +secret string The secret contained in the link sent to the customer +email string The customer email address +locale string The locale used for communication with this customer +datetime datetime Time of order creation +expires datetime The order will expire, if it is still pending by this time +payment_date date Date of payment receival +payment_provider string Payment provider used for this order +payment_fee money (string) Payment fee included in this order's total +payment_fee_tax_rate decimal (string) VAT rate applied to the payment fee +payment_fee_tax_value money (string) VAT value included in the payment fee +total money (string) Total value of this order +comment string Internal comment on this order +invoice_address object Invoice address information (can be ``null``) +├ last_modified datetime Last modification date of the address +├ company string Customer company name +├ name string Customer name +├ street string Customer street +├ zipcode string Customer ZIP code +├ city string Customer city +├ country string Customer country +└ vat_id string Customer VAT ID +position list of objects List of order positions (see below) +downloads list of objects List of ticket download options for order-wise ticket + downloading. This might be a multi-page PDF or a ZIP + file of tickets for outputs that do not support + multiple tickets natively. See also order position + download options. +├ output string Ticket output provider (e.g. ``pdf``, ``passbook``) +└ url string Download URL +===================================== ========================== ======================================================= + +Order position resource +----------------------- + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the order positon +code string Order code of the order the position belongs to +positionid integer Number of the position within the order +item integer ID of the purchased item +variation integer ID of the purchased variation (or ``null``) +price money (string) Price of this position +attendee_name string Specified attendee name for this position (or ``null``) +attendee_email string Specified attendee email address for this position (or ``null``) +voucher integer Internal ID of the voucher used for this position (or ``null``) +tax_rate decimal (string) VAT rate applied for this position +tax_value money (string) VAT included in this position +secret string Secret code printed on the tickets for validation +addon_to integer Internal ID of the position this position is an add-on for (or ``null``) +checkins list of objects List of check-ins with this ticket +└ datetime datetime Time of check-in +downloads list of objects List of ticket download options +├ output string Ticket output provider (e.g. ``pdf``, ``passbook``) +└ url string Download URL +===================================== ========================== ======================================================= + + +Order endpoints +--------------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/ + + Returns a list of all orders within a given event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/orders/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "code": "ABC12", + "status": "p", + "secret": "k24fiuwvu8kxz3y1", + "email": "tester@example.org", + "locale": "en", + "datetime": "2017-12-01T10:00:00Z", + "expires": "2017-12-10T10:00:00Z", + "payment_date": "2017-12-05", + "payment_provider": "banktransfer", + "payment_fee": "0.00", + "payment_fee_tax_rate": "0.00", + "payment_fee_tax_value": "0.00", + "total": "23.00", + "comment": "", + "invoice_address": { + "last_modified": "2017-12-01T10:00:00Z", + "company": "Sample company", + "name": "John Doe", + "street": "Test street 12", + "zipcode": "12345", + "city": "Testington", + "country": "Testikistan", + "vat_id": "EU123456789" + }, + "positions": [ + { + "id": 23442, + "order": "ABC12", + "positionid": 1, + "item": 1345, + "variation": null, + "price": "23.00", + "attendee_name": "Peter", + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", + "addon_to": null, + "checkins": [ + { + "datetime": "2017-12-25T12:45:23Z" + } + ], + "downloads": [ + { + "output": "pdf", + "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/" + } + ] + } + ], + "downloads": [ + { + "output": "pdf", + "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/download/pdf/" + } + ] + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``code`` and + ``status``. Default: ``datetime`` + :query string code: Only return orders that match the given order code + :query string status: Only return orders in the given order status (see above) + :query string email: Only return orders created with the given email address + :query string locale: Only return orders with the given customer locale + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/ + + Returns information on one order, identified by its order code. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "code": "ABC12", + "status": "p", + "secret": "k24fiuwvu8kxz3y1", + "email": "tester@example.org", + "locale": "en", + "datetime": "2017-12-01T10:00:00Z", + "expires": "2017-12-10T10:00:00Z", + "payment_date": "2017-12-05", + "payment_provider": "banktransfer", + "payment_fee": "0.00", + "payment_fee_tax_rate": "0.00", + "payment_fee_tax_value": "0.00", + "total": "23.00", + "comment": "", + "invoice_address": { + "last_modified": "2017-12-01T10:00:00Z", + "company": "Sample company", + "name": "John Doe", + "street": "Test street 12", + "zipcode": "12345", + "city": "Testington", + "country": "Testikistan", + "vat_id": "EU123456789" + }, + "positions": [ + { + "id": 23442, + "order": "ABC12", + "positionid": 1, + "item": 1345, + "variation": null, + "price": "23.00", + "attendee_name": "Peter", + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", + "addon_to": null, + "checkins": [ + { + "datetime": "2017-12-25T12:45:23Z" + } + ], + "downloads": [ + { + "output": "pdf", + "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/" + } + ] + } + ], + "downloads": [ + { + "output": "pdf", + "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/download/pdf/" + } + ] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param code: The ``code`` field of the order to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/download/(output)/ + + Download tickets for an order, identified by its order code. Depending on the chosen output, the response might + be a ZIP file, PDF file or something else. The order details response contains a list of output options for this + partictular order. + + Tickets can be only downloaded if the order is paid and if ticket downloads are active. Note that in some cases the + ticket file might not yet have been created. In that case, you will receive a status code :http:statuscode:`409` and + you are expected to retry the request after a short period of waiting. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/download/pdf/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/pdf + + ... + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param code: The ``code`` field of the order to fetch + :param output: The internal name of the output provider to use + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource + **or** downlodas are not available for this order at this time. The response content will + contain more details. + :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting vor a few + seconds. + + +Order position endpoints +------------------------ + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/ + + Returns a list of all order positions within a given event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 23442, + "order": "ABC12", + "positionid": 1, + "item": 1345, + "variation": null, + "price": "23.00", + "attendee_name": "Peter", + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", + "addon_to": null, + "checkins": [ + { + "datetime": "2017-12-25T12:45:23Z" + } + ], + "downloads": [ + { + "output": "pdf", + "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/" + } + ] + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``order__code``, + ``order__datetime``, ``positionid``, ``attendee_name``, and ``order__status``. Default: + ``order__datetime,positionid`` + :query string order: Only return positions of the order with the given order code + :query integer item: Only return positions with the purchased item matching the given ID. + :query integer variation: Only return positions with the purchased item variation matching the given ID. + :query string attendee_name: Only return positions with the given value in the attendee_name field. Also, add-on + products positions are shown if they refer to an attendee with the given name. + :query string secret: Only return positions with the given ticket secret. + :query string order__status: Only return positions with the given order status. + :query bollean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been + checked in already. + :query integer addon_to: Only return positions that are add-ons to the position with the given ID. + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/ + + Returns information on one order position, identified by its internal ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "id": 23442, + "order": "ABC12", + "positionid": 1, + "item": 1345, + "variation": null, + "price": "23.00", + "attendee_name": "Peter", + "attendee_email": null, + "voucher": null, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", + "addon_to": null, + "checkins": [ + { + "datetime": "2017-12-25T12:45:23Z" + } + ], + "downloads": [ + { + "output": "pdf", + "url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/" + } + ] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``id`` field of the order position to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/download/(output)/ + + Download tickets for one order position, identified by its internal ID. + Depending on the chosen output, the response might be a ZIP file, PDF file or something else. The order details + response contains a list of output options for this partictular order position. + + Tickets can be only downloaded if the order is paid and if ticket downloads are active. Also, depending on event + configuration downloads might be only unavailable for add-on products or non-admission products. + Note that in some cases the ticket file might not yet have been created. In that case, you will receive a status + code :http:statuscode:`409` and you are expected to retry the request after a short period of waiting. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/download/pdf/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/pdf + + ... + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``id`` field of the order position to fetch + :param output: The internal name of the output provider to use + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource + **or** downlodas are not available for this order position at this time. The response content will + contain more details. + :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting vor a few + seconds. diff --git a/doc/api/resources/organizers.rst b/doc/api/resources/organizers.rst new file mode 100644 index 0000000000..d24374a7ce --- /dev/null +++ b/doc/api/resources/organizers.rst @@ -0,0 +1,90 @@ +Organizers +========== + +Resource description +-------------------- + +An organizers is an entity running any number of events. In pretix, every event belongs to one +organizer and various settings, such as teams and permissions, are managed on organizer level. + +The organizer resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +name string The organizer's full name, i.e. the name of an + organization or company. +slug string A short form of the name, used e.g. in URLs. +===================================== ========================== ======================================================= + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/ + + Returns a list of all organizers the authenticated user/token has access to. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "name": "Big Events LLC", + "slug": "Big Events", + } + ] + } + + :query page: The page number in case of a multi-page result set, default is 1 + :statuscode 200: no error + :statuscode 401: Authentication failure + +.. http:get:: /api/v1/organizers/(organizer)/ + + Returns information on one organizer account, identified by its slug. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "name": "Big Events LLC", + "slug": "Big Events", + } + + :param organizer: The ``slug`` field of the organizer to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view it. diff --git a/doc/api/resources/questions.rst b/doc/api/resources/questions.rst new file mode 100644 index 0000000000..d20e11217f --- /dev/null +++ b/doc/api/resources/questions.rst @@ -0,0 +1,145 @@ +Questions +========= + +Resource description +-------------------- + +Questions define additional fields that need to be filled out by customers during checkout. +The question resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the question +question multi-lingual string The field label shown to the customer +type string The expected type of answer. Valid options: + + * ``N`` – number + * ``S`` – one-line string + * ``T`` – multi-line string + * ``B`` – boolean + * ``C`` – choice from a list + * ``M`` – multiple choice from a list +required boolean If ``True``, the question needs to be filled out. +position integer An integer, used for sorting +items list of integers List of item IDs this question is assigned to. +options list of objects In case of question type ``C`` or ``M``, this lists the + available objects. +├ id integer Internal ID of the option +└ answer multi-lingual string The displayed value of this option +===================================== ========================== ======================================================= + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/questions/ + + Returns a list of all questions within a given event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/questions/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "question": {"en": "T-Shirt size"}, + "type": "C", + "required": false, + "items": [1, 2], + "position": 1, + "options": [ + { + "id": 1, + "answer": {"en": "S"} + }, + { + "id": 2, + "answer": {"en": "M"} + }, + { + "id": 3, + "answer": {"en": "L"} + } + ] + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``. + Default: ``position`` + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/questions/(id)/ + + Returns information on one question, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/questions/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "id": 1, + "question": {"en": "T-Shirt size"}, + "type": "C", + "required": false, + "items": [1, 2], + "position": 1, + "options": [ + { + "id": 1, + "answer": {"en": "S"} + }, + { + "id": 2, + "answer": {"en": "M"} + }, + { + "id": 3, + "answer": {"en": "L"} + } + ] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``id`` field of the question to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. diff --git a/doc/api/resources/quotas.rst b/doc/api/resources/quotas.rst new file mode 100644 index 0000000000..e845dcacd6 --- /dev/null +++ b/doc/api/resources/quotas.rst @@ -0,0 +1,103 @@ +Quotas +====== + +Resource description +-------------------- + +Questions define how many times an item can be sold. +The quota resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the quota +name string The internal name of the quota +size integer The size of the quota or ``null`` for unlimited +items list of integers List of item IDs this quota acts on. +variations list of integers List of item variation IDs this quota acts on. +===================================== ========================== ======================================================= + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/quotas/ + + Returns a list of all quotas within a given event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/quotas/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "name": "Ticket Quota", + "size": 200, + "items": [1, 2], + "variations": [1, 4, 5, 7] + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``id`` and ``position``. + Default: ``position`` + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/quotas/(id)/ + + Returns information on one question, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/quotas/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "id": 1, + "name": "Ticket Quota", + "size": 200, + "items": [1, 2], + "variations": [1, 4, 5, 7] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``id`` field of the quota to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. diff --git a/doc/api/resources/vouchers.rst b/doc/api/resources/vouchers.rst new file mode 100644 index 0000000000..e875ede72f --- /dev/null +++ b/doc/api/resources/vouchers.rst @@ -0,0 +1,160 @@ +Vouchers +======== + +Resource description +-------------------- + +The voucher resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the voucher +code string The voucher code that is required to redeem the voucher +max_usages integer The maximum number of times this voucher can be + redeemed (default: 1). +redeemed integer The number of times this voucher already has been + redeemed. +valid_until datetime The voucher expiration date (or ``null``). +block_quota boolean If ``True``, quota is blocked for this voucher. +allow_ignore_quota boolean If ``True``, this voucher can be redeemed even if a + product is sold out and even if quota is not blocked + for this voucher. +price_mode string Determines how this voucher affects product prices. + Possible values: + + * ``none`` – No effect on price + * ``set`` – The product price is set to the given ``value`` + * ``subtract`` – The product price is determined by the original price *minus* the given ``value`` + * ``percent`` – The product price is determined by the original price reduced by the percentage given in ``value`` +value decimal (string) The value (see ``price_mode``) +item integer An ID of an item this voucher is restricted to (or ``null``) +variation integer An ID of a variation this voucher is restricted to (or ``null``) +quota integer An ID of a quota this voucher is restricted to (or + ``null``). This is an exclusive alternative to + ``item`` and ``variation``: A voucher can be + attached either to a specific product or to all + products within one quota or it can be available + for all items without restriction. +tag string A string that is used for grouping vouchers +comment string An internal comment on the voucher +===================================== ========================== ======================================================= + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/vouchers/ + + Returns a list of all vouchers within a given event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/vouchers/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "code": "43K6LKM37FBVR2YG", + "max_usages": 1, + "redeemed": 0, + "valid_until": null, + "block_quota": false, + "allow_ignore_quota": false, + "price_mode": "set", + "value": "12.00", + "item": 1, + "variation": null, + "quota": null, + "tag": "testvoucher", + "comment": "" + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string code: Only show the voucher with the given voucher code. + :query integer max_usages: Only show vouchers with the given maximal number of usages. + :query integer redeemed: Only show vouchers with the given number of redemptions. Note that this doesn't tell you if + the voucher can still be redeemed, as this also depends on ``max_usages``. See the + ``active`` query parameter as well. + :query boolean block_quota: If set to ``true`` or ``false``, only vouchers with this value in the field + ``block_quota`` will be shown. + :query boolean allow_ignore_quota: If set to ``true`` or ``false``, only vouchers with this value in the field + ``allow_ignore_quota`` will be shown. + :query string price_mode: If set, only vouchers with this value in the field ``price_mode`` will be shown (see + above). + :query string value: If set, only vouchers with this value in the field ``value`` will be shown. + :query integer item: If set, only vouchers attached to the item with the given ID will be shown. + :query integer variation: If set, only vouchers attached to the variation with the given ID will be shown. + :query integer quota: If set, only vouchers attached to the quota with the given ID will be shown. + :query string tag: If set, only vouchers with the given tag will be shown. + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``id``, ``code``, + ``max_usages``, ``valid_until``, and ``value``. Default: ``id`` + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/vouchers/(id)/ + + Returns information on one voucher, identified by its internal ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/vouchers/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "id": 1, + "code": "43K6LKM37FBVR2YG", + "max_usages": 1, + "redeemed": 0, + "valid_until": null, + "block_quota": false, + "allow_ignore_quota": false, + "price_mode": "set", + "value": "12.00", + "item": 1, + "variation": null, + "quota": null, + "tag": "testvoucher", + "comment": "" + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``id`` field of the voucher to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. diff --git a/doc/api/resources/waitinglist.rst b/doc/api/resources/waitinglist.rst new file mode 100644 index 0000000000..653947f663 --- /dev/null +++ b/doc/api/resources/waitinglist.rst @@ -0,0 +1,119 @@ +Waiting list entries +==================== + +Resource description +-------------------- + +The waiting list entry resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the waiting list entry +created datetime Creation date of the waiting list entry +email string Email address of the user on the waiting list +voucher integer Internal ID of the voucher sent to this user. If + this field is set, the user has been sent a voucher + and is no longer waiting. If it is ``null``, the + user is still waiting. +item integer An ID of an item the user is waiting to be available + again +variation integer An ID of a variation the user is waiting to be + available again (or ``null``) +locale string Locale of the waiting user +===================================== ========================== ======================================================= + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/waitinglistentries/ + + Returns a list of all waiting list entries within a given event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/waitinglistentries/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "created": "2017-12-01T10:00:00Z", + "email": "waiting@example.org", + "voucher": null, + "item": 2, + "variation": null, + "locale": "en" + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query string email: Only show waiting list entries created with the given email address. + :query string locale: Only show waiting list entries created with the given locale. + :query boolean has_voucher: If set to ``true`` or ``false``, only waiting list entries are returned that have or + have not been sent a voucher. + :query integer item: If set, only entries of users waiting for the item with the given ID will be shown. + :query integer variation: If set, only entries of users waiting for the variation with the given ID will be shown. + :query string ordering: Manually set the ordering of results. Valid fields to be used are ``id``, ``created``, + ``email``, ``item``. Default: ``created`` + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/waitinglistentries/(id)/ + + Returns information on one waiting list entry, identified by its internal ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/waitinglistentries/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: text/javascript + + { + "id": 1, + "created": "2017-12-01T10:00:00Z", + "email": "waiting@example.org", + "voucher": null, + "item": 2, + "variation": null, + "locale": "en" + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``id`` field of the waiting list entry to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. diff --git a/doc/conf.py b/doc/conf.py index d2108628f4..cb2cea407f 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -24,7 +24,7 @@ from datetime import date sys.path.insert(0, os.path.abspath('../src')) import django -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.testutils.settings") django.setup() # -- General configuration ------------------------------------------------ diff --git a/doc/contents.rst b/doc/contents.rst index 75b4fecdfb..79bd35fedb 100644 --- a/doc/contents.rst +++ b/doc/contents.rst @@ -6,6 +6,7 @@ Table of contents user/index admin/index + api/index development/index plugins/index diff --git a/doc/development/api/customview.rst b/doc/development/api/customview.rst index 50022e14ef..36a9553d7e 100644 --- a/doc/development/api/customview.rst +++ b/doc/development/api/customview.rst @@ -96,3 +96,52 @@ correctly ensure that: * The ``request.event`` attribute contains the correct ``Event`` object * The ``request.organizer`` attribute contains the correct ``Organizer`` object * The locale is set correctly + +REST API viewsets +----------------- + +Our REST API is built upon `Django REST Framework`_ (DRF). DRF has two important concepts that are different from +standard Django request handling: There are `ViewSets`_ to group related views in a single class and `Routers`_ to +automatically build URL configurations from them. + +To integrate a custom viewset with pretix' REST API, you can just register with one of our routers within the +``urls.py`` module of your plugin:: + + + from pretix.api.urls import event_router, router, orga_router + + router.register('global_viewset', MyViewSet) + orga_router.register('orga_level_viewset', MyViewSet) + event_router.register('event_level_viewset', MyViewSet) + +Routes registered with ``router`` are inserted into the global API space at ``/api/v1/``. Routes registered with +``orga_router`` will be included at ``/api/v1/organizers/(organizer)/`` and routes registered with ``event_router`` +will be included at ``/api/v1/organizers/(organizer)/events/(event)/``. + +In case of ``orga_router`` and ``event_router``, permission checking is done for you similarly as with custom views +in the control panel. However, you need to make sure on your own only to return the correct subset of data! ``request +.event`` and ``request.organizer`` are available as usual. + +To require a special permission like ``can_view_orders``, you do not need to inherit from a special ViewSet base +class, you can just set the ``permission`` attribute on your viewset:: + + class MyViewSet(ModelViewSet): + permission = 'can_view_orders' + ... + +If you want to check the permission only for some methods of your viewset, you have to do it yourself. Note here that +API authentications can be done via user sessions or API tokens and you should therefore check something like the +following:: + + + perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user) + if perm_holder.has_event_permission(request.event.organizer, request.event, 'can_view_orders'): + ... + + +.. warning:: It is important that you do this in the ``yourplugin.urls`` module, otherwise pretix will not find your + routes early enough during system startup. + +.. _Django REST Framework: http://www.django-rest-framework.org/ +.. _ViewSets: http://www.django-rest-framework.org/api-guide/viewsets/ +.. _Routers: http://www.django-rest-framework.org/api-guide/routers/ diff --git a/doc/development/index.rst b/doc/development/index.rst index 24de5c88de..1dc86d8f88 100644 --- a/doc/development/index.rst +++ b/doc/development/index.rst @@ -1,8 +1,6 @@ Developer documentation ======================= -Contents: - .. toctree:: :maxdepth: 2 @@ -14,4 +12,4 @@ Contents: api/index .. TODO:: - Document settings objects, ItemVariation objects, form fields. \ No newline at end of file + Document settings objects, ItemVariation objects, form fields. diff --git a/doc/plugins/pretixdroid.rst b/doc/plugins/pretixdroid.rst index c9840f13fe..5dddc15cd6 100644 --- a/doc/plugins/pretixdroid.rst +++ b/doc/plugins/pretixdroid.rst @@ -5,8 +5,9 @@ The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_ uses to communicate with the pretix server. .. warning:: This API is intended **only** to serve the pretixdroid Android app. There are no backwards compatibility - guarantees on this API. We will not add features that are not required for the Android App. There will be - a proper general-use API for pretix at a later point in time. + guarantees on this API. We will not add features that are not required for the Android App. There is a + general-purpose :ref:`rest-api` that not yet provides all features that this API provides, but will do + so in the future. .. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/ diff --git a/src/pretix/api/__init__.py b/src/pretix/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/pretix/api/auth/__init__.py b/src/pretix/api/auth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/pretix/api/auth/permission.py b/src/pretix/api/auth/permission.py new file mode 100644 index 0000000000..be028ad442 --- /dev/null +++ b/src/pretix/api/auth/permission.py @@ -0,0 +1,43 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission + +from pretix.base.models import Event +from pretix.base.models.organizer import Organizer, TeamAPIToken + + +class EventPermission(BasePermission): + model = TeamAPIToken + + def has_permission(self, request, view): + if not request.user.is_authenticated and not isinstance(request.auth, TeamAPIToken): + if request.method in SAFE_METHODS and request.path.startswith('/api/v1/docs/'): + return True + return False + + perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) + else request.user) + if 'event' in request.resolver_match.kwargs and 'organizer' in request.resolver_match.kwargs: + request.event = Event.objects.filter( + slug=request.resolver_match.kwargs['event'], + organizer__slug=request.resolver_match.kwargs['organizer'], + ).select_related('organizer').first() + if not request.event or not perm_holder.has_event_permission(request.event.organizer, request.event): + return False + request.organizer = request.event.organizer + request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event) + + if hasattr(view, 'permission'): + if view.permission and view.permission not in request.eventpermset: + return False + + elif 'organizer' in request.resolver_match.kwargs: + request.organizer = Organizer.objects.filter( + slug=request.resolver_match.kwargs['organizer'], + ).first() + if not request.organizer or not perm_holder.has_organizer_permission(request.organizer): + return False + request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer) + + if hasattr(view, 'permission'): + if view.permission and view.permission not in request.orgapermset: + return False + return True diff --git a/src/pretix/api/auth/token.py b/src/pretix/api/auth/token.py new file mode 100644 index 0000000000..7e6fa88345 --- /dev/null +++ b/src/pretix/api/auth/token.py @@ -0,0 +1,21 @@ +from django.contrib.auth.models import AnonymousUser +from rest_framework import exceptions +from rest_framework.authentication import TokenAuthentication + +from pretix.base.models.organizer import TeamAPIToken + + +class TeamTokenAuthentication(TokenAuthentication): + model = TeamAPIToken + + def authenticate_credentials(self, key): + model = self.get_model() + try: + token = model.objects.select_related('team', 'team__organizer').get(token=key) + except model.DoesNotExist: + raise exceptions.AuthenticationFailed('Invalid token.') + + if not token.active: + raise exceptions.AuthenticationFailed('Token inactive or deleted.') + + return AnonymousUser(), token diff --git a/src/pretix/api/serializers/__init__.py b/src/pretix/api/serializers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py new file mode 100644 index 0000000000..0d55e4c5f9 --- /dev/null +++ b/src/pretix/api/serializers/event.py @@ -0,0 +1,10 @@ +from pretix.api.serializers.i18n import I18nAwareModelSerializer +from pretix.base.models import Event + + +class EventSerializer(I18nAwareModelSerializer): + class Meta: + model = Event + fields = ('name', 'slug', 'live', 'currency', 'date_from', + 'date_to', 'date_admission', 'is_public', 'presale_start', + 'presale_end', 'location') diff --git a/src/pretix/api/serializers/i18n.py b/src/pretix/api/serializers/i18n.py new file mode 100644 index 0000000000..bd03804adb --- /dev/null +++ b/src/pretix/api/serializers/i18n.py @@ -0,0 +1,31 @@ +from django.conf import settings +from i18nfield.fields import I18nCharField, I18nTextField +from rest_framework.fields import Field +from rest_framework.serializers import ModelSerializer + + +class I18nField(Field): + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + self.trim_whitespace = kwargs.pop('trim_whitespace', True) + self.max_length = kwargs.pop('max_length', None) + self.min_length = kwargs.pop('min_length', None) + super().__init__(**kwargs) + + def to_representation(self, value): + if value is None or value.data is None: + return None + if isinstance(value.data, dict): + return value.data + else: + return { + settings.LANGUAGE_CODE: str(value.data) + } + + +class I18nAwareModelSerializer(ModelSerializer): + pass + + +I18nAwareModelSerializer.serializer_field_mapping[I18nCharField] = I18nField +I18nAwareModelSerializer.serializer_field_mapping[I18nTextField] = I18nField diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py new file mode 100644 index 0000000000..da904726ea --- /dev/null +++ b/src/pretix/api/serializers/item.py @@ -0,0 +1,64 @@ +from rest_framework import serializers + +from pretix.api.serializers.i18n import I18nAwareModelSerializer +from pretix.base.models import ( + Item, ItemAddOn, ItemCategory, ItemVariation, Question, QuestionOption, + Quota, +) + + +class InlineItemVariationSerializer(I18nAwareModelSerializer): + class Meta: + model = ItemVariation + fields = ('id', 'value', 'active', 'description', + 'position', 'default_price', 'price') + + +class InlineItemAddOnSerializer(serializers.ModelSerializer): + class Meta: + model = ItemAddOn + fields = ('addon_category', 'min_count', 'max_count', + 'position') + + +class ItemSerializer(I18nAwareModelSerializer): + addons = InlineItemAddOnSerializer(many=True) + variations = InlineItemVariationSerializer(many=True) + + class Meta: + model = Item + fields = ('id', 'category', 'name', 'active', 'description', + 'default_price', 'free_price', 'tax_rate', 'admission', + 'position', 'picture', 'available_from', 'available_until', + 'require_voucher', 'hide_without_voucher', 'allow_cancel', + 'min_per_order', 'max_per_order', 'has_variations', + 'variations', 'addons') + + +class ItemCategorySerializer(I18nAwareModelSerializer): + + class Meta: + model = ItemCategory + fields = ('id', 'name', 'description', 'position', 'is_addon') + + +class InlineQuestionOptionSerializer(I18nAwareModelSerializer): + + class Meta: + model = QuestionOption + fields = ('id', 'answer') + + +class QuestionSerializer(I18nAwareModelSerializer): + options = InlineQuestionOptionSerializer(many=True) + + class Meta: + model = Question + fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position') + + +class QuotaSerializer(I18nAwareModelSerializer): + + class Meta: + model = Quota + fields = ('id', 'name', 'size', 'items', 'variations') diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py new file mode 100644 index 0000000000..f969c64b69 --- /dev/null +++ b/src/pretix/api/serializers/order.py @@ -0,0 +1,110 @@ +from rest_framework import serializers +from rest_framework.reverse import reverse + +from pretix.api.serializers.i18n import I18nAwareModelSerializer +from pretix.base.models import ( + Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition, +) +from pretix.base.signals import register_ticket_outputs + + +class InvoiceAdddressSerializer(I18nAwareModelSerializer): + class Meta: + model = InvoiceAddress + fields = ('last_modified', 'company', 'name', 'street', 'zipcode', 'city', 'country', 'vat_id') + + +class CheckinSerializer(I18nAwareModelSerializer): + class Meta: + model = Checkin + fields = ('datetime',) + + +class OrderDownloadsField(serializers.Field): + def to_representation(self, instance: Order): + if instance.status != Order.STATUS_PAID: + return [] + + request = self.context['request'] + res = [] + responses = register_ticket_outputs.send(instance.event) + for receiver, response in responses: + provider = response(instance.event) + if provider.is_enabled: + res.append({ + 'output': provider.identifier, + 'url': reverse('api-v1:order-download', kwargs={ + 'organizer': instance.event.organizer.slug, + 'event': instance.event.slug, + 'code': instance.code, + 'output': provider.identifier, + }, request=request) + }) + return res + + +class PositionDownloadsField(serializers.Field): + def to_representation(self, instance: OrderPosition): + if instance.order.status != Order.STATUS_PAID: + return [] + if instance.addon_to_id and not instance.order.event.settings.ticket_download_addons: + return [] + if not instance.item.admission and not instance.order.event.settings.ticket_download_nonadm: + return [] + + request = self.context['request'] + res = [] + responses = register_ticket_outputs.send(instance.order.event) + for receiver, response in responses: + provider = response(instance.order.event) + if provider.is_enabled: + res.append({ + 'output': provider.identifier, + 'url': reverse('api-v1:orderposition-download', kwargs={ + 'organizer': instance.order.event.organizer.slug, + 'event': instance.order.event.slug, + 'pk': instance.pk, + 'output': provider.identifier, + }, request=request) + }) + return res + + +class OrderPositionSerializer(I18nAwareModelSerializer): + checkins = CheckinSerializer(many=True) + downloads = PositionDownloadsField(source='*') + order = serializers.SlugRelatedField(slug_field='code', read_only=True) + + class Meta: + model = OrderPosition + fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_email', + 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'checkins', 'downloads') + + +class OrderSerializer(I18nAwareModelSerializer): + invoice_address = InvoiceAdddressSerializer() + positions = OrderPositionSerializer(many=True) + downloads = OrderDownloadsField(source='*') + + class Meta: + model = Order + fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date', + 'payment_provider', 'payment_fee', 'payment_fee_tax_rate', 'payment_fee_tax_value', + 'total', 'comment', 'invoice_address', 'positions', 'downloads') + + +class InlineInvoiceLineSerializer(I18nAwareModelSerializer): + class Meta: + model = InvoiceLine + fields = ('description', 'gross_value', 'tax_value', 'tax_rate') + + +class InvoiceSerializer(I18nAwareModelSerializer): + order = serializers.SlugRelatedField(slug_field='code', read_only=True) + refers = serializers.SlugRelatedField(slug_field='invoice_no', read_only=True) + lines = InlineInvoiceLineSerializer(many=True) + + class Meta: + model = Invoice + fields = ('order', 'invoice_no', 'is_cancellation', 'invoice_from', 'invoice_to', 'date', 'refers', 'locale', + 'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines') diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py new file mode 100644 index 0000000000..3fa16488df --- /dev/null +++ b/src/pretix/api/serializers/organizer.py @@ -0,0 +1,8 @@ +from pretix.api.serializers.i18n import I18nAwareModelSerializer +from pretix.base.models import Organizer + + +class OrganizerSerializer(I18nAwareModelSerializer): + class Meta: + model = Organizer + fields = ('name', 'slug') diff --git a/src/pretix/api/serializers/voucher.py b/src/pretix/api/serializers/voucher.py new file mode 100644 index 0000000000..a281b7ddf5 --- /dev/null +++ b/src/pretix/api/serializers/voucher.py @@ -0,0 +1,10 @@ +from pretix.api.serializers.i18n import I18nAwareModelSerializer +from pretix.base.models import Voucher + + +class VoucherSerializer(I18nAwareModelSerializer): + class Meta: + model = Voucher + fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota', + 'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota', + 'tag', 'comment') diff --git a/src/pretix/api/serializers/waitinglist.py b/src/pretix/api/serializers/waitinglist.py new file mode 100644 index 0000000000..a34a842c6c --- /dev/null +++ b/src/pretix/api/serializers/waitinglist.py @@ -0,0 +1,9 @@ +from pretix.api.serializers.i18n import I18nAwareModelSerializer +from pretix.base.models import WaitingListEntry + + +class WaitingListSerializer(I18nAwareModelSerializer): + + class Meta: + model = WaitingListEntry + fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale') diff --git a/src/pretix/api/templates/__init__.py b/src/pretix/api/templates/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/pretix/api/templates/rest_framework/api.html b/src/pretix/api/templates/rest_framework/api.html new file mode 100644 index 0000000000..d5df9817fc --- /dev/null +++ b/src/pretix/api/templates/rest_framework/api.html @@ -0,0 +1,19 @@ +{% extends "rest_framework/base.html" %} +{% load staticfiles %} +{% load compress %} + +{% block bootstrap_theme %} + {% compress css %} + + {% endcompress %} +{% endblock %} +{% block branding %} + pretix REST API +{% endblock %} +{% block description %} + +{% endblock %} diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py new file mode 100644 index 0000000000..e06afd3670 --- /dev/null +++ b/src/pretix/api/urls.py @@ -0,0 +1,36 @@ +import importlib + +from django.apps import apps +from django.conf.urls import include, url +from rest_framework import routers + +from .views import event, item, order, organizer, voucher, waitinglist + +router = routers.DefaultRouter() +router.register(r'organizers', organizer.OrganizerViewSet) + +orga_router = routers.DefaultRouter() +orga_router.register(r'events', event.EventViewSet) + +event_router = routers.DefaultRouter() +event_router.register(r'items', item.ItemViewSet) +event_router.register(r'categories', item.ItemCategoryViewSet) +event_router.register(r'questions', item.QuestionViewSet) +event_router.register(r'quotas', item.QuotaViewSet) +event_router.register(r'vouchers', voucher.VoucherViewSet) +event_router.register(r'orders', order.OrderViewSet) +event_router.register(r'orderpositions', order.OrderPositionViewSet) +event_router.register(r'invoices', order.InvoiceViewSet) +event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet) + +# Force import of all plugins to give them a chance to register URLs with the router +for app in apps.get_app_configs(): + if hasattr(app, 'PretixPluginMeta'): + if importlib.util.find_spec(app.name + '.urls'): + importlib.import_module(app.name + '.urls') + +urlpatterns = [ + url(r'^', include(router.urls)), + url(r'^organizers/(?P[^/]+)/', include(orga_router.urls)), + url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/', include(event_router.urls)), +] diff --git a/src/pretix/api/views/__init__.py b/src/pretix/api/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py new file mode 100644 index 0000000000..fbe232bb47 --- /dev/null +++ b/src/pretix/api/views/event.py @@ -0,0 +1,14 @@ +from rest_framework import viewsets + +from pretix.api.serializers.event import EventSerializer +from pretix.base.models import Event + + +class EventViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = EventSerializer + queryset = Event.objects.none() + lookup_field = 'slug' + lookup_url_kwarg = 'event' + + def get_queryset(self): + return self.request.organizer.events.all() diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py new file mode 100644 index 0000000000..f869f70a08 --- /dev/null +++ b/src/pretix/api/views/item.py @@ -0,0 +1,67 @@ +from django_filters.rest_framework import DjangoFilterBackend, FilterSet +from rest_framework import viewsets +from rest_framework.filters import OrderingFilter + +from pretix.api.serializers.item import ( + ItemCategorySerializer, ItemSerializer, QuestionSerializer, + QuotaSerializer, +) +from pretix.base.models import Item, ItemCategory, Question, Quota + + +class ItemFilter(FilterSet): + class Meta: + model = Item + fields = ['active', 'category', 'admission', 'tax_rate', 'free_price'] + + +class ItemViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = ItemSerializer + queryset = Item.objects.none() + filter_backends = (DjangoFilterBackend, OrderingFilter) + ordering_fields = ('id', 'position') + ordering = ('position', 'id') + filter_class = ItemFilter + + def get_queryset(self): + return self.request.event.items.prefetch_related('variations', 'addons').all() + + +class ItemCategoryFilter(FilterSet): + class Meta: + model = ItemCategory + fields = ['is_addon'] + + +class ItemCategoryViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = ItemCategorySerializer + queryset = ItemCategory.objects.none() + filter_backends = (DjangoFilterBackend, OrderingFilter) + filter_class = ItemCategoryFilter + ordering_fields = ('id', 'position') + ordering = ('position', 'id') + + def get_queryset(self): + return self.request.event.categories.all() + + +class QuestionViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = QuestionSerializer + queryset = Question.objects.none() + filter_backends = (OrderingFilter,) + ordering_fields = ('id', 'position') + ordering = ('position', 'id') + + def get_queryset(self): + return self.request.event.questions.prefetch_related('options').all() + + +class QuotaViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = QuotaSerializer + queryset = Quota.objects.none() + filter_backends = (OrderingFilter,) + ordering_fields = ('id', 'size') + ordering = ('id',) + + def get_queryset(self): + return self.request.event.quotas.all() diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py new file mode 100644 index 0000000000..4967b18e09 --- /dev/null +++ b/src/pretix/api/views/order.py @@ -0,0 +1,181 @@ +import django_filters +from django.db.models import Q +from django.http import FileResponse +from django_filters.rest_framework import DjangoFilterBackend, FilterSet +from rest_framework import viewsets +from rest_framework.decorators import detail_route +from rest_framework.exceptions import APIException, NotFound, PermissionDenied +from rest_framework.filters import OrderingFilter + +from pretix.api.serializers.order import ( + InvoiceSerializer, OrderPositionSerializer, OrderSerializer, +) +from pretix.base.models import Invoice, Order, OrderPosition +from pretix.base.services.invoices import invoice_pdf +from pretix.base.services.tickets import ( + get_cachedticket_for_order, get_cachedticket_for_position, +) +from pretix.base.signals import register_ticket_outputs + + +class OrderFilter(FilterSet): + class Meta: + model = Order + fields = ['code', 'status', 'email', 'locale'] + + +class OrderViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = OrderSerializer + queryset = Order.objects.none() + filter_backends = (DjangoFilterBackend, OrderingFilter) + ordering = ('datetime',) + ordering_fields = ('datetime', 'code', 'status') + filter_class = OrderFilter + lookup_field = 'code' + permission = 'can_view_orders' + + def get_queryset(self): + return self.request.event.orders.prefetch_related( + 'positions', 'positions__checkins', 'positions__item', + ).select_related( + 'invoice_address' + ) + + def _get_output_provider(self, identifier): + responses = register_ticket_outputs.send(self.request.event) + for receiver, response in responses: + prov = response(self.request.event) + if prov.identifier == identifier: + return prov + raise NotFound('Unknown output provider.') + + @detail_route(url_name='download', url_path='download/(?P[^/]+)') + def download(self, request, output, **kwargs): + provider = self._get_output_provider(output) + order = self.get_object() + + if order.status != Order.STATUS_PAID: + raise PermissionDenied("Downloads are not available for unpaid orders.") + + ct = get_cachedticket_for_order(order, provider.identifier) + + if not ct.file: + raise RetryException() + else: + resp = FileResponse(ct.file.file, content_type=ct.type) + resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format( + self.request.event.slug.upper(), order.code, + provider.identifier, ct.extension + ) + return resp + + +class OrderPositionFilter(FilterSet): + order = django_filters.CharFilter(name='order', lookup_expr='code') + has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs') + attendee_name = django_filters.CharFilter(method='attendee_name_qs') + + def has_checkin_qs(self, queryset, name, value): + return queryset.filter(checkins__isnull=not value) + + def attendee_name_qs(self, queryset, name, value): + return queryset.filter(Q(attendee_name=value) | Q(addon_to__attendee_name=value)) + + class Meta: + model = OrderPosition + fields = ['item', 'variation', 'attendee_name', 'secret', 'order', 'order__status', 'has_checkin', + 'addon_to'] + + +class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = OrderPositionSerializer + queryset = OrderPosition.objects.none() + filter_backends = (DjangoFilterBackend, OrderingFilter) + ordering = ('order__datetime', 'positionid') + ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',) + filter_class = OrderPositionFilter + permission = 'can_view_orders' + + def get_queryset(self): + return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related( + 'checkins', + ).select_related( + 'item', 'order', 'order__event', 'order__event__organizer' + ) + + def _get_output_provider(self, identifier): + responses = register_ticket_outputs.send(self.request.event) + for receiver, response in responses: + prov = response(self.request.event) + if prov.identifier == identifier: + return prov + raise NotFound('Unknown output provider.') + + @detail_route(url_name='download', url_path='download/(?P[^/]+)') + def download(self, request, output, **kwargs): + provider = self._get_output_provider(output) + pos = self.get_object() + + if pos.order.status != Order.STATUS_PAID: + raise PermissionDenied("Downloads are not available for unpaid orders.") + if pos.addon_to_id and not request.event.settings.ticket_download_addons: + raise PermissionDenied("Downloads are not enabled for add-on products.") + if not pos.item.admission and not request.event.settings.ticket_download_nonadm: + raise PermissionDenied("Downloads are not enabled for non-admission products.") + + ct = get_cachedticket_for_position(pos, provider.identifier) + + if not ct.file: + raise RetryException() + else: + resp = FileResponse(ct.file.file, content_type=ct.type) + resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format( + self.request.event.slug.upper(), pos.order.code, pos.positionid, + provider.identifier, ct.extension + ) + return resp + + +class InvoiceFilter(FilterSet): + refers = django_filters.CharFilter(name='refers', lookup_expr='invoice_no__iexact') + order = django_filters.CharFilter(name='order', lookup_expr='code__iexact') + + class Meta: + model = Invoice + fields = ['order', 'invoice_no', 'is_cancellation', 'refers', 'locale'] + + +class RetryException(APIException): + status_code = 409 + default_detail = 'The requested resource is not ready, please retry later.' + default_code = 'retry_later' + + +class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = InvoiceSerializer + queryset = Invoice.objects.none() + filter_backends = (DjangoFilterBackend, OrderingFilter) + ordering = ('invoice_no',) + ordering_fields = ('invoice_no', 'date') + filter_class = InvoiceFilter + lookup_field = 'invoice_no' + lookup_url_kwarg = 'invoice_no' + permission = 'can_view_orders' + + def get_queryset(self): + return self.request.event.invoices.prefetch_related('lines').select_related('order') + + @detail_route() + def download(self, request, **kwargs): + invoice = self.get_object() + + if not invoice.file: + invoice_pdf(invoice.pk) + invoice.refresh_from_db() + + if not invoice.file: + raise RetryException() + + resp = FileResponse(invoice.file.file, content_type='application/pdf') + resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number) + return resp diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py new file mode 100644 index 0000000000..2bdcedc755 --- /dev/null +++ b/src/pretix/api/views/organizer.py @@ -0,0 +1,20 @@ +from rest_framework import viewsets + +from pretix.api.serializers.organizer import OrganizerSerializer +from pretix.base.models import Organizer + + +class OrganizerViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = OrganizerSerializer + queryset = Organizer.objects.none() + lookup_field = 'slug' + lookup_url_kwarg = 'organizer' + + def get_queryset(self): + if self.request.user.is_authenticated(): + if self.request.user.is_superuser: + return Organizer.objects.all() + else: + return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True)) + else: + return Organizer.objects.filter(pk=self.request.auth.team.organizer_id) diff --git a/src/pretix/api/views/voucher.py b/src/pretix/api/views/voucher.py new file mode 100644 index 0000000000..19a3dcff58 --- /dev/null +++ b/src/pretix/api/views/voucher.py @@ -0,0 +1,40 @@ +from django.db.models import F, Q +from django.utils.timezone import now +from django_filters.rest_framework import ( + BooleanFilter, DjangoFilterBackend, FilterSet, +) +from rest_framework import viewsets +from rest_framework.filters import OrderingFilter + +from pretix.api.serializers.voucher import VoucherSerializer +from pretix.base.models import Voucher + + +class VoucherFilter(FilterSet): + active = BooleanFilter(method='filter_active') + + class Meta: + model = Voucher + fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota', + 'price_mode', 'value', 'item', 'variation', 'quota', 'tag'] + + def filter_active(self, queryset, name, value): + if value: + return queryset.filter(Q(redeemed__lt=F('max_usages')) & + (Q(valid_until__isnull=True) | Q(valid_until__gt=now()))) + else: + return queryset.filter(Q(redeemed__gte=F('max_usages')) | + (Q(valid_until__isnull=False) & Q(valid_until__lte=now()))) + + +class VoucherViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = VoucherSerializer + queryset = Voucher.objects.none() + filter_backends = (DjangoFilterBackend, OrderingFilter) + ordering = ('id',) + ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value') + filter_class = VoucherFilter + permission = 'can_view_vouchers' + + def get_queryset(self): + return self.request.event.vouchers.all() diff --git a/src/pretix/api/views/waitinglist.py b/src/pretix/api/views/waitinglist.py new file mode 100644 index 0000000000..e520d83d94 --- /dev/null +++ b/src/pretix/api/views/waitinglist.py @@ -0,0 +1,31 @@ +import django_filters +from django_filters.rest_framework import DjangoFilterBackend, FilterSet +from rest_framework import viewsets +from rest_framework.filters import OrderingFilter + +from pretix.api.serializers.waitinglist import WaitingListSerializer +from pretix.base.models import WaitingListEntry + + +class WaitingListFilter(FilterSet): + has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs') + + def has_voucher_qs(self, queryset, name, value): + return queryset.filter(voucher__isnull=not value) + + class Meta: + model = WaitingListEntry + fields = ['item', 'variation', 'email', 'locale', 'has_voucher'] + + +class WaitingListViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = WaitingListSerializer + queryset = WaitingListEntry.objects.none() + filter_backends = (DjangoFilterBackend, OrderingFilter) + ordering = ('created',) + ordering_fields = ('id', 'created', 'email', 'item') + filter_class = WaitingListFilter + permission = 'can_view_orders' + + def get_queryset(self): + return self.request.event.waitinglistentries.all() diff --git a/src/pretix/base/middleware.py b/src/pretix/base/middleware.py index 0d6b31fd63..a656ae94e0 100644 --- a/src/pretix/base/middleware.py +++ b/src/pretix/base/middleware.py @@ -161,6 +161,9 @@ def _merge_csp(a, b): class SecurityMiddleware(MiddlewareMixin): + CSP_EXEMPT = ( + '/api/v1/docs/', + ) def process_response(self, request, resp): if settings.DEBUG and resp.status_code >= 400: @@ -199,6 +202,7 @@ class SecurityMiddleware(MiddlewareMixin): else: staticdomain += " " + settings.SITE_URL dynamicdomain += " " + settings.SITE_URL + if hasattr(request, 'organizer') and request.organizer: domain = get_domain(request.organizer) if domain: @@ -207,5 +211,6 @@ class SecurityMiddleware(MiddlewareMixin): domain = '%s:%d' % (domain, siteurlsplit.port) dynamicdomain += " " + domain - resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain) + if request.path not in self.CSP_EXEMPT: + resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain) return resp diff --git a/src/pretix/base/migrations/0062_auto_20170602_0948.py b/src/pretix/base/migrations/0062_auto_20170602_0948.py new file mode 100644 index 0000000000..5b3cae9697 --- /dev/null +++ b/src/pretix/base/migrations/0062_auto_20170602_0948.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.2 on 2017-06-02 09:48 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.organizer + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0061_auto_20170521_0942'), + ] + + operations = [ + migrations.CreateModel( + name='TeamAPIToken', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=190)), + ('active', models.BooleanField(default=True)), + ('token', models.CharField(default=pretix.base.models.organizer.generate_api_token, max_length=64)), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to='pretixbase.Team')), + ], + ), + ] diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py index 60e16a5b4f..51670c6087 100644 --- a/src/pretix/base/models/invoices.py +++ b/src/pretix/base/models/invoices.py @@ -1,8 +1,8 @@ import string -from datetime import date from decimal import Decimal from django.db import DatabaseError, models, transaction +from django.utils import timezone from django.utils.crypto import get_random_string from django.utils.functional import cached_property @@ -15,6 +15,10 @@ def invoice_filename(instance, filename: str) -> str: ) +def today(): + return timezone.now().date() + + class Invoice(models.Model): """ Represents an invoice that is issued because of an order. Because invoices are legally required @@ -56,7 +60,7 @@ class Invoice(models.Model): refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True) invoice_from = models.TextField() invoice_to = models.TextField() - date = models.DateField(default=date.today) + date = models.DateField(default=today) locale = models.CharField(max_length=50, default='en') introductory_text = models.TextField(blank=True) additional_text = models.TextField(blank=True) diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index d71e5049bd..efef151ccc 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -72,6 +72,10 @@ def generate_invite_token(): return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) +def generate_api_token(): + return get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits) + + class Team(LoggedModel): """ A team is a collection of people given certain access rights to one or more events of an organizer. @@ -175,6 +179,10 @@ class Team(LoggedModel): else: return self.limit_events.filter(pk=event.pk).exists() + @property + def active_tokens(self): + return self.tokens.filter(active=True) + class Meta: verbose_name = _("Team") verbose_name_plural = _("Teams") @@ -200,3 +208,81 @@ class TeamInvite(models.Model): return _("Invite to team '{team}' for '{email}'").format( team=str(self.team), email=self.email ) + + +class TeamAPIToken(models.Model): + """ + A TeamAPIToken represents an API token that has the same access level as the team it belongs to. + + :param team: The team the person is invited to + :type team: Team + :param name: A human-readable name for the token + :type name: str + :param active: Whether or not this token is active + :type active: bool + :param token: The secret required to submit to the API + :type token: str + """ + team = models.ForeignKey(Team, related_name="tokens", on_delete=models.CASCADE) + name = models.CharField(max_length=190) + active = models.BooleanField(default=True) + token = models.CharField(default=generate_api_token, max_length=64) + + def get_event_permission_set(self, organizer, event) -> set: + """ + Gets a set of permissions (as strings) that a token holds for a particular event + + :param organizer: The organizer of the event + :param event: The event to check + :return: set of permissions + """ + has_event_access = (self.team.all_events and organizer == self.team.organizer) or ( + event in self.team.limit_events.all() + ) + return self.team.permission_set() if has_event_access else set() + + def get_organizer_permission_set(self, organizer) -> set: + """ + Gets a set of permissions (as strings) that a token holds for a particular organizer + + :param organizer: The organizer of the event + :return: set of permissions + """ + return self.team.permission_set() if self.team.organizer == organizer else set() + + def has_event_permission(self, organizer, event, perm_name=None) -> bool: + """ + Checks if this token is part of a team that grants access of type ``perm_name`` + to the event ``event``. + + :param organizer: The organizer of the event + :param event: The event to check + :param perm_name: The permission, e.g. ``can_change_teams`` + :return: bool + """ + has_event_access = (self.team.all_events and organizer == self.team.organizer) or ( + event in self.team.limit_events.all() + ) + return has_event_access and (not perm_name or self.team.has_permission(perm_name)) + + def has_organizer_permission(self, organizer, perm_name=None): + """ + Checks if this token is part of a team that grants access of type ``perm_name`` + to the organizer ``organizer``. + + :param organizer: The organizer to check + :param perm_name: The permission, e.g. ``can_change_teams`` + :return: bool + """ + return organizer == self.team.organizer and (not perm_name or self.team.has_permission(perm_name)) + + def get_events_with_any_permission(self): + """ + Returns a queryset of events the token has any permissions to. + + :return: Iterable of Events + """ + if self.team.all_events: + return self.team.organizer.events.all() + else: + return self.team.limit_events.all() diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 5e9007b444..90bc0e834d 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -1,14 +1,13 @@ import copy import tempfile from collections import defaultdict -from datetime import date from decimal import Decimal from django.contrib.staticfiles import finders from django.core.files.base import ContentFile from django.db import transaction +from django.utils import timezone from django.utils.formats import date_format, localize -from django.utils.timezone import now from django.utils.translation import pgettext, ugettext as _ from i18nfield.strings import LazyI18nString from reportlab.lib import pagesizes @@ -108,7 +107,7 @@ def generate_cancellation(invoice: Invoice): cancellation.invoice_no = None cancellation.refers = invoice cancellation.is_cancellation = True - cancellation.date = date.today() + cancellation.date = timezone.now().date() cancellation.payment_provider_text = '' cancellation.save() @@ -135,7 +134,7 @@ def generate_invoice(order: Order): invoice = Invoice( order=order, event=order.event, - date=date.today(), + date=timezone.now().date(), locale=locale ) invoice = build_invoice(invoice) @@ -430,11 +429,11 @@ def build_preview_invoice_pdf(event): locale = event.settings.locale with rolledback_transaction(), language(locale): - order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(), - expires=now(), code="PREVIEW", total=119) + order = event.orders.create(status=Order.STATUS_PENDING, datetime=timezone.now(), + expires=timezone.now(), code="PREVIEW", total=119) invoice = Invoice( order=order, event=event, invoice_no="PREVIEW", - date=date.today(), locale=locale + date=timezone.now().date(), locale=locale ) invoice.invoice_from = event.settings.get('invoice_address_from') diff --git a/src/pretix/base/services/tickets.py b/src/pretix/base/services/tickets.py index b53b9b3f9c..7ef7e4df84 100644 --- a/src/pretix/base/services/tickets.py +++ b/src/pretix/base/services/tickets.py @@ -1,4 +1,5 @@ import os +from datetime import timedelta from django.core.files.base import ContentFile from django.utils.timezone import now @@ -85,3 +86,43 @@ def preview(event: int, provider: str): prov = response(event) if prov.identifier == provider: return prov.generate(p) + + +def get_cachedticket_for_position(pos, identifier): + try: + ct = CachedTicket.objects.filter( + order_position=pos, provider=identifier + ).last() + except CachedTicket.DoesNotExist: + ct = None + + if not ct: + ct = CachedTicket.objects.create( + order_position=pos, provider=identifier, + extension='', type='', file=None) + generate.apply_async(args=(pos.id, identifier)) + + if not ct.file: + if now() - ct.created > timedelta(minutes=5): + generate.apply_async(args=(pos.id, identifier)) + return ct + + +def get_cachedticket_for_order(order, identifier): + try: + ct = CachedCombinedTicket.objects.filter( + order=order, provider=identifier + ).last() + except CachedCombinedTicket.DoesNotExist: + ct = None + + if not ct: + ct = CachedCombinedTicket.objects.create( + order=order, provider=identifier, + extension='', type='', file=None) + generate_order.apply_async(args=(order.id, identifier)) + + if not ct.file: + if now() - ct.created > timedelta(minutes=5): + generate_order.apply_async(args=(order.id, identifier)) + return ct diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 63c140856e..648f57fa13 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -169,6 +169,12 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): if logentry.action_type == 'pretix.team.invite.deleted': return _('The invite for {user} has been revoked.').format(user=data.get('email')) + if logentry.action_type == 'pretix.team.token.created': + return _('The token "{name}" has been created.').format(name=data.get('name')) + + if logentry.action_type == 'pretix.team.token.deleted': + return _('The token "{name}" has been revoked.').format(name=data.get('name')) + if logentry.action_type == 'pretix.user.settings.changed': text = str(_('Your account settings have been changed.')) if 'email' in data: diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index 16528d0454..ed72fa3434 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -232,7 +232,7 @@ {% if messages %} {% for message in messages %}
- {{ message }} + {{ message|linebreaksbr }}
{% endfor %} {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/team_members.html b/src/pretix/control/templates/pretixcontrol/organizers/team_members.html index 8ed1c9af94..d5923799eb 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/team_members.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/team_members.html @@ -10,6 +10,7 @@ {% trans "Edit" %} +

{% trans "Team members" %}

{% csrf_token %} @@ -18,7 +19,7 @@ {% trans "Member" %} - + @@ -70,6 +71,47 @@
+

{% trans "API tokens" %}

+
+ {% csrf_token %} + + + + + + + + + + + {% for t in team.active_tokens %} + + + + + {% endfor %} + + + + + + + +
{% trans "Name" %}
+ {{ t.name }} + + +
+ {% bootstrap_field add_token_form.name layout='inline' %}
+
+ +
+

diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 94989dfc92..7730ba3b06 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -13,6 +13,7 @@ from django.views.generic import ( ) from pretix.base.models import Organizer, Team, TeamInvite, User +from pretix.base.models.organizer import TeamAPIToken from pretix.base.services.mail import SendMailException, mail from pretix.control.forms.organizer import ( OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, @@ -39,6 +40,10 @@ class InviteForm(forms.Form): user = forms.EmailField(required=False, label=_('User')) +class TokenForm(forms.Form): + name = forms.CharField(required=False, label=_('Token name')) + + class OrganizerDetailViewMixin: def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) @@ -309,11 +314,18 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, @cached_property def add_form(self): - return InviteForm(data=self.request.POST if self.request.method == "POST" else None) + return InviteForm(data=(self.request.POST + if self.request.method == "POST" and "user" in self.request.POST else None)) + + @cached_property + def add_token_form(self): + return TokenForm(data=(self.request.POST + if self.request.method == "POST" and "name" in self.request.POST else None)) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['add_form'] = self.add_form + ctx['add_token_form'] = self.add_token_form return ctx def _send_invite(self, instance): @@ -380,7 +392,24 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, messages.success(self.request, _('The invite has been revoked.')) return redirect(self.get_success_url()) - elif self.add_form.is_valid() and self.add_form.has_changed(): + elif 'remove-token' in request.POST: + try: + token = self.object.tokens.get(pk=request.POST.get('remove-token')) + except TeamAPIToken.DoesNotExist: + messages.error(self.request, _('Invalid token selected.')) + return redirect(self.get_success_url()) + else: + token.active = False + token.save() + self.object.log_action( + 'pretix.team.token.deleted', user=self.request.user, data={ + 'name': token.name + } + ) + messages.success(self.request, _('The token has been revoked.')) + return redirect(self.get_success_url()) + + elif "user" in self.request.POST and self.add_form.is_valid() and self.add_form.has_changed(): try: user = User.objects.get(email=self.add_form.cleaned_data['user']) @@ -414,6 +443,18 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, messages.success(self.request, _('The new member has been added to the team.')) return redirect(self.get_success_url()) + elif "name" in self.request.POST and self.add_token_form.is_valid() and self.add_token_form.has_changed(): + token = self.object.tokens.create(name=self.add_token_form.cleaned_data['name']) + self.object.log_action( + 'pretix.team.token.created', user=self.request.user, data={ + 'name': self.add_token_form.cleaned_data['name'], + 'id': token.pk + } + ) + messages.success(self.request, _('A new API token has been created with the following secret: {}\n' + 'Please copy this secret to a safe place. You will not be able to ' + 'view it again here.').format(token.token)) + return redirect(self.get_success_url()) else: messages.error(self.request, _('Your changes could not be saved.')) return self.get(request, *args, **kwargs) diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 74b85b9fcf..e422367a3c 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -1,5 +1,3 @@ -from datetime import timedelta - from django.contrib import messages from django.db import transaction from django.db.models import Sum @@ -11,13 +9,15 @@ from django.utils.translation import ugettext_lazy as _ from django.views.generic import TemplateView, View from pretix.base.models import CachedTicket, Invoice, Order, OrderPosition -from pretix.base.models.orders import CachedCombinedTicket, InvoiceAddress +from pretix.base.models.orders import InvoiceAddress from pretix.base.payment import PaymentException from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, ) from pretix.base.services.orders import cancel_order -from pretix.base.services.tickets import generate, generate_order +from pretix.base.services.tickets import ( + get_cachedticket_for_order, get_cachedticket_for_position, +) from pretix.base.signals import ( register_payment_providers, register_ticket_outputs, ) @@ -554,22 +554,7 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View): return self._download_order() def _download_order(self): - try: - ct = CachedCombinedTicket.objects.filter( - order=self.order, provider=self.output.identifier - ).last() - except CachedCombinedTicket.DoesNotExist: - ct = None - - if not ct: - ct = CachedCombinedTicket.objects.create( - order=self.order, provider=self.output.identifier, - extension='', type='', file=None) - generate_order.apply_async(args=(self.order.id, self.output.identifier)) - - if not ct.file: - if now() - ct.created > timedelta(minutes=5): - generate_order.apply_async(args=(self.order.id, self.output.identifier)) + ct = get_cachedticket_for_order(self.order, self.output.identifier) if 'ajax' in self.request.GET: return JsonResponse({ @@ -587,22 +572,7 @@ class OrderDownload(EventViewMixin, OrderDetailMixin, View): return resp def _download_position(self): - try: - ct = CachedTicket.objects.filter( - order_position=self.order_position, provider=self.output.identifier - ).last() - except CachedTicket.DoesNotExist: - ct = None - - if not ct: - ct = CachedTicket.objects.create( - order_position=self.order_position, provider=self.output.identifier, - extension='', type='', file=None) - generate.apply_async(args=(self.order_position.id, self.output.identifier)) - - if not ct.file: - if now() - ct.created > timedelta(minutes=5): - generate.apply_async(args=(self.order_position.id, self.output.identifier)) + ct = get_cachedticket_for_position(self.order_position, self.output.identifier) if 'ajax' in self.request.GET: return JsonResponse({ diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 8b39597460..b837c93f17 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -189,6 +189,9 @@ INSTALLED_APPS = [ 'pretix.control', 'pretix.presale', 'pretix.multidomain', + 'pretix.api', + 'rest_framework', + 'django_filters', 'compressor', 'bootstrap3', 'djangoformsetjs', @@ -233,6 +236,23 @@ if config.has_option('sentry', 'dsn'): } +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': [ + 'pretix.api.auth.permission.EventPermission', + ], + 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning', + 'PAGE_SIZE': 50, + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'pretix.api.auth.token.TeamTokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework.renderers.JSONRenderer', + ), + 'UNICODE_JSON': False +} + + CORE_MODULES = { ("pretix", "base"), ("pretix", "presale"), diff --git a/src/pretix/static/rest_framework/scss/_variables.scss b/src/pretix/static/rest_framework/scss/_variables.scss new file mode 100644 index 0000000000..6d391c981d --- /dev/null +++ b/src/pretix/static/rest_framework/scss/_variables.scss @@ -0,0 +1,2 @@ +$font-family-sans-serif: "Open Sans", "OpenSans", "Helvetica Neue", Helvetica, Arial, sans-serif !default; +$brand-primary: #8E44B3 !default; diff --git a/src/pretix/static/rest_framework/scss/main.scss b/src/pretix/static/rest_framework/scss/main.scss new file mode 100644 index 0000000000..dff5584f08 --- /dev/null +++ b/src/pretix/static/rest_framework/scss/main.scss @@ -0,0 +1,10 @@ +@import "_variables.scss"; +@import "../../pretixbase/scss/colors.scss"; +@import "../../bootstrap/scss/_bootstrap.scss"; +@import "../../pretixbase/scss/webfont.scss"; + +.alert-docs-link { + text-align: center; + font-weight: bold; + font-size: 20px; +} diff --git a/src/pretix/urls.py b/src/pretix/urls.py index 607f942396..9988fd64aa 100644 --- a/src/pretix/urls.py +++ b/src/pretix/urls.py @@ -1,5 +1,6 @@ from django.conf import settings from django.conf.urls import include, url +from django.views.generic import RedirectView import pretix.control.urls import pretix.presale.urls @@ -15,6 +16,8 @@ base_patterns = [ url(r'^jsi18n/(?P[a-zA-Z-_]+)/$', js_catalog.js_catalog, name='javascript-catalog'), url(r'^metrics$', metrics.serve_metrics, name='metrics'), + url(r'^api/v1/', include('pretix.api.urls', namespace='api-v1')), + url(r'^api/$', RedirectView.as_view(url='/api/v1/'), name='redirect-api-version') ] control_patterns = [ diff --git a/src/requirements/production.txt b/src/requirements/production.txt index a860d1ac12..69c7692bef 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -1,11 +1,13 @@ # Functional requirements Django>=1.11.* +djangorestframework==3.6.* python-dateutil pytz django-bootstrap3==8.2.* django-formset-js-improved==0.5.0.1 django-compressor==2.1.1 -django-hierarkey==1.0.* +django-hierarkey==1.0.*,>=1.0.2 +django-filter==1.0.* reportlab==3.2.* PyPDF2==1.26.* easy-thumbnails==2.4.* @@ -29,6 +31,9 @@ markdown bleach==2.* raven django-i18nfield>=1.0.1 +# API docs +coreapi==2.3.* +pygments # Stripe stripe==1.22.* # PayPal diff --git a/src/tests/api/__init__.py b/src/tests/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py new file mode 100644 index 0000000000..02f90aa982 --- /dev/null +++ b/src/tests/api/conftest.py @@ -0,0 +1,47 @@ +from datetime import datetime + +import pytest +from pytz import UTC +from rest_framework.test import APIClient + +from pretix.base.models import Event, Organizer, Team, User + + +@pytest.fixture +def client(): + return APIClient() + + +@pytest.fixture +def organizer(): + return Organizer.objects.create(name='Dummy', slug='dummy') + + +@pytest.fixture +def event(organizer): + return 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' + ) + + +@pytest.fixture +def team(organizer): + return Team.objects.create(organizer=organizer) + + +@pytest.fixture +def user(): + return User.objects.create_user('dummy@dummy.dummy', 'dummy') + + +@pytest.fixture +def token_client(client, team): + team.can_view_orders = True + team.can_view_vouchers = True + team.all_events = True + team.save() + t = team.tokens.create(name='Foo') + client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) + return client diff --git a/src/tests/api/test_auth.py b/src/tests/api/test_auth.py new file mode 100644 index 0000000000..b6c6046461 --- /dev/null +++ b/src/tests/api/test_auth.py @@ -0,0 +1,53 @@ +import pytest + +from pretix.base.models import Organizer + + +@pytest.mark.django_db +def test_no_auth(client): + resp = client.get('/api/v1/organizers/') + assert resp.status_code == 401 + + +@pytest.mark.django_db +def test_session_auth_no_teams(client, user): + client.login(email=user.email, password='dummy') + resp = client.get('/api/v1/organizers/') + assert resp.status_code == 200 + assert len(resp.data['results']) == 0 + + +@pytest.mark.django_db +def test_session_auth_with_teams(client, user, team): + team.members.add(user) + Organizer.objects.create(name='Other dummy', slug='dummy') + client.login(email=user.email, password='dummy') + resp = client.get('/api/v1/organizers/') + assert resp.status_code == 200 + assert len(resp.data['results']) == 1 + + +@pytest.mark.django_db +def test_token_invalid(client): + client.credentials(HTTP_AUTHORIZATION='Token ABCDE') + resp = client.get('/api/v1/organizers/') + assert resp.status_code == 401 + + +@pytest.mark.django_db +def test_token_auth_valid(client, team): + Organizer.objects.create(name='Other dummy', slug='dummy') + t = team.tokens.create(name='Foo') + client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) + resp = client.get('/api/v1/organizers/') + assert resp.status_code == 200 + assert len(resp.data['results']) == 1 + + +@pytest.mark.django_db +def test_token_auth_inactive(client, team): + Organizer.objects.create(name='Other dummy', slug='dummy') + t = team.tokens.create(name='Foo', active=False) + client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) + resp = client.get('/api/v1/organizers/') + assert resp.status_code == 401 diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py new file mode 100644 index 0000000000..345d04f725 --- /dev/null +++ b/src/tests/api/test_events.py @@ -0,0 +1,32 @@ +import pytest + +TEST_EVENT_RES = { + "name": {"en": "Dummy"}, + "live": False, + "currency": "EUR", + "date_from": "2017-12-27T10:00:00Z", + "date_to": None, + "date_admission": None, + "is_public": False, + "presale_start": None, + "presale_end": None, + "location": None, + "slug": "dummy", +} + + +@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]) + + +@pytest.mark.django_db +def test_event_detail(token_client, organizer, event, team): + team.all_events = True + team.save() + resp = token_client.get('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert TEST_EVENT_RES == resp.data diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py new file mode 100644 index 0000000000..74826cd5f3 --- /dev/null +++ b/src/tests/api/test_items.py @@ -0,0 +1,268 @@ +from decimal import Decimal + +import pytest + + +@pytest.fixture +def category(event): + return event.categories.create(name="Tickets") + + +TEST_CATEGORY_RES = { + "name": {"en": "Tickets"}, + "description": {"en": ""}, + "position": 0, + "is_addon": False +} + + +@pytest.mark.django_db +def test_category_list(token_client, organizer, event, team, category): + res = dict(TEST_CATEGORY_RES) + res["id"] = category.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/categories/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/categories/?is_addon=false'.format( + organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/categories/?is_addon=true'.format( + organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [] == resp.data['results'] + category.is_addon = True + category.save() + res["is_addon"] = True + resp = token_client.get('/api/v1/organizers/{}/events/{}/categories/?is_addon=true'.format( + organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_category_detail(token_client, organizer, event, team, category): + res = dict(TEST_CATEGORY_RES) + res["id"] = category.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/categories/{}/'.format(organizer.slug, event.slug, + category.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.fixture +def item(event): + return event.items.create(name="Budget Ticket", default_price=23) + + +TEST_ITEM_RES = { + "name": {"en": "Budget Ticket"}, + "default_price": "23.00", + "category": None, + "active": True, + "description": None, + "free_price": False, + "tax_rate": "0.00", + "admission": False, + "position": 0, + "picture": None, + "available_from": None, + "available_until": None, + "require_voucher": False, + "hide_without_voucher": False, + "allow_cancel": True, + "min_per_order": None, + "max_per_order": None, + "has_variations": False, + "variations": [], + "addons": [] +} + + +@pytest.mark.django_db +def test_item_list(token_client, organizer, event, team, item): + res = dict(TEST_ITEM_RES) + res["id"] = item.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?active=true'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?active=false'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?category=1'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?admission=true'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?admission=false'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + item.admission = True + item.save() + res['admission'] = True + + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?admission=true'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?admission=false'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?tax_rate=0'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?tax_rate=19'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/?free_price=true'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [] == resp.data['results'] + + +@pytest.mark.django_db +def test_item_detail(token_client, organizer, event, team, item): + res = dict(TEST_ITEM_RES) + res["id"] = item.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, + item.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_item_detail_variations(token_client, organizer, event, team, item): + var = item.variations.create(value="Children") + res = dict(TEST_ITEM_RES) + res["id"] = item.pk + res["variations"] = [{ + "id": var.pk, + "value": {"en": "Children"}, + "default_price": None, + "price": Decimal("23.00"), + "active": True, + "description": None, + "position": 0, + }] + res["has_variations"] = True + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, + item.pk)) + assert resp.status_code == 200 + assert res['variations'] == resp.data['variations'] + + +@pytest.mark.django_db +def test_item_detail_addons(token_client, organizer, event, team, item, category): + item.addons.create(addon_category=category) + res = dict(TEST_ITEM_RES) + + res["id"] = item.pk + res["addons"] = [{ + "addon_category": category.pk, + "min_count": 0, + "max_count": 1, + "position": 0 + }] + resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, + item.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.fixture +def quota(event, item): + q = event.quotas.create(name="Budget Quota", size=200) + q.items.add(item) + return q + + +TEST_QUOTA_RES = { + "name": "Budget Quota", + "size": 200, + "items": [], + "variations": [] +} + + +@pytest.mark.django_db +def test_quota_list(token_client, organizer, event, quota, item): + res = dict(TEST_QUOTA_RES) + res["id"] = quota.pk + res["items"] = [item.pk] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/quotas/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_quota_detail(token_client, organizer, event, quota, item): + res = dict(TEST_QUOTA_RES) + + res["id"] = quota.pk + res["items"] = [item.pk] + resp = token_client.get('/api/v1/organizers/{}/events/{}/quotas/{}/'.format(organizer.slug, event.slug, + quota.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.fixture +def question(event, item): + q = event.questions.create(question="T-Shirt size", type="C") + q.items.add(item) + q.options.create(answer="XL") + return q + + +TEST_QUESTION_RES = { + "question": {"en": "T-Shirt size"}, + "type": "C", + "required": False, + "items": [], + "position": 0, + "options": [ + { + "id": 0, + "answer": {"en": "XL"} + } + ] +} + + +@pytest.mark.django_db +def test_question_list(token_client, organizer, event, question, item): + res = dict(TEST_QUESTION_RES) + res["id"] = question.pk + res["items"] = [item.pk] + res["options"][0]["id"] = question.options.first().pk + + resp = token_client.get('/api/v1/organizers/{}/events/{}/questions/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_question_detail(token_client, organizer, event, question, item): + res = dict(TEST_QUESTION_RES) + + res["id"] = question.pk + res["items"] = [item.pk] + res["options"][0]["id"] = question.options.first().pk + + resp = token_client.get('/api/v1/organizers/{}/events/{}/questions/{}/'.format(organizer.slug, event.slug, + question.pk)) + assert resp.status_code == 200 + assert res == resp.data diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py new file mode 100644 index 0000000000..4aaeea2e7b --- /dev/null +++ b/src/tests/api/test_orders.py @@ -0,0 +1,321 @@ +import datetime +from decimal import Decimal +from unittest import mock + +import pytest +from pytz import UTC + +from pretix.base.models import InvoiceAddress, Order, OrderPosition +from pretix.base.services.invoices import ( + generate_cancellation, generate_invoice, +) + + +@pytest.fixture +def item(event): + return event.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def order(event, item): + testtime = datetime.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.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC), + expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC), + total=23, payment_provider='banktransfer', locale='en' + ) + InvoiceAddress.objects.create(order=o, company="Sample company") + OrderPosition.objects.create( + order=o, + item=item, + variation=None, + price=Decimal("23"), + attendee_name="Peter", + secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w" + ) + return o + + +TEST_ORDERPOSITION_RES = { + "id": 1, + "order": "FOO", + "positionid": 1, + "item": 1, + "variation": None, + "price": "23.00", + "attendee_name": "Peter", + "attendee_email": None, + "voucher": None, + "tax_rate": "0.00", + "tax_value": "0.00", + "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", + "addon_to": None, + "checkins": [], + "downloads": [] +} +TEST_ORDER_RES = { + "code": "FOO", + "status": "n", + "secret": "k24fiuwvu8kxz3y1", + "email": "dummy@dummy.test", + "locale": "en", + "datetime": "2017-12-01T10:00:00Z", + "expires": "2017-12-10T10:00:00Z", + "payment_date": None, + "payment_provider": "banktransfer", + "payment_fee": "0.00", + "payment_fee_tax_rate": "0.00", + "payment_fee_tax_value": "0.00", + "total": "23.00", + "comment": "", + "invoice_address": { + "last_modified": "2017-12-01T10:00:00Z", + "company": "Sample company", + "name": "", + "street": "", + "zipcode": "", + "city": "", + "country": "", + "vat_id": "" + }, + "positions": [TEST_ORDERPOSITION_RES], + "downloads": [] +} + + +@pytest.mark.django_db +def test_order_list(token_client, organizer, event, order, item): + res = dict(TEST_ORDER_RES) + res["positions"][0]["id"] = order.positions.first().pk + res["positions"][0]["item"] = item.pk + + resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?code=FOO'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?code=BAR'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?status=n'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?status=p'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orders/?email=dummy@dummy.test'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orders/?email=foo@example.org'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?locale=en'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?locale=de'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + +@pytest.mark.django_db +def test_order_detail(token_client, organizer, event, order, item): + res = dict(TEST_ORDER_RES) + res["positions"][0]["id"] = order.positions.first().pk + res["positions"][0]["item"] = item.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/'.format(organizer.slug, event.slug, + order.code)) + assert resp.status_code == 200 + assert res == resp.data + + order.status = 'p' + order.save() + event.settings.ticketoutput_pdf__enabled = True + resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/{}/'.format(organizer.slug, event.slug, + order.code)) + assert len(resp.data['downloads']) == 1 + assert len(resp.data['positions'][0]['downloads']) == 1 + + +@pytest.mark.django_db +def test_orderposition_list(token_client, organizer, event, order, item): + var = item.variations.create(value="Children") + res = dict(TEST_ORDERPOSITION_RES) + op = order.positions.first() + op.variation = var + op.save() + res["id"] = op.pk + res["item"] = item.pk + res["variation"] = var.pk + + resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?order__status=n'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?order__status=p'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?item={}'.format(organizer.slug, event.slug, item.pk)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?item={}'.format(organizer.slug, event.slug, item.pk + 1)) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?variation={}'.format(organizer.slug, event.slug, var.pk)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?variation={}'.format(organizer.slug, event.slug, var.pk + 1)) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?attendee_name=Peter'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?attendee_name=Mark'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?secret=z3fsn8jyufm5kpk768q69gkbyr5f4h6w'.format( + organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?secret=abc123'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?order=FOO'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?order=BAR'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?has_checkin=false'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?has_checkin=true'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + order.positions.first().checkins.create(datetime=datetime.datetime(2017, 12, 26, 10, 0, 0, tzinfo=UTC)) + res['checkins'] = [{'datetime': '2017-12-26T10:00:00Z'}] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/orderpositions/?has_checkin=true'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_orderposition_detail(token_client, organizer, event, order, item): + res = dict(TEST_ORDERPOSITION_RES) + op = order.positions.first() + res["id"] = op.pk + res["item"] = item.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format(organizer.slug, event.slug, + op.pk)) + assert resp.status_code == 200 + assert res == resp.data + + order.status = 'p' + order.save() + event.settings.ticketoutput_pdf__enabled = True + resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format(organizer.slug, event.slug, + op.pk)) + assert len(resp.data['downloads']) == 1 + + +@pytest.fixture +def invoice(order): + testtime = datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + return generate_invoice(order) + + +TEST_INVOICE_RES = { + "order": "FOO", + "invoice_no": "00001", + "is_cancellation": False, + "invoice_from": "", + "invoice_to": "Sample company", + "date": "2017-12-10", + "refers": None, + "locale": "en", + "introductory_text": "", + "additional_text": "", + "payment_provider_text": "", + "footer_text": "", + "lines": [ + { + "description": "Budget Ticket", + "gross_value": "23.00", + "tax_value": "0.00", + "tax_rate": "0.00" + } + ] +} + + +@pytest.mark.django_db +def test_invoice_list(token_client, organizer, event, order, invoice): + res = dict(TEST_INVOICE_RES) + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?order=FOO'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?order=BAR'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?invoice_no={}'.format( + organizer.slug, event.slug, invoice.invoice_no)) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?invoice_no=XXX'.format( + organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?locale=en'.format( + organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?locale=de'.format( + organizer.slug, event.slug)) + assert [] == resp.data['results'] + + ic = generate_cancellation(invoice) + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?is_cancellation=false'.format( + organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?is_cancellation=true'.format( + organizer.slug, event.slug)) + assert len(resp.data['results']) == 1 + assert resp.data['results'][0]['invoice_no'] == ic.invoice_no + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?refers={}'.format( + organizer.slug, event.slug, invoice.invoice_no)) + assert len(resp.data['results']) == 1 + assert resp.data['results'][0]['invoice_no'] == ic.invoice_no + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/?refers={}'.format( + organizer.slug, event.slug, ic.invoice_no)) + assert [] == resp.data['results'] + + +@pytest.mark.django_db +def test_invoice_detail(token_client, organizer, event, invoice): + res = dict(TEST_INVOICE_RES) + + resp = token_client.get('/api/v1/organizers/{}/events/{}/invoices/{}/'.format(organizer.slug, event.slug, + invoice.invoice_no)) + assert resp.status_code == 200 + assert res == resp.data diff --git a/src/tests/api/test_organizers.py b/src/tests/api/test_organizers.py new file mode 100644 index 0000000000..375e7272d9 --- /dev/null +++ b/src/tests/api/test_organizers.py @@ -0,0 +1,20 @@ +import pytest + +TEST_ORGANIZER_RES = { + "name": "Dummy", + "slug": "dummy" +} + + +@pytest.mark.django_db +def test_organizer_list(token_client, organizer): + resp = token_client.get('/api/v1/organizers/') + assert resp.status_code == 200 + assert TEST_ORGANIZER_RES in resp.data['results'] + + +@pytest.mark.django_db +def test_organizer_detail(token_client, organizer): + resp = token_client.get('/api/v1/organizers/{}/'.format(organizer.slug)) + assert resp.status_code == 200 + assert TEST_ORGANIZER_RES == resp.data diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py new file mode 100644 index 0000000000..cab6314551 --- /dev/null +++ b/src/tests/api/test_permissions.py @@ -0,0 +1,109 @@ +import pytest + +from pretix.base.models import Organizer + +event_urls = [ + 'categories/', + 'invoices/', + 'items/', + 'orders/', + 'orderpositions/', + 'questions/', + 'quotas/', + 'vouchers/', + 'waitinglistentries/', +] + +event_permission_urls = [ + ('get', 'can_view_orders', 'orders/', 200), + ('get', 'can_view_orders', 'orderpositions/', 200), + ('get', 'can_view_vouchers', 'vouchers/', 200), + ('get', 'can_view_orders', 'invoices/', 200), + ('get', 'can_view_orders', 'waitinglistentries/', 200), +] + + +@pytest.fixture +def token_client(client, team): + team.can_view_orders = True + team.can_view_vouchers = True + team.save() + t = team.tokens.create(name='Foo') + client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) + return client + + +@pytest.mark.django_db +def test_organizer_allowed(token_client, organizer): + resp = token_client.get('/api/v1/organizers/{}/events/'.format(organizer.slug)) + assert resp.status_code == 200 + + +@pytest.mark.django_db +def test_organizer_not_allowed(token_client, organizer): + o2 = Organizer.objects.create(slug='o2', name='Organizer 2') + resp = token_client.get('/api/v1/organizers/{}/events/'.format(o2.slug)) + assert resp.status_code == 403 + + +@pytest.mark.django_db +def test_organizer_not_existing(token_client, organizer): + resp = token_client.get('/api/v1/organizers/{}/events/'.format('o2')) + assert resp.status_code == 403 + + +@pytest.mark.django_db +@pytest.mark.parametrize("url", event_urls) +def test_event_allowed_all_events(token_client, team, organizer, event, url): + team.all_events = True + team.save() + resp = token_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) + assert resp.status_code == 200 + + +@pytest.mark.django_db +@pytest.mark.parametrize("url", event_urls) +def test_event_allowed_limit_events(token_client, organizer, team, event, url): + team.all_events = False + team.save() + team.limit_events.add(event) + resp = token_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) + assert resp.status_code == 200 + + +@pytest.mark.django_db +@pytest.mark.parametrize("url", event_urls) +def test_event_not_allowed(token_client, organizer, team, event, url): + team.all_events = False + team.save() + resp = token_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) + assert resp.status_code == 403 + + +@pytest.mark.django_db +@pytest.mark.parametrize("url", event_urls) +def test_event_not_existing(token_client, organizer, url, event): + resp = token_client.get('/api/v1/organizers/{}/events/{}/{}'.format(organizer.slug, event.slug, url)) + assert resp.status_code == 403 + + +@pytest.mark.django_db +@pytest.mark.parametrize("urlset", event_permission_urls) +def test_token_event_permission_allowed(token_client, team, organizer, event, urlset): + team.all_events = True + setattr(team, urlset[1], True) + team.save() + resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/{}/{}'.format( + organizer.slug, event.slug, urlset[2])) + assert resp.status_code == urlset[3] + + +@pytest.mark.django_db +@pytest.mark.parametrize("urlset", event_permission_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() + resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/{}/{}'.format( + organizer.slug, event.slug, urlset[2])) + assert resp.status_code in (404, 403) diff --git a/src/tests/api/test_vouchers.py b/src/tests/api/test_vouchers.py new file mode 100644 index 0000000000..e2f89d92af --- /dev/null +++ b/src/tests/api/test_vouchers.py @@ -0,0 +1,201 @@ +import datetime + +import pytest +from django.utils import timezone + + +@pytest.fixture +def item(event): + return event.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def voucher(event, item): + return event.vouchers.create(item=item, price_mode='set', value=12, tag='Foo') + + +@pytest.fixture +def quota(event, item): + q = event.quotas.create(name="Budget Quota", size=200) + q.items.add(item) + return q + + +TEST_VOUCHER_RES = { + 'id': 1, + 'code': '43K6LKM37FBVR2YG', + 'max_usages': 1, + 'redeemed': 0, + 'valid_until': None, + 'block_quota': False, + 'allow_ignore_quota': False, + 'price_mode': 'set', + 'value': '12.00', + 'item': 1, + 'variation': None, + 'quota': None, + 'tag': 'Foo', + 'comment': '' +} + + +@pytest.mark.django_db +def test_voucher_list(token_client, organizer, event, voucher, item, quota): + res = dict(TEST_VOUCHER_RES) + res['item'] = item.pk + res['id'] = voucher.pk + res['code'] = voucher.code + + resp = token_client.get('/api/v1/organizers/{}/events/{}/vouchers/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?code={}'.format(organizer.slug, event.slug, voucher.code) + ) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?code=ABC'.format(organizer.slug, event.slug) + ) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?max_usages=1'.format(organizer.slug, event.slug) + ) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?max_usages=2'.format(organizer.slug, event.slug) + ) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?redeemed=0'.format(organizer.slug, event.slug) + ) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?redeemed=1'.format(organizer.slug, event.slug) + ) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?block_quota=false'.format(organizer.slug, event.slug) + ) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?block_quota=true'.format(organizer.slug, event.slug) + ) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?allow_ignore_quota=false'.format(organizer.slug, event.slug) + ) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?allow_ignore_quota=true'.format(organizer.slug, event.slug) + ) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?price_mode=set'.format(organizer.slug, event.slug) + ) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?price_mode=percent'.format(organizer.slug, event.slug) + ) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?value=12.00'.format(organizer.slug, event.slug) + ) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?value=10.00'.format(organizer.slug, event.slug) + ) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?item={}'.format(organizer.slug, event.slug, item.pk) + ) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?item={}'.format(organizer.slug, event.slug, item.pk + 1) + ) + assert [] == resp.data['results'] + + var = item.variations.create(value='VIP') + voucher.variation = var + voucher.save() + res['variation'] = var.pk + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?variation={}'.format(organizer.slug, event.slug, var.pk) + ) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?variation={}'.format(organizer.slug, event.slug, var.pk + 1) + ) + assert [] == resp.data['results'] + + voucher.variation = None + voucher.item = None + voucher.quota = quota + voucher.save() + res['variation'] = None + res['item'] = None + res['quota'] = quota.pk + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?quota={}'.format(organizer.slug, event.slug, quota.pk) + ) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?quota={}'.format(organizer.slug, event.slug, quota.pk + 1) + ) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?tag=Foo'.format(organizer.slug, event.slug) + ) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?tag=bar'.format(organizer.slug, event.slug) + ) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?active=true'.format(organizer.slug, event.slug) + ) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?active=false'.format(organizer.slug, event.slug) + ) + assert [] == resp.data['results'] + + voucher.redeemed = 1 + voucher.save() + res['redeemed'] = 1 + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?active=false'.format(organizer.slug, event.slug) + ) + assert [res] == resp.data['results'] + + voucher.redeemed = 0 + voucher.valid_until = (timezone.now() - datetime.timedelta(days=1)).replace(microsecond=0) + voucher.save() + res['valid_until'] = voucher.valid_until.isoformat().replace('+00:00', 'Z') + res['redeemed'] = 0 + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/vouchers/?active=false'.format(organizer.slug, event.slug) + ) + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_voucher_detail(token_client, organizer, event, voucher, item): + res = dict(TEST_VOUCHER_RES) + res['item'] = item.pk + res['id'] = voucher.pk + res['code'] = voucher.code + + resp = token_client.get('/api/v1/organizers/{}/events/{}/vouchers/{}/'.format(organizer.slug, event.slug, + voucher.pk)) + assert resp.status_code == 200 + assert res == resp.data diff --git a/src/tests/api/test_waitinglist.py b/src/tests/api/test_waitinglist.py new file mode 100644 index 0000000000..effbefc73d --- /dev/null +++ b/src/tests/api/test_waitinglist.py @@ -0,0 +1,103 @@ +import datetime +from unittest import mock + +import pytest +from pytz import UTC + +from pretix.base.models import WaitingListEntry + + +@pytest.fixture +def item(event): + return event.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def wle(event, item): + testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + return WaitingListEntry.objects.create(event=event, item=item, email="waiting@example.org", locale="en") + + +TEST_WLE_RES = { + "id": 1, + "created": "2017-12-01T10:00:00Z", + "email": "waiting@example.org", + "voucher": None, + "item": 2, + "variation": None, + "locale": "en" +} + + +@pytest.mark.django_db +def test_wle_list(token_client, organizer, event, wle, item): + var = item.variations.create(value="Children") + res = dict(TEST_WLE_RES) + wle.variation = var + wle.save() + res["id"] = wle.pk + res["item"] = item.pk + res["variation"] = var.pk + + resp = token_client.get('/api/v1/organizers/{}/events/{}/waitinglistentries/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?item={}'.format(organizer.slug, event.slug, item.pk)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?item={}'.format(organizer.slug, event.slug, item.pk + 1)) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?variation={}'.format(organizer.slug, event.slug, var.pk)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?variation={}'.format(organizer.slug, event.slug, var.pk + 1)) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?email=waiting@example.org'.format( + organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?email=foo@bar.sample'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?locale=en'.format( + organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?locale=de'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?has_voucher=false'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?has_voucher=true'.format(organizer.slug, event.slug)) + assert [] == resp.data['results'] + + v = event.vouchers.create(item=item, price_mode='set', value=12, tag='Foo') + wle.voucher = v + wle.save() + res['voucher'] = v.pk + resp = token_client.get( + '/api/v1/organizers/{}/events/{}/waitinglistentries/?has_voucher=true'.format(organizer.slug, event.slug)) + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_wle_detail(token_client, organizer, event, wle, item): + res = dict(TEST_WLE_RES) + res["id"] = wle.pk + res["item"] = item.pk + resp = token_client.get('/api/v1/organizers/{}/events/{}/waitinglistentries/{}/'.format(organizer.slug, event.slug, + wle.pk)) + assert resp.status_code == 200 + assert res == resp.data diff --git a/src/tests/control/test_teams.py b/src/tests/control/test_teams.py index 520dddc4e7..a7a8b251cb 100644 --- a/src/tests/control/test_teams.py +++ b/src/tests/control/test_teams.py @@ -76,6 +76,33 @@ def test_team_create_invite(event, admin_user, admin_team, client): assert len(djmail.outbox) == 1 +@pytest.mark.django_db +def test_team_create_token(event, admin_user, admin_team, client): + client.login(email='dummy@dummy.dummy', password='dummy') + djmail.outbox = [] + + resp = client.post('/control/organizer/dummy/team/{}/'.format(admin_team.pk), { + 'name': 'Test token' + }, follow=True) + assert 'Test token' in resp.rendered_content + assert admin_team.tokens.first().name == 'Test token' + assert admin_team.tokens.first().token in resp.rendered_content + + +@pytest.mark.django_db +def test_team_remove_token(event, admin_user, admin_team, client): + client.login(email='dummy@dummy.dummy', password='dummy') + + tk = admin_team.tokens.create(name='Test token') + resp = client.post('/control/organizer/dummy/team/{}/'.format(admin_team.pk), { + 'remove-token': str(tk.pk) + }, follow=True) + assert tk.token not in resp.rendered_content + assert 'Test token' in resp.rendered_content + tk.refresh_from_db() + assert not tk.active + + @pytest.mark.django_db def test_team_revoke_invite(event, admin_user, admin_team, client): client.login(email='dummy@dummy.dummy', password='dummy')