From b2d4bea1d071d92a8a6e233c65666c8cb9e8f5e1 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 19 Jun 2017 11:16:04 +0200 Subject: [PATCH] Refs #314 -- Read-only REST API (#513) * initial commit * API auth * Hierarchical URLs * Add session auth * Strong hierarchy * Add filters * Add i18n fields, questions * More viewsets and serializers * Ticket download * Add OrderPosition serializer * View-level permissions * More tests * More tests * Add basic API docs * Add REST API to docs frontpage * Tests for order endpoints * Add invoice tests * Voucher and waitinglist tests * Doc draft * order docs * Docs on all viewsets * Disable DRF docs, style sphinx, style browsable API * Fix tests * deprecated imports * Test foo * Attendee names * Fix migration problems * Remove browsable API, plugin integration * Doc fixes --- doc/_templates/index.html | 18 +- .../pretix_theme/static/css/pretix.css | 13 +- doc/api/fundamentals.rst | 145 ++++++ doc/api/img/token_form.png | Bin 0 -> 9842 bytes doc/api/img/token_success.png | Bin 0 -> 20595 bytes doc/api/index.rst | 17 + doc/api/resources/categories.rst | 108 ++++ doc/api/resources/events.rst | 118 +++++ doc/api/resources/index.rst | 16 + doc/api/resources/invoices.rst | 187 +++++++ doc/api/resources/items.rst | 228 ++++++++ doc/api/resources/orders.rst | 485 ++++++++++++++++++ doc/api/resources/organizers.rst | 90 ++++ doc/api/resources/questions.rst | 145 ++++++ doc/api/resources/quotas.rst | 103 ++++ doc/api/resources/vouchers.rst | 160 ++++++ doc/api/resources/waitinglist.rst | 119 +++++ doc/conf.py | 2 +- doc/contents.rst | 1 + doc/development/api/customview.rst | 49 ++ doc/development/index.rst | 4 +- doc/plugins/pretixdroid.rst | 5 +- src/pretix/api/__init__.py | 0 src/pretix/api/auth/__init__.py | 0 src/pretix/api/auth/permission.py | 43 ++ src/pretix/api/auth/token.py | 21 + src/pretix/api/serializers/__init__.py | 0 src/pretix/api/serializers/event.py | 10 + src/pretix/api/serializers/i18n.py | 31 ++ src/pretix/api/serializers/item.py | 64 +++ src/pretix/api/serializers/order.py | 110 ++++ src/pretix/api/serializers/organizer.py | 8 + src/pretix/api/serializers/voucher.py | 10 + src/pretix/api/serializers/waitinglist.py | 9 + src/pretix/api/templates/__init__.py | 0 .../api/templates/rest_framework/api.html | 19 + src/pretix/api/urls.py | 36 ++ src/pretix/api/views/__init__.py | 0 src/pretix/api/views/event.py | 14 + src/pretix/api/views/item.py | 67 +++ src/pretix/api/views/order.py | 181 +++++++ src/pretix/api/views/organizer.py | 20 + src/pretix/api/views/voucher.py | 40 ++ src/pretix/api/views/waitinglist.py | 31 ++ src/pretix/base/middleware.py | 7 +- .../migrations/0062_auto_20170602_0948.py | 28 + src/pretix/base/models/invoices.py | 8 +- src/pretix/base/models/organizer.py | 86 ++++ src/pretix/base/services/invoices.py | 13 +- src/pretix/base/services/tickets.py | 41 ++ src/pretix/control/logdisplay.py | 6 + .../control/templates/pretixcontrol/base.html | 2 +- .../organizers/team_members.html | 44 +- src/pretix/control/views/organizer.py | 45 +- src/pretix/presale/views/order.py | 42 +- src/pretix/settings.py | 20 + .../rest_framework/scss/_variables.scss | 2 + .../static/rest_framework/scss/main.scss | 10 + src/pretix/urls.py | 3 + src/requirements/production.txt | 7 +- src/tests/api/__init__.py | 0 src/tests/api/conftest.py | 47 ++ src/tests/api/test_auth.py | 53 ++ src/tests/api/test_events.py | 32 ++ src/tests/api/test_items.py | 268 ++++++++++ src/tests/api/test_orders.py | 321 ++++++++++++ src/tests/api/test_organizers.py | 20 + src/tests/api/test_permissions.py | 109 ++++ src/tests/api/test_vouchers.py | 201 ++++++++ src/tests/api/test_waitinglist.py | 103 ++++ src/tests/control/test_teams.py | 27 + 71 files changed, 4213 insertions(+), 59 deletions(-) create mode 100644 doc/api/fundamentals.rst create mode 100644 doc/api/img/token_form.png create mode 100644 doc/api/img/token_success.png create mode 100644 doc/api/index.rst create mode 100644 doc/api/resources/categories.rst create mode 100644 doc/api/resources/events.rst create mode 100644 doc/api/resources/index.rst create mode 100644 doc/api/resources/invoices.rst create mode 100644 doc/api/resources/items.rst create mode 100644 doc/api/resources/orders.rst create mode 100644 doc/api/resources/organizers.rst create mode 100644 doc/api/resources/questions.rst create mode 100644 doc/api/resources/quotas.rst create mode 100644 doc/api/resources/vouchers.rst create mode 100644 doc/api/resources/waitinglist.rst create mode 100644 src/pretix/api/__init__.py create mode 100644 src/pretix/api/auth/__init__.py create mode 100644 src/pretix/api/auth/permission.py create mode 100644 src/pretix/api/auth/token.py create mode 100644 src/pretix/api/serializers/__init__.py create mode 100644 src/pretix/api/serializers/event.py create mode 100644 src/pretix/api/serializers/i18n.py create mode 100644 src/pretix/api/serializers/item.py create mode 100644 src/pretix/api/serializers/order.py create mode 100644 src/pretix/api/serializers/organizer.py create mode 100644 src/pretix/api/serializers/voucher.py create mode 100644 src/pretix/api/serializers/waitinglist.py create mode 100644 src/pretix/api/templates/__init__.py create mode 100644 src/pretix/api/templates/rest_framework/api.html create mode 100644 src/pretix/api/urls.py create mode 100644 src/pretix/api/views/__init__.py create mode 100644 src/pretix/api/views/event.py create mode 100644 src/pretix/api/views/item.py create mode 100644 src/pretix/api/views/order.py create mode 100644 src/pretix/api/views/organizer.py create mode 100644 src/pretix/api/views/voucher.py create mode 100644 src/pretix/api/views/waitinglist.py create mode 100644 src/pretix/base/migrations/0062_auto_20170602_0948.py create mode 100644 src/pretix/static/rest_framework/scss/_variables.scss create mode 100644 src/pretix/static/rest_framework/scss/main.scss create mode 100644 src/tests/api/__init__.py create mode 100644 src/tests/api/conftest.py create mode 100644 src/tests/api/test_auth.py create mode 100644 src/tests/api/test_events.py create mode 100644 src/tests/api/test_items.py create mode 100644 src/tests/api/test_orders.py create mode 100644 src/tests/api/test_organizers.py create mode 100644 src/tests/api/test_permissions.py create mode 100644 src/tests/api/test_vouchers.py create mode 100644 src/tests/api/test_waitinglist.py 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. +

+
+
+
-
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 0000000000000000000000000000000000000000..f4f24a1c7cc908e7bcee0be22c9ddd33973ff8e0 GIT binary patch literal 9842 zcmeHtWn9zk7dH~pE!`m?Aw89bfwXjtl9Uk>kdTsQfJnEaMu{{y21u7QPC<~-jdX_~ zJiBji{l9(QJnx=2`|R`Eu5+F1e9v{_dwwyxPgF^X7>Tg3ut?R_l=ZQ&upyZ5z67^1 zpZN*B?O0gsSnA4(hQ3xiv%$KlXRX5f4eF*;7J@#7m6^=QAKp89(KGt3IL8x^&y=4_ z>AC#P7H7i7!V#PrhGJaV3={GlS?q**2Nw2>83A0m0iom3Qj{er^+LW0=>{ZAmA)w; zG>X4zZ z2E*`kdU*8z3WfsYaQ^I}Yt2d^g78DB@2uYYe*M#OgUql%MrEt>fgj($I{YIfGphpu zG&eC~MCO4*_;j!p=2^I6h2NdnK#M?_Ui#~CY7P%b65C|N>St^ct4TV48gIy&hB z_w80HZW-whu$&(a4CrV4{d)%gDIsDR>I4OdzlmvfFHyN){`&Fm>R5ChyDn)YAdXDW zOjV4DKBRb>#8XwcLSyUOqMF<*Mh42N0f-bFf^`BNdFU*f-=-^XN!KuM2R`EG)p`8i zaYOL2$A}@?k@!2j6j9N8hE1k)ct$A_C3e)oI9_VGi)@~7AK z7Nvt7n;yH~1*m72gCV$;mQKF!fN_l}lS$PtO8}pNu;RIpo^TUXYZ82`5J?BTLG}=*zb}CTBu-tr*~za24fo!1h!|NG7WG zOzg`(IAb8BRkb=F_<_=Iow#Kb9LTU?KmS>l49oiV~5CyG%0k*^2(0(sswYrNVXY2z3~Sa-sOGwKT;`I?xJ`sO8ykvY!p zE^kskOS}!vnzQ{obxHll!QX83qRlEPDa3aH2sHu&V1`9>@*BR4NalDHl&?o6Q*SK) zu~#;$-bdX>B`n&KCc2&tOcONH9%V92qW2#mwtiA_ug)kmL?~G*KSaD$ATj;;h2!ko zR~wb@xkq0eDFd(W{dWU1qp(?_fOTwK7Ba*1Zsb;PNW_~ReTF)<$R8gKGiL<3awHI- z73Ss@=2?N7^*~9eXCyv+B<5)z>90;k(>@l~ov?e-gsLtd;ZwdO???#mmmA}wp7Sd| zM&gP@EW0;R#*EJJuD_&13u@Q-a#oVOG+KP9s~>j9QN$1}&c36Q`mMTBfFk21V-u{t z8;<<-F`|ii+zVW@GwwhSurRdqW!2+!ovM$v%0> zJ_!}`AnrzQ7tRF>d@=GoBS6;gU{sTjCqoR0$0J}_jPjVuBv>b~HVBeAZ5tCZs?&I% z9A}v*GNlyZ*_TjQ0cYAQwc`_3r3#D!NWGAizuRWn#g~#e)~znX7X?17A(! z49SI?4arrbkfE2W4>M1t7%QZ2XoJWVm|QSt6yrYzrzn_>5=r7ae`B6(ye} zejr^28AX*aPEwQJc74)}t%(TSLmUP0*!MB3#F3b1SbR>GVv+xQl~Y16)@{P-3@e=( z;`5kYUcPqX=1zkfy~IkZyH%;_G5Bni1j6qyM4m%JKG^46pI<)v#(3%wcv0G&qUS4x z!{wQ9284h-I+=>3`aGK6Jf?JD&^x4Dblh4}?;sXLdr<%b zpEtvfI0c^731-@xXo7WbRpaJtxOSy+VZ)>qN2;h{c1vP{o(t^?`P)QcH&0*HwTj3- zNW>Fa#bEr$><4Nsg-Sn45um%w?ZS)SEzyj^h^hM=<+kMxy+3-LXTTnYqTWnNFBYO< zX=QQ;Bz2U3aMx0_ehnW1sTaEQ;Rq3+` zt?D~93#I}3BxD`I;bvQQ z>C=*}HbRrZ(N5E`Fso8Z@9)u6lW8;tu9W13f8yXSUI{Hao0y`D2d&&d{EcB6-eN1U znK|s|>8DpJ90@44Jtr4C(99b10nLpr_Z} z%9k?f7s>W8AvyKi{q+ygea{+Lsb4mqDZ!4U(xFO44i?I>8c>_+ArZkojrD8Z1fl=x z312>uNEDz2i^IY=igC7r%-hOS;na0n&@Q31kd|9!4)rWiNWQ8=%GRMc_H|vqb%vlA zLAs4LNVe>yxZ`k>zah&@s&5xR;Y~iz&z!_FxP(-U6Hf=jBB%-lAJWU&n0UXfV1pPA(5c{K{uZs0o)LI2CUNgvgp$ZQekUcPG zpTQE4B0GWNaU(4qRZF!|&x#N>f8(0kz=BI=njbG6=DOZoY;XGEYC*B(en9s?0Gxsq z4lKRX|8eBy7w+oVxNstT%G|@B`bKmRu%cfo_bc7G;CFfJU#vr3x4z#iRE>)KWPOM#8am&v#MUa9`nvk079%~#>X^?e8wN+1{X)62 zxeYW|?40(phJU)OqQlv5Zv(kEMdA-LZzXvz1{?|KyrujH8GMfH zDj)(HDgh3~3-SZCFTB3xeToq`asN=vq+6{JA%_>0z-?WB6wf88UGEfVER_8amqPT>UV$S6M>{H%{QaG^(vL< z%;mX;dkTN9Mx?c+M1drW$Shc~nxt=6=4sqEfpz|SikJZ50;9_APmS)l4RgxcKzze5 zO{Fp{WDYO&HBkvs+75xOH1p{g|xI5@k>l*wfFEz9|uC0{X#j^x>@7x#W${ z+d4aU#HZK$-5EAohy^bts#J>yr(BEG`dxYHueAPmU|@$dTw6K>dS$5=@D68lqg$<} zExzvd`lcSp>1{1bpPLyxMlQteo*v4}h(mgvNa``B^teGrB1s4#58&v`s59aRKoD$G z4)LBMEjvM0%tGpl5DJhY5~CU-|J&W}6W_vQ-`b6j@m$R>+zU-e>p#-9wvM7EBD&67 z8^6OpAq{xwi{3mFb>O-NllnW}K}!I;3kB>=k@~cP55?d1+A6w>m~fu=QV+uZOSHNj zJPWNT9$uGfjqdwdUOZd)E6e&$nkgEvVLyW{fKv5`f`Rdncq@SlaUux3j$%=QYHZNH zGmO#Zi)bB{Hx|oUb0b?IR@z;Q=nlv1KOygia^D%8vWj#6Xc+onl10R1+1H0dOUM|+ z!WenZg`Z!Jj$Ia-emv@Q1ojp!8KPvimSiRbcY`Txj=1Pp;8v$WLtSF-hU%#*cGIVOKP#t>{)LJTF* z(;NO*a>opT79(2j=SE?Z6GQd?qB(752eELA$S#XGLZ~Jxv_bPf=Ly*(8s>_TcC z{bSi>&S5df#Z$ok)n z%_PT8V3qCS%?k`eA^{ba%|061fWYxnFB#z&6BJ4hx?}u9l)$IkW^2k`fXN zy>w5N6%;~L>6ZWVhtLb|{5Sls_B-*TU%!5<|A7Hr($f>Jyif~8dT&ikX?*&_k6jrl zFmSedu9E>_?LPa=b9BII=B1;r|5|M!7=!Bdtfr=t3trXx)VGa*~x?DwweC*O;ly%T>Db zhP;!w8(AolQ2Sl0&h+x!VPS7(d|2l%KJ6Uz?I>0m_2=X}E=>^yA2QslUr{KEM(L(D z-HLVe*AJTa43(oMxpby9$c}2H_}o6~_AiwegRy}N`0`@}vYmwp;-(2sdG+hvvELz+ z%YHbJ(Yl6O5pWmP)$(vlFznF|7(&9&3NU#G$~lketz`BAjyIbl%nxSR~*ai!$<7kqb~e&Qmz`jK#s zliQcfNS5G#JXYL7**T^GGqP%t1~+SvYvEOQA^6E}6DlKhI&8JlV_4n5F3NhZDLJ5= zMZO$Yz7f(X;gutNuZr&|f>ba$?^UBwVvNfck@Y?tH8JK(;~m{Q(Vvwz>+ej`-|~TQ z9jau`X0{?6Hd-%D(fooZr(+Kj?XJ0NP{8T08FmLcml?JNmB;dzv#O=%2eMaRt~G=LszmDuJXcfLIbG2roXyrx1N-j51qc@ zhF6-jolTepo<3u7-o&RkUt-*rqJ&;QsP=pUw1&dDD14=3+Sd<4J1aEX4Z@p!i&a9a^g zzx_m8C40gOsMluxT6rU&3Vy0c{-SA6w)Q^BTzr&IO_ z!t@mTxkYztuAFHtGgI=Jox#mrdf{21V;+quYfKODN3`Yh>T}8G{H&DOKR1aIt=dSY zdhekg!GuFcG7Un|6F~vZt2A~=m$6Ariji&Uu* zI1btPqwd3{2sqGc+jDuZ^EDqm3@Bvz^`eU1&m0sjrx~{W9sUE*`^%YYcqfNGweGY` z`+dLG>j)wJdpsufRDm#$AINOYT_J^XRSkct?JUg$K85uXp_&P@0Oj2$DrHtKZu6NM zFp`wB0TI8Cg+ha#2Cfl5avAg`p=)QlC7*(_CaccYL;c{Y+1;ABR&_k3QIrOfqwHMB$vWpZ6XTsDWAEC zuV>acTM@p)s(Fiz)^r2DQ!f9-?(>tA9@+6}=+MnMQ{clVVB+y$V)uY`?%*@|Q854^J2Zw^CU@ zKeYiLZK!^_gpa%-2Hl>S*ikiJx-A&a(4ZNsi*8a6%6cBDe@;&ZDM$H8vO&>lYM(V# ziXuL?nXCBL%V5QT?nS39-|!tlMwfhk~V;5ZYAd2fB0Bk$weSSS8#e&niBvc_>EH6vgdY%AJ_r_Bt#o zhHo@RW&Rs;i+($0ub;;$dxYFLO;KSW2=Lkf$_ZP>E}X86sAHsm;SAUQtW;o8)82%Q zhu2WcZ;meikXO#hO$}X9!zGoA0Ci0-NhX=z+tKavhG>Z%@{}S@5Hm`#8c}Kxg4<~w zu%r9wjx^l>|M!_I50qeFg;TP-5hwlol@U9mLcSvuyq$xKU)m{EkG|F_{$M;ZT4AcP zahPCwLH47C5D1fbkoBmsm7nG^Wn#$lE8$T$d&ReVH}o6f7slch=z7$Bas{1P&`K&9 z=ZOU^cVEGdKCRWO8k}`YsA^h>3^ST1fF}o%&|YK!;r zOZu8u<{;URAmaovgx_)a>IrIsuYDy}^iG{?T&)JylnFF(+m?{78!qH#DHNYU$|-8n zm0YPi8O{jg*;SL|cLCo|CZR5&%@>}H*1G3?55CiQarcgp?|&9-}liaFo= z5Wr+m9x`D`?<4&qef6fk#}9-{&wO7c!%*l`x3VV3(9Xk}aT zu5RqQq#w`Wr(gme7rq-z)xXaX8p(=&?A2>hQcIy|oV`2vG;BJ4@a>DXjz&_oubQhu zm*l1ehG;{5Cr^?LPWC$oH~0`)Uz{kVJl57}9x(o>nF?FOl2-b7e1*N$YE6RS z_&K!haU|Ojp8Xnv>!77oWBDqxSR0yaPw(LHUMN3xu*YYTV@R~(^y%@47;eTgbC?14 zVw+2}7iqu<)#K5!M5Ao}?mRJkT_vZTpOdbN9sEYUuf!yr`82|OhA;Yz3APSTmqw)R zTqc0A@@OqSevtyo^8EQa9P?wBV)VOgUw*iD8K{KFa2!xr`8g=AZS+Zn*J*;SElCnm zyS_hcDeq^gX4Y4Tv=&H{bO-|S8XO` zod7JK7Cnh#6yQR9N*pPCmtvA;ySLcrK78f_n-HruX7rkgsgpFLw73vLzk&pj!6yws z&+o2|=7OEuLRrhF%dp0?mSLwMqMqvbYeu19f9xTv^hc1(98YxWve$-iV@%$S*&5>2 zGShdXb4g4`cI;?hr8M21nQo&gX#J5U?3b9j6~mJh1UFoJ&`XS4at_W~)Vty=N}BI(iOf`jSBc z$-Oq}TE4e%^iT0>Sv0H~AV6hyvcB<9dLYOJSuNE0DKCgxr}cvP>I!Fa#62(U2@%hH zi>JY_r#pLYTL!~&UY^Tv3HAAe%nbV=99?*U}&n3=8I$desu z-iKG_liypn=ZF$d88BvgR6Wl1$ryx)`fk=_b$~Lh#;xKcw0JQ$09CEDZL5BWl@;F< z+x^lZZ|m9Ij{X;fr3uu*5@`Nm*oldem5rv9_Z819t~(Tyb@C*xqNT~V)g66=pjb3AJg(^49oBLMF=2usH0%Krd?>d|3Aa3= z$0VW9<697@_M;VTjaNaXHf?wnX(z;?YHsOQ|EzFlTRUETuUo;D6@^dxv%IkqL*X+9 zy5&UuTGC{i=CV;eBh)~yIuz-5(ZJ25eTy6q!#&_GFB;Hh_QADx^{~5(^|`75$YtTW z6J^sCm$9y~;y=xttpx+<8b$^;bJ?e=l&2=RsQo=$+7c5!)RUv8|s%_(|?yhcir%!BM#7eeLmI8JCvQ%*?-X$3^pFEKODzI5&s$OYULq|a}j{vCNm zkX&i2@1SNF_ICK2v^h5CMjMcsT$y^+{{B-w;T4k zrBNJ^jD5$H9pmf7MTP4N00#6L)-MmsugJXVZ}s|=XwgyB#G*oST zn7%N!b%_mP3qDE+V)mq)KuTq&*$j3nU%IciTwsWPSd}dhLm8c6qy~yT)5Ns(Nc;8e zmeWYQNq?osZLH3t=;g&ohJRh)PJT!=hGn`iOE}jFphsW56ZYf%-I(KG=R%QP83G5r zytjN02`4837aegmgmQQHFSz{1oPSdxrIxW6pj77*!yS=2uy-cQ2mkRkO_ci*h*`c% ztahpSHyym;^fPNELxI<28rDqbEo@)ZgJbvN=F)7EC&UpKfoFB0|1J|T!R!er&M9fw z&4>TIB{;kL6fS3S*0uE$s!+p2pf_zVP{vky)TlT{1Q{6_As{9WH>*5754*ztOjvZL8GUq4>rDV$(+r2dd>t|<^IR|1YkoB-=<-xAPxx$ zuhaWo;s(~Qu`!a3%W2qN9aE+Yg2IY5GnTR(bF-FKed&L1(Q$@%{d2N?=^w`HABu<0 z%2&~9`N5`aP+KsuE1Zo8!|2>2({SI~+^mxw&kOqbp6%;?`oEJHYdFzAtQfMO=fh{Y zp$?=j8#Y_Zo>LTnt%VK{^OsdSuf(L>c^@ISzcZd-3>A0uZf>RZ4^_F)fPpq>y1qs9 z4}*859s(c9^G`YZZ`zd~GEam-6*77F>CY5spBKYi25)N55&u3(2zHPF#+4c@hnW7% uW;^mQ6y(+W2Y$l8QN-l`|0`u~*pedkkOifE=a_fNu+&waC|4<2hy5Qt-z2aA literal 0 HcmV?d00001 diff --git a/doc/api/img/token_success.png b/doc/api/img/token_success.png new file mode 100644 index 0000000000000000000000000000000000000000..c0551d2fc92412159aa870c3bfd1f3fc0c7fdf1f GIT binary patch literal 20595 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 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')