diff --git a/.gitattributes b/.gitattributes index 30ebb98ebd..3fdddca17b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,4 +3,18 @@ src/static/lightbox/* linguist-vendored src/static/typeahead/* linguist-vendored src/static/moment/* linguist-vendored src/static/datetimepicker/* linguist-vendored +src/static/colorpicker/* linguist-vendored +src/static/fileupload/* linguist-vendored src/static/charts/* linguist-vendored +src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/fabric.* linguist-vendored +src/pretix/plugins/ticketoutputpdf/static/pretixplugins/ticketoutputpdf/pdf.* linguist-vendored + +# Denote all files that are truly binary and should not be modified. +*.eot binary +*.otf binary +*.ttf binary +*.woff binary +*.zip binary +*.png binary +*.gif binary +*.jpg binary diff --git a/doc/conf.py b/doc/conf.py index 6362eb49c5..e7dea7c479 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -55,16 +55,17 @@ master_doc = 'index' # General information about the project. project = 'pretix' -copyright = '2014-2016, Raphael Michel' +copyright = '2014-2017, Raphael Michel' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '0.0.0' +from pretix import __version__ +version = '.'.join(__version__.split('.')[:2]) # The full version, including alpha/beta/rc tags. -release = '0.0.0' +release = __version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/doc/development/contribution/general.rst b/doc/development/contribution/general.rst index e9cba898be..7b450b6761 100644 --- a/doc/development/contribution/general.rst +++ b/doc/development/contribution/general.rst @@ -18,8 +18,9 @@ If you improved pretix in any way, we'd be very happy if you contribute it back to the main code base! The easiest way to do so is to `create a pull request`_ on our `GitHub repository`_. -Before you do so, please `squash all your changes`_ into one single commit. Please -use the test suite to check whether your changes break any existing features and run +We recommend that you create a feature branch for every issue you work on so the changes can +be reviewed individually. +Please use the test suite to check whether your changes break any existing features and run the code style checks to confirm you are consistent with pretix's coding style. You'll find instructions on this in the :ref:`checksandtests` section of the development setup guide. @@ -34,4 +35,3 @@ Again: If you get stuck, do not hesitate to contact any of us, or Raphael person .. _create a pull request: https://help.github.com/articles/creating-a-pull-request/ .. _GitHub repository: https://github.com/pretix/pretix -.. _squash all your changes: https://davidwalsh.name/squash-commits-git diff --git a/doc/development/implementation/models.rst b/doc/development/implementation/models.rst index bda59b9cb8..21f48a9731 100644 --- a/doc/development/implementation/models.rst +++ b/doc/development/implementation/models.rst @@ -20,13 +20,10 @@ Organizers and events .. autoclass:: pretix.base.models.Organizer :members: -.. autoclass:: pretix.base.models.OrganizerPermission - :members: - .. autoclass:: pretix.base.models.Event :members: -.. autoclass:: pretix.base.models.EventPermission +.. autoclass:: pretix.base.models.Team :members: .. autoclass:: pretix.base.models.RequiredAction @@ -67,7 +64,7 @@ Carts and Orders :members: .. autoclass:: pretix.base.models.QuestionAnswer -:members: + :members: .. autoclass:: pretix.base.models.Checkin :members: @@ -92,5 +89,3 @@ Vouchers .. autoclass:: pretix.base.models.Voucher :members: - -.. _cleanerversion: https://github.com/swisscom/cleanerversion diff --git a/doc/development/setup.rst b/doc/development/setup.rst index 13fe4d9379..55b4a89fd0 100644 --- a/doc/development/setup.rst +++ b/doc/development/setup.rst @@ -83,6 +83,10 @@ As we did not implement an overall front page yet, you need to go directly to http://localhost:8000/control/ for the admin view or, if you imported the test data as suggested above, to the event page at http://localhost:8000/bigevents/2017/ +.. note:: If you want the development server to listen on a different interface or + port (for example because you develop on `pretixdroid`_), you can check + `Django's documentation`_ for more options. + .. _`checksandtests`: Code checks and unit tests @@ -148,3 +152,7 @@ To build the documentation, run the following command from the ``doc/`` director make html You will now find the generated documentation in the ``doc/_build/html/`` subdirectory. + + +.. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver +.. _pretixdroid: https://github.com/pretix/pretixdroid diff --git a/doc/index.rst b/doc/index.rst index d33932818a..aecab08637 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -6,6 +6,7 @@ Contents: .. toctree:: :maxdepth: 2 + user/index admin/index development/index plugins/index diff --git a/doc/plugins/list.rst b/doc/plugins/list.rst index eb60aaec34..6b37687e11 100644 --- a/doc/plugins/list.rst +++ b/doc/plugins/list.rst @@ -21,6 +21,7 @@ same team: * `Pages`_ * `Passbook/Wallet ticket output`_ * `Cartshare`_ +* `Fontpack Free fonts`_ The following plugins are from independent third-party authors, so we can make no statements about their stability: @@ -34,3 +35,4 @@ no statements about their stability: .. _Pages: https://github.com/pretix/pretix-pages .. _esPass ticket output: https://github.com/esPass/pretix-espass .. _IcePay integration: https://github.com/chotee/pretix-icepay +.. _Fontpack Free fonts: https://github.com/pretix/pretix-fontpack-free diff --git a/doc/plugins/pretixdroid.rst b/doc/plugins/pretixdroid.rst index e0a5db573b..c9840f13fe 100644 --- a/doc/plugins/pretixdroid.rst +++ b/doc/plugins/pretixdroid.rst @@ -4,6 +4,10 @@ pretixdroid HTTP API The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_ uses to communicate with the pretix server. +.. warning:: This API is intended **only** to serve the pretixdroid Android app. There are no backwards compatibility + guarantees on this API. We will not add features that are not required for the Android App. There will be + a proper general-use API for pretix at a later point in time. + .. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/ Redeems a ticket, i.e. checks the user in. @@ -19,6 +23,16 @@ uses to communicate with the pretix server. secret=az9u4mymhqktrbupmwkvv6xmgds5dk3 + You can optionally include the additional parameter ``datetime`` in the body containing an ISO8601-encoded + datetime of the entry attempt. If you don't, the current date and time will be used. + + You can optionally include the additional parameter ``force`` to indicate that the request should be logged + regardless of previous check-ins for the same ticket. This might be useful if you made the entry decision offline. + + You can optionally include the additional parameter ``nonce`` with a globally unique random value to identify this + check-in. This is meant to be used to prevent duplicate check-ins when you are just retrying after a connection + failure. + **Example successful response**: .. sourcecode:: http @@ -51,9 +65,9 @@ uses to communicate with the pretix server. * ``unknown_ticket`` - Secret does not match a ticket in the database :query key: Secret API key - :statuscode 200: Valid request - :statuscode 404: Unknown organizer or event - :statuscode 403: Invalid authorization key + :statuscode 200: Valid request + :statuscode 404: Unknown organizer or event + :statuscode 403: Invalid authorization key .. http:get:: /pretixdroid/api/(organizer)/(event)/search/ @@ -97,6 +111,46 @@ uses to communicate with the pretix server. :statuscode 404: Unknown organizer or event :statuscode 403: Invalid authorization key +.. http:get:: /pretixdroid/api/(organizer)/(event)/download/ + + Download data for all tickets. + + **Example request**: + + .. sourcecode:: http + + GET /pretixdroid/api/demoorga/democon/download/?key=ABCDEF HTTP/1.1 + Host: demo.pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Content-Type: text/json + + { + "results": [ + { + "secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3", + "order": "ABCE6", + "item": "Standard ticket", + "variation": null, + "attendee_name": "Peter Higgs", + "redeemed": false, + "paid": true + }, + ... + ], + "version": 2 + } + + :query key: Secret API key + :statuscode 200: Valid request + :statuscode 404: Unknown organizer or event + :statuscode 403: Invalid authorization key + .. http:get:: /pretixdroid/api/(organizer)/(event)/status/ Returns status information, such as the total number of tickets and the diff --git a/doc/user/index.rst b/doc/user/index.rst new file mode 100644 index 0000000000..2280b87949 --- /dev/null +++ b/doc/user/index.rst @@ -0,0 +1,9 @@ +User Guide +========== + +Contents: + +.. toctree:: + :maxdepth: 2 + + payments/index diff --git a/doc/user/payments/banktransfer.rst b/doc/user/payments/banktransfer.rst new file mode 100644 index 0000000000..46bdd5a11d --- /dev/null +++ b/doc/user/payments/banktransfer.rst @@ -0,0 +1,31 @@ +.. _`banktransfer`: + +Bank transfer +============= + +To accept payments with bank transfer, you only need to fill one important field in pretix' settings: In "Bank +account details" you should specify everything one needs to know to transfer money to you, e.g. your IBAN and BIC, +the name of your bank and for international transfers, preferably also your address and the bank's address. + +pretix will automatically tell the user to include the order code in the payment reference so incoming transfers can +automatically be matched to payments. + +Importing payment data +---------------------- + +The easiest way to import payment data is to download a CSV file from your online banking. Most banks provide a CSV +export of some sort. You can go to "Import bank data" in pretix to upload a new file: + +.. image:: img/bank1.png + +If you upload a file for the first time, pretix will not know what information is contained in which column as every +bank builds completely different CSV files. Therefore, pretix will ask you for that information. It will show you the +data of the file you imported and ask you to define the column's meanings. You can select one column that contains +the payment date and one that contains the paid amount. You can select multiple columns that contain information +about the payer or the payment reference. All other columns will be ignored. + +Once you continue, pretix will try to match the payments to the respective orders automatically. It will tell you how +many orders could be processed correctly and how many could not. You can then go back to the upload page to see all +transfers from your bank statement that are not yet matched to an order. Using the input field and the buttons on the +left of each transaction, you can manually enter an order code to match it to or just discard it from the list, e.g. +if the transaction is not related to the event at all. diff --git a/doc/user/payments/fees.rst b/doc/user/payments/fees.rst new file mode 100644 index 0000000000..8c9cc4ca5a --- /dev/null +++ b/doc/user/payments/fees.rst @@ -0,0 +1,52 @@ +Payment method fees +=================== + +Most external payment providers like PayPal or Stripe charge substantial fees for your service. In general, you have +two options to deal with this: + +1. Pay the fees yourself + +2. Add the fees to your customer's total + +The choice totally depends on you and what your customers expect from you. Option two might be appropriate if you +offer different payment methods and want to encourage your customers to use the ones that come you cheaper, but you +might also decide to go for option one to make it easier for customers who don't have the option. + +If you go for the second option, you can configure pretix to charge the payment method fees to your user. You can +define both an absolute fee as well as a percental fee based on the order total. If you do so, there are two +different ways in which pretix can calculate the fee. Normally, it is fine to just go with the default setting, but +in case you are interested, here are all the details: + +Payment fee calculation +----------------------- + +If you configure a fee for a payment method, there are two possible ways for us to calculate this. Let's +assume that your payment provider, e.g. PayPal, charges you 5 % fees and you want to charge your users the +same 5 %, such that for a ticket with a list price of 100 € you will get your full 100 €. + +**Method A: Calculate the fee from the subtotal and add it to the bill.** + + For a ticket price of 100 €, this will lead to the following calculation: + + ============================================== ============ + Ticket price 100.00 € + pretix calculates the fee as 5 % of 100 € +5.00 € + Subtotal that will be paid by the customer 105.00 € + PayPal calculates its fee as 5 % of 105 € -5.25 € + End total that is on your bank account **99.75 €** + ============================================== ============ + +**Method B (default): Calculate the fee from the total value including the fee.** + + For a ticket price of 100 €, this will lead to the following calculation: + + ===================================================== ============= + Ticket price 100.00 € + pretix calculates the fee as 100/(100 - 5) % of 100 € +5.26 € + Subtotal that will be paid by the customer 105.26 € + PayPal calculates its fee as 5 % of 105 € -5.26 € + End total that is on your bank account **100.00 €** + ===================================================== ============= + + Due to the various rounding steps performed by pretix and by the payment provider, the end total on + your bank account might stil vary by one cent. diff --git a/doc/user/payments/img/bank1.png b/doc/user/payments/img/bank1.png new file mode 100644 index 0000000000..97c45a6739 Binary files /dev/null and b/doc/user/payments/img/bank1.png differ diff --git a/doc/user/payments/img/bank2.png b/doc/user/payments/img/bank2.png new file mode 100644 index 0000000000..68f004278e Binary files /dev/null and b/doc/user/payments/img/bank2.png differ diff --git a/doc/user/payments/img/paypal1.png b/doc/user/payments/img/paypal1.png new file mode 100644 index 0000000000..3f8135478b Binary files /dev/null and b/doc/user/payments/img/paypal1.png differ diff --git a/doc/user/payments/img/paypal2.png b/doc/user/payments/img/paypal2.png new file mode 100644 index 0000000000..30d607078f Binary files /dev/null and b/doc/user/payments/img/paypal2.png differ diff --git a/doc/user/payments/img/paypal3.png b/doc/user/payments/img/paypal3.png new file mode 100644 index 0000000000..33c29ad6ac Binary files /dev/null and b/doc/user/payments/img/paypal3.png differ diff --git a/doc/user/payments/img/paypal4.png b/doc/user/payments/img/paypal4.png new file mode 100644 index 0000000000..b8e806fbc4 Binary files /dev/null and b/doc/user/payments/img/paypal4.png differ diff --git a/doc/user/payments/img/paypal5.png b/doc/user/payments/img/paypal5.png new file mode 100644 index 0000000000..7333668452 Binary files /dev/null and b/doc/user/payments/img/paypal5.png differ diff --git a/doc/user/payments/img/paypal6.png b/doc/user/payments/img/paypal6.png new file mode 100644 index 0000000000..f20bbd1138 Binary files /dev/null and b/doc/user/payments/img/paypal6.png differ diff --git a/doc/user/payments/img/paypal7.png b/doc/user/payments/img/paypal7.png new file mode 100644 index 0000000000..a7caf2f612 Binary files /dev/null and b/doc/user/payments/img/paypal7.png differ diff --git a/doc/user/payments/img/paypal8.png b/doc/user/payments/img/paypal8.png new file mode 100644 index 0000000000..4e4b7d1dde Binary files /dev/null and b/doc/user/payments/img/paypal8.png differ diff --git a/doc/user/payments/img/paypal_pretix.png b/doc/user/payments/img/paypal_pretix.png new file mode 100644 index 0000000000..9421473c70 Binary files /dev/null and b/doc/user/payments/img/paypal_pretix.png differ diff --git a/doc/user/payments/img/stripe1.png b/doc/user/payments/img/stripe1.png new file mode 100644 index 0000000000..1f86cc6904 Binary files /dev/null and b/doc/user/payments/img/stripe1.png differ diff --git a/doc/user/payments/img/stripe2.png b/doc/user/payments/img/stripe2.png new file mode 100644 index 0000000000..ac073e5387 Binary files /dev/null and b/doc/user/payments/img/stripe2.png differ diff --git a/doc/user/payments/index.rst b/doc/user/payments/index.rst new file mode 100644 index 0000000000..6293be4c38 --- /dev/null +++ b/doc/user/payments/index.rst @@ -0,0 +1,14 @@ +Accepting payments +================== + +Contents: + +.. toctree:: + :maxdepth: 2 + + overview + fees + paypal + stripe + banktransfer + diff --git a/doc/user/payments/overview.rst b/doc/user/payments/overview.rst new file mode 100644 index 0000000000..409d358c2c --- /dev/null +++ b/doc/user/payments/overview.rst @@ -0,0 +1,34 @@ +Payment method overview +======================= + +pretix allows you to accept payments using a variety of payment methods to fit the needs of very different events. +This page gives you a short overview over them and links to more detailled descriptions in some cases. + +Payment methods are built as pretix plugins. For this reason, you might first need to enable a certain plugin at +"Settings" → "Plugins" in your event settings. Then, you can configure them in detail at "Settings" -> "Payment". + +If you host pretix on your own server, you might need to install a plugin first for some of the payment methods listed +on this page as well as for additional ones. + +:ref:`stripe` + Stripe is a US-based company that offers you an easy way to accept credit card payments from all over the world. + To accept payments with Stripe, you need to have a Stripe merchant account that is easy to create. Click on the link + above to get more details about the Stripe integration into pretix. + +:ref:`paypal` + If you want to accept online payments via PayPal, you can do so using pretix. You will need a PayPal merchant + account and it is a little bit complicated to obtain the required technical details, but we've got you covered. + Click on the link above to learn more. + +:ref:`banktransfer` + Classical IBAN wire transfers are a common payment method in central Europe that has the large benefit that it + often does not cause any additional fees. However, it requires you to invest some more effort as you need to + check your bank account for incoming payments regularly. We provide some tools to make this easier for you. + +SEPA debit + In some Europen countries, a very popular online payment method is SEPA direct debit. If you want to offer this + option in your pretix ticket shop, we provide a convenient plugin that allows users to enter their SEPA bank + account details and issue a SEPA mandate. You will then need to regularly download a SEPA XML file from pretix + and upload it to your bank's interface to actually perform the debits. + + diff --git a/doc/user/payments/paypal.rst b/doc/user/payments/paypal.rst new file mode 100644 index 0000000000..742bc4c858 --- /dev/null +++ b/doc/user/payments/paypal.rst @@ -0,0 +1,50 @@ +.. _`paypal`: + +PayPal +====== + +To integrate PayPal with pretix, you first need to have an active PayPal merchant account. If you do not already have a +PayPal account, you can create one on `paypal.com`_. +If you look into pretix' settings, you are required to fill in two keys: + +.. image:: img/paypal_pretix.png + +Unfortunately, it is not straightforward how to get those keys from PayPal's website. In order to do so, you +need to go to `developer.paypal.com`_ to link the account to your pretix event. +Click on "Log In" in the top-right corner and log in with your PayPal account. + +.. image:: img/paypal2.png + +Then, click on "Dashboard" in the top-right corner. + +.. image:: img/paypal3.png + +In the dashboard, scroll down until you see the headline "REST API Apps". Click "Create App". + +.. image:: img/paypal4.png + +Enter any name for the application that helps you to identify it later. Then confirm with "Create App". + +.. image:: img/paypal5.png + +On the next page, before you do anything else, switch the mode on the right to "Live" to get the correct keys. +Then, copy the "Client ID" and the "Secret" and enter them into the appropriate fields in the payment settings in +pretix. + +.. image:: img/paypal6.png + +Finally, we need to create a webhook. The webhook tells PayPal to notify pretix e.g. if a payment gets cancelled so +pretix can cancel the ticket as well. If you have multiple events connected to your PayPal account, you need multiple +webhooks. To create one, scroll a bit down and click "Add Webhook". + +.. image:: img/paypal7.png + +Then, enter the webhook URL that you find on the pretix settings page. It should look similar to the one in the +screenshot but contain your event name. Tick the box "All events" and save. + +.. image:: img/paypal8.png + +That's it, you are ready to go! + +.. _paypal.com: https://www.paypal.com/webapps/mpp/account-selection +.. _developer.paypal.com: https://developer.paypal.com/ diff --git a/doc/user/payments/stripe.rst b/doc/user/payments/stripe.rst new file mode 100644 index 0000000000..fc09005a74 --- /dev/null +++ b/doc/user/payments/stripe.rst @@ -0,0 +1,28 @@ +.. _stripe: + +Stripe +====== + +To integrate Stripe with pretix, you first need to have an active Stripe merchant account. If you do not already have a +Stripe account, you can create one on `stripe.com`_. Then, click on "API" in the left navigation of the Stripe +Dashboard. As you can see in the following screenshot, you will be presented with two sets of API keys, one for test +and one for live payments. In each set, there is a secret and a publishable keys. + +.. image:: img/stripe1.png + +Choose one of the two sets and copy the two keys to the appropriate fields in pretix' settings. To perform actual +payments, you will need to use the live keys, but you can use the test keys to test the payment flow before you go live. +In test mode, you cannot use your real credit card, but only `test cards`_ like ``4242424242424242`` that you can +find in Stripe's documentation. + +If you want Stripe to notify pretix automatically once a payment gets cancelled, so pretix can cancel the ticket as +well, you need to create a so-called webhook. To do so, click "Webhooks" on top of the page in the Stripe dashboard +that you are currently on. Then, click "Add endpoint" and enter the URL that you find directly below the key +configuration in pretix' settings. + +.. image:: img/stripe2.png + +Again, you can choose between live mode and test mode here. + +.. _stripe.com: https://dashboard.stripe.com/register +.. _test cards: https://stripe.com/docs/testing#cards diff --git a/src/MANIFEST.in b/src/MANIFEST.in index 0f99efb6cf..c4c7e3f42d 100644 --- a/src/MANIFEST.in +++ b/src/MANIFEST.in @@ -16,3 +16,5 @@ recursive-include pretix/plugins/statistics/templates * recursive-include pretix/plugins/statistics/static * recursive-include pretix/plugins/stripe/templates * recursive-include pretix/plugins/stripe/static * +recursive-include pretix/plugins/ticketoutputpdf/templates * +recursive-include pretix/plugins/ticketoutputpdf/static * diff --git a/src/make_testdata.py b/src/make_testdata.py index 5c684676c2..580168b800 100644 --- a/src/make_testdata.py +++ b/src/make_testdata.py @@ -23,9 +23,6 @@ user.save() organizer = Organizer.objects.create( name='BigEvents LLC', slug='bigevents' ) -OrganizerPermission.objects.get_or_create( - organizer=organizer, user=user -) year = now().year + 1 event = Event.objects.create( organizer=organizer, name='Demo Conference {}'.format(year), @@ -33,9 +30,13 @@ event = Event.objects.create( date_from=datetime(year, 9, 4, 17, 0, 0), date_to=datetime(year, 9, 6, 17, 0, 0), ) -EventPermission.objects.get_or_create( - event=event, user=user +t = Team.objects.get_or_create( + organizer=organizer, name='Admin Team', + all_events=True, can_create_events=True, can_change_teams=True, + can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True, + can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True ) +t.members.add(user) cat_tickets = ItemCategory.objects.create( event=event, name='Tickets' ) diff --git a/src/pretix/__init__.py b/src/pretix/__init__.py index df6612d704..67bc602abf 100644 --- a/src/pretix/__init__.py +++ b/src/pretix/__init__.py @@ -1 +1 @@ -__version__ = "1.3.0-alpha1" +__version__ = "1.3.0" diff --git a/src/pretix/base/migrations/0051_auto_20170206_2027_squashed_0057_auto_20170501_2116.py b/src/pretix/base/migrations/0051_auto_20170206_2027_squashed_0057_auto_20170501_2116.py new file mode 100644 index 0000000000..e92c3ce2d2 --- /dev/null +++ b/src/pretix/base/migrations/0051_auto_20170206_2027_squashed_0057_auto_20170501_2116.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-05-02 09:04 +from __future__ import unicode_literals + +import django.db.migrations.operations.special +import django.db.models.deletion +import i18nfield.fields +from django.db import migrations, models + +import pretix.base.models.base + + +def migrate_global_settings(apps, schema_editor): + GlobalSetting = apps.get_model('pretixbase', 'GlobalSetting') + GlobalSettingsObject_SettingsStore = apps.get_model('pretixbase', 'GlobalSettingsObject_SettingsStore') + + l = [] + for s in GlobalSetting.objects.all(): + l.append(GlobalSettingsObject_SettingsStore(key=s.key, value=s.value)) + + GlobalSettingsObject_SettingsStore.objects.bulk_create(l) + + +class Migration(migrations.Migration): + + replaces = [('pretixbase', '0051_auto_20170206_2027'), ('pretixbase', '0052_auto_20170324_1506'), ('pretixbase', '0053_auto_20170409_1651'), ('pretixbase', '0054_auto_20170413_1050'), ('pretixbase', '0055_auto_20170413_1537'), ('pretixbase', '0056_auto_20170414_1044'), ('pretixbase', '0057_auto_20170501_2116')] + + dependencies = [ + ('pretixbase', '0050_orderposition_positionid_squashed_0061_event_location'), + ] + + operations = [ + migrations.CreateModel( + name='WaitingListEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')), + ('email', models.EmailField(max_length=254, verbose_name='E-mail address')), + ('locale', models.CharField(default='en', max_length=190)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')), + ('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')), + ('variation', models.ForeignKey(blank=True, help_text='The variation of the product selected above.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.ItemVariation', verbose_name='Product variation')), + ('voucher', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher', verbose_name='Assigned voucher')), + ], + options={ + 'ordering': ['created'], + 'verbose_name_plural': 'Waiting list entries', + 'verbose_name': 'Waiting list entry', + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.AlterField( + model_name='cachedcombinedticket', + name='created', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterModelOptions( + name='invoice', + options={'ordering': ('invoice_no',)}, + ), + migrations.AlterModelOptions( + name='orderposition', + options={'ordering': ('positionid', 'id'), 'verbose_name': 'Order position', 'verbose_name_plural': 'Order positions'}, + ), + migrations.AddField( + model_name='item', + name='max_per_order', + field=models.IntegerField(blank=True, help_text='This product can only be bought at most this times within one order. If you keep the field empty or set it to 0, there is no special limit for this product. The limit for the maximum number of items in the whole order applies regardless.', null=True, verbose_name='Maximum times per order'), + ), + migrations.AlterField( + model_name='item', + name='allow_cancel', + field=models.BooleanField(default=True, help_text='If this is active and the general event settings allo wit, orders containing this product can be canceled by the user until the order is paid for. Users cannot cancel paid orders on their own and you can cancel orders at all times, regardless of this setting', verbose_name='Allow product to be canceled'), + ), + migrations.AlterField( + model_name='item', + name='default_price', + field=models.DecimalField(decimal_places=2, help_text='If this product has multiple variations, you can set different prices for each of the variations. If a variation does not have a special price or if you do not have variations, this price will be used.', max_digits=7, null=True, verbose_name='Default price'), + ), + migrations.RenameModel( + old_name='EventSetting', + new_name='Event_SettingsStore', + ), + migrations.RenameModel( + old_name='OrganizerSetting', + new_name='Organizer_SettingsStore', + ), + migrations.CreateModel( + name='GlobalSettingsObject_SettingsStore', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(db_index=True, max_length=255)), + ('value', models.TextField()), + ], + ), + migrations.RunPython( + code=migrate_global_settings, + reverse_code=django.db.migrations.operations.special.RunPython.noop, + ), + migrations.DeleteModel( + name='GlobalSetting', + ), + migrations.AlterField( + model_name='event_settingsstore', + name='object', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_settings_objects', to='pretixbase.Event'), + ), + migrations.AlterField( + model_name='organizer_settingsstore', + name='object', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_settings_objects', to='pretixbase.Organizer'), + ), + migrations.AddField( + model_name='item', + name='min_per_order', + field=models.IntegerField(blank=True, help_text='This product can only be bought if it is added to the cart at least this many times. If you keep the field empty or set it to 0, there is no special limit for this product.', null=True, verbose_name='Minimum amount per order'), + ), + migrations.AlterField( + model_name='event', + name='currency', + field=models.CharField(choices=[('AED', 'AED - UAE Dirham'), ('AFN', 'AFN - Afghani'), ('ALL', 'ALL - Lek'), ('AMD', 'AMD - Armenian Dram'), ('ANG', 'ANG - Netherlands Antillean Guilder'), ('AOA', 'AOA - Kwanza'), ('ARS', 'ARS - Argentine Peso'), ('AUD', 'AUD - Australian Dollar'), ('AWG', 'AWG - Aruban Florin'), ('AZN', 'AZN - Azerbaijanian Manat'), ('BAM', 'BAM - Convertible Mark'), ('BBD', 'BBD - Barbados Dollar'), ('BDT', 'BDT - Taka'), ('BGN', 'BGN - Bulgarian Lev'), ('BHD', 'BHD - Bahraini Dinar'), ('BIF', 'BIF - Burundi Franc'), ('BMD', 'BMD - Bermudian Dollar'), ('BND', 'BND - Brunei Dollar'), ('BOB', 'BOB - Boliviano'), ('BRL', 'BRL - Brazilian Real'), ('BSD', 'BSD - Bahamian Dollar'), ('BTN', 'BTN - Ngultrum'), ('BWP', 'BWP - Pula'), ('BYR', 'BYR - Belarusian Ruble'), ('BZD', 'BZD - Belize Dollar'), ('CAD', 'CAD - Canadian Dollar'), ('CDF', 'CDF - Congolese Franc'), ('CHF', 'CHF - Swiss Franc'), ('CLP', 'CLP - Chilean Peso'), ('CNY', 'CNY - Yuan Renminbi'), ('COP', 'COP - Colombian Peso'), ('CRC', 'CRC - Costa Rican Colon'), ('CUC', 'CUC - Peso Convertible'), ('CUP', 'CUP - Cuban Peso'), ('CVE', 'CVE - Cabo Verde Escudo'), ('CZK', 'CZK - Czech Koruna'), ('DJF', 'DJF - Djibouti Franc'), ('DKK', 'DKK - Danish Krone'), ('DOP', 'DOP - Dominican Peso'), ('DZD', 'DZD - Algerian Dinar'), ('EGP', 'EGP - Egyptian Pound'), ('ERN', 'ERN - Nakfa'), ('ETB', 'ETB - Ethiopian Birr'), ('EUR', 'EUR - Euro'), ('FJD', 'FJD - Fiji Dollar'), ('FKP', 'FKP - Falkland Islands Pound'), ('GBP', 'GBP - Pound Sterling'), ('GEL', 'GEL - Lari'), ('GHS', 'GHS - Ghana Cedi'), ('GIP', 'GIP - Gibraltar Pound'), ('GMD', 'GMD - Dalasi'), ('GNF', 'GNF - Guinea Franc'), ('GTQ', 'GTQ - Quetzal'), ('GYD', 'GYD - Guyana Dollar'), ('HKD', 'HKD - Hong Kong Dollar'), ('HNL', 'HNL - Lempira'), ('HRK', 'HRK - Kuna'), ('HTG', 'HTG - Gourde'), ('HUF', 'HUF - Forint'), ('IDR', 'IDR - Rupiah'), ('ILS', 'ILS - New Israeli Sheqel'), ('INR', 'INR - Indian Rupee'), ('IQD', 'IQD - Iraqi Dinar'), ('IRR', 'IRR - Iranian Rial'), ('ISK', 'ISK - Iceland Krona'), ('JMD', 'JMD - Jamaican Dollar'), ('JOD', 'JOD - Jordanian Dinar'), ('JPY', 'JPY - Yen'), ('KES', 'KES - Kenyan Shilling'), ('KGS', 'KGS - Som'), ('KHR', 'KHR - Riel'), ('KMF', 'KMF - Comoro Franc'), ('KPW', 'KPW - North Korean Won'), ('KRW', 'KRW - Won'), ('KWD', 'KWD - Kuwaiti Dinar'), ('KYD', 'KYD - Cayman Islands Dollar'), ('KZT', 'KZT - Tenge'), ('LAK', 'LAK - Kip'), ('LBP', 'LBP - Lebanese Pound'), ('LKR', 'LKR - Sri Lanka Rupee'), ('LRD', 'LRD - Liberian Dollar'), ('LSL', 'LSL - Loti'), ('LYD', 'LYD - Libyan Dinar'), ('MAD', 'MAD - Moroccan Dirham'), ('MDL', 'MDL - Moldovan Leu'), ('MGA', 'MGA - Malagasy Ariary'), ('MKD', 'MKD - Denar'), ('MMK', 'MMK - Kyat'), ('MNT', 'MNT - Tugrik'), ('MOP', 'MOP - Pataca'), ('MRO', 'MRO - Ouguiya'), ('MUR', 'MUR - Mauritius Rupee'), ('MVR', 'MVR - Rufiyaa'), ('MWK', 'MWK - Malawi Kwacha'), ('MXN', 'MXN - Mexican Peso'), ('MYR', 'MYR - Malaysian Ringgit'), ('MZN', 'MZN - Mozambique Metical'), ('NAD', 'NAD - Namibia Dollar'), ('NGN', 'NGN - Naira'), ('NIO', 'NIO - Cordoba Oro'), ('NOK', 'NOK - Norwegian Krone'), ('NPR', 'NPR - Nepalese Rupee'), ('NZD', 'NZD - New Zealand Dollar'), ('OMR', 'OMR - Rial Omani'), ('PAB', 'PAB - Balboa'), ('PEN', 'PEN - Sol'), ('PGK', 'PGK - Kina'), ('PHP', 'PHP - Philippine Peso'), ('PKR', 'PKR - Pakistan Rupee'), ('PLN', 'PLN - Zloty'), ('PYG', 'PYG - Guarani'), ('QAR', 'QAR - Qatari Rial'), ('RON', 'RON - Romanian Leu'), ('RSD', 'RSD - Serbian Dinar'), ('RUB', 'RUB - Russian Ruble'), ('RWF', 'RWF - Rwanda Franc'), ('SAR', 'SAR - Saudi Riyal'), ('SBD', 'SBD - Solomon Islands Dollar'), ('SCR', 'SCR - Seychelles Rupee'), ('SDG', 'SDG - Sudanese Pound'), ('SEK', 'SEK - Swedish Krona'), ('SGD', 'SGD - Singapore Dollar'), ('SHP', 'SHP - Saint Helena Pound'), ('SLL', 'SLL - Leone'), ('SOS', 'SOS - Somali Shilling'), ('SRD', 'SRD - Surinam Dollar'), ('SSP', 'SSP - South Sudanese Pound'), ('STD', 'STD - Dobra'), ('SVC', 'SVC - El Salvador Colon'), ('SYP', 'SYP - Syrian Pound'), ('SZL', 'SZL - Lilangeni'), ('THB', 'THB - Baht'), ('TJS', 'TJS - Somoni'), ('TMT', 'TMT - Turkmenistan New Manat'), ('TND', 'TND - Tunisian Dinar'), ('TOP', 'TOP - Pa’anga'), ('TRY', 'TRY - Turkish Lira'), ('TTD', 'TTD - Trinidad and Tobago Dollar'), ('TWD', 'TWD - New Taiwan Dollar'), ('TZS', 'TZS - Tanzanian Shilling'), ('UAH', 'UAH - Hryvnia'), ('UGX', 'UGX - Uganda Shilling'), ('USD', 'USD - US Dollar'), ('UYU', 'UYU - Peso Uruguayo'), ('UZS', 'UZS - Uzbekistan Sum'), ('VEF', 'VEF - Bolívar'), ('VND', 'VND - Dong'), ('VUV', 'VUV - Vatu'), ('WST', 'WST - Tala'), ('XAF', 'XAF - CFA Franc BEAC'), ('XAG', 'XAG - Silver'), ('XAU', 'XAU - Gold'), ('XBA', 'XBA - Bond Markets Unit European Composite Unit (EURCO)'), ('XBB', 'XBB - Bond Markets Unit European Monetary Unit (E.M.U.-6)'), ('XBC', 'XBC - Bond Markets Unit European Unit of Account 9 (E.U.A.-9)'), ('XBD', 'XBD - Bond Markets Unit European Unit of Account 17 (E.U.A.-17)'), ('XCD', 'XCD - East Caribbean Dollar'), ('XDR', 'XDR - SDR (Special Drawing Right)'), ('XOF', 'XOF - CFA Franc BCEAO'), ('XPD', 'XPD - Palladium'), ('XPF', 'XPF - CFP Franc'), ('XPT', 'XPT - Platinum'), ('XSU', 'XSU - Sucre'), ('XTS', 'XTS - Codes specifically reserved for testing purposes'), ('XUA', 'XUA - ADB Unit of Account'), ('XXX', 'XXX - The codes assigned for transactions where no currency is involved'), ('YER', 'YER - Yemeni Rial'), ('ZAR', 'ZAR - Rand'), ('ZMW', 'ZMW - Zambian Kwacha'), ('ZWL', 'ZWL - Zimbabwe Dollar')], default='EUR', max_length=10, verbose_name='Default currency'), + ), + migrations.AlterField( + model_name='event_settingsstore', + name='key', + field=models.CharField(db_index=True, max_length=255), + ), + migrations.AlterField( + model_name='item', + name='max_per_order', + field=models.IntegerField(blank=True, help_text='This product can only be bought at most this many times within one order. If you keep the field empty or set it to 0, there is no special limit for this product. The limit for the maximum number of items in the whole order applies regardless.', null=True, verbose_name='Maximum amount per order'), + ), + migrations.AlterField( + model_name='organizer_settingsstore', + name='key', + field=models.CharField(db_index=True, max_length=255), + ), + migrations.AddField( + model_name='cartposition', + name='attendee_email', + field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'), + ), + migrations.AddField( + model_name='orderposition', + name='attendee_email', + field=models.EmailField(blank=True, help_text='Empty, if this product is not an admission ticket', max_length=254, null=True, verbose_name='Attendee email'), + ), + migrations.AlterField( + model_name='event_settingsstore', + name='key', + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name='globalsettingsobject_settingsstore', + name='key', + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name='organizer_settingsstore', + name='key', + field=models.CharField(max_length=255), + ), + migrations.CreateModel( + name='ItemAddOn', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('min_count', models.PositiveIntegerField(default=0, verbose_name='Minimum number')), + ('max_count', models.PositiveIntegerField(default=1, verbose_name='Maximum number')), + ], + ), + migrations.AddField( + model_name='cartposition', + name='addon_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.CartPosition'), + ), + migrations.AddField( + model_name='itemcategory', + name='is_addon', + field=models.BooleanField(default=False, help_text='If selected, the products belonging to this category are not for sale on their own. They can only be bought in combination with a product that has this category configured as a possible source for add-ons.', verbose_name='Products in this category are add-on products'), + ), + migrations.AddField( + model_name='orderposition', + name='addon_to', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.OrderPosition'), + ), + migrations.AlterField( + model_name='item', + name='free_price', + field=models.BooleanField(default=False, help_text='If this option is active, your users can choose the price themselves. The price configured above is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect additional donations for your event. This is currently not supported for products that are bought as an add-on to other products.', verbose_name='Free price input'), + ), + migrations.AddField( + model_name='itemaddon', + name='addon_category', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addon_to', to='pretixbase.ItemCategory', verbose_name='Category'), + ), + migrations.AddField( + model_name='itemaddon', + name='base_item', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addons', to='pretixbase.Item'), + ), + migrations.AlterUniqueTogether( + name='itemaddon', + unique_together=set([('base_item', 'addon_category')]), + ), + migrations.AlterModelOptions( + name='itemaddon', + options={'ordering': ('position', 'pk')}, + ), + migrations.AddField( + model_name='itemaddon', + name='position', + field=models.PositiveIntegerField(default=0, verbose_name='Position'), + ), + migrations.AddField( + model_name='itemvariation', + name='description', + field=i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the variation name in lists.', null=True, verbose_name='Description'), + ), + ] diff --git a/src/pretix/base/migrations/0052_team_teaminvite.py b/src/pretix/base/migrations/0052_team_teaminvite.py new file mode 100644 index 0000000000..79d01ee5e1 --- /dev/null +++ b/src/pretix/base/migrations/0052_team_teaminvite.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-04-27 09:11 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import pretix.base.models.organizer + + +def create_teams(apps, schema_editor): + Event = apps.get_model('pretixbase', 'Event') + Organizer = apps.get_model('pretixbase', 'Organizer') + Team = apps.get_model('pretixbase', 'Team') + TeamInvite = apps.get_model('pretixbase', 'TeamInvite') + EventPermission = apps.get_model('pretixbase', 'EventPermission') + OrganizerPermission = apps.get_model('pretixbase', 'OrganizerPermission') + + for o in Organizer.objects.prefetch_related('events'): + for e in o.events.all(): + teams = {} + + for p in e.user_perms.all(): + pkey = (p.can_change_settings, p.can_change_items, p.can_view_orders, + p.can_change_permissions, p.can_change_orders, p.can_view_vouchers, + p.can_change_vouchers) + if pkey not in teams: + team = Team() + team.can_change_event_settings = p.can_change_settings + team.can_change_items = p.can_change_items + team.can_view_orders = p.can_view_orders + team.can_change_orders = p.can_change_orders + team.can_view_vouchers = p.can_view_vouchers + team.can_change_vouchers = p.can_change_vouchers + team.organizer = o + team.name = '{} Team {}'.format( + str(e.name), len(teams) + 1 + ) + team.save() + team.limit_events.add(e) + + teams[pkey] = team + + if p.user: + teams[pkey].members.add(p.user) + else: + teams[pkey].invites.create(email=p.invite_email, token=p.invite_token) + + teams = {} + for p in o.user_perms.all(): + pkey = (p.can_create_events, p.can_change_permissions) + if pkey not in teams: + team = Team() + team.can_change_organizer_settings = True + team.can_create_events = p.can_create_events + team.can_change_teams = p.can_change_permissions + team.organizer = o + team.name = '{} Team {}'.format( + str(o.name), len(teams) + 1 + ) + team.save() + teams[pkey] = team + + if p.user: + teams[pkey].members.add(p.user) + else: + teams[pkey].invites.create(email=p.invite_email, token=p.invite_token) + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0051_auto_20170206_2027_squashed_0057_auto_20170501_2116'), + ] + + operations = [ + migrations.CreateModel( + name='Team', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=190, verbose_name='Team name')), + ('all_events', models.BooleanField(default=False, verbose_name='All events (including newly created ones)')), + ('can_create_events', models.BooleanField(default=False, verbose_name='Can create events')), + ('can_change_teams', models.BooleanField(default=False, verbose_name='Can change permissions')), + ('can_change_organizer_settings', models.BooleanField(default=False, verbose_name='Can change organizer settings')), + ('can_change_event_settings', models.BooleanField(default=False, verbose_name='Can change event settings')), + ('can_change_items', models.BooleanField(default=False, verbose_name='Can change product settings')), + ('can_view_orders', models.BooleanField(default=False, verbose_name='Can view orders')), + ('can_change_orders', models.BooleanField(default=False, verbose_name='Can change orders')), + ('can_view_vouchers', models.BooleanField(default=False, verbose_name='Can view vouchers')), + ('can_change_vouchers', models.BooleanField(default=False, verbose_name='Can change vouchers')), + ('limit_events', models.ManyToManyField(to='pretixbase.Event', verbose_name='Limit to events')), + ('members', models.ManyToManyField(related_name='teams', to=settings.AUTH_USER_MODEL, verbose_name='Team members')), + ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='teams', to='pretixbase.Organizer')), + ], + options={ + 'verbose_name_plural': 'Teams', + 'verbose_name': 'Team', + }, + ), + migrations.CreateModel( + name='TeamInvite', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(blank=True, max_length=254, null=True)), + ('token', models.CharField(blank=True, default=pretix.base.models.organizer.generate_invite_token, max_length=64, null=True)), + ('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='pretixbase.Team')), + ], + ), + migrations.RunPython( + create_teams, migrations.RunPython.noop + ) + ] diff --git a/src/pretix/base/migrations/0057_auto_20170501_2116.py b/src/pretix/base/migrations/0057_auto_20170501_2116.py new file mode 100644 index 0000000000..b86d50c704 --- /dev/null +++ b/src/pretix/base/migrations/0057_auto_20170501_2116.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-05-01 21:16 +from __future__ import unicode_literals + +import i18nfield.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0056_auto_20170414_1044'), + ] + + operations = [ + migrations.AlterModelOptions( + name='itemaddon', + options={'ordering': ('position', 'pk')}, + ), + migrations.AddField( + model_name='itemaddon', + name='position', + field=models.PositiveIntegerField(default=0, verbose_name='Position'), + ), + migrations.AddField( + model_name='itemvariation', + name='description', + field=i18nfield.fields.I18nTextField(blank=True, help_text='This is shown below the variation name in lists.', null=True, verbose_name='Description'), + ), + ] diff --git a/src/pretix/base/migrations/0058_auto_20170429_1020.py b/src/pretix/base/migrations/0058_auto_20170429_1020.py new file mode 100644 index 0000000000..ec37c6a1f6 --- /dev/null +++ b/src/pretix/base/migrations/0058_auto_20170429_1020.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-04-29 10:20 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0052_team_teaminvite'), + ] + + operations = [ + migrations.RemoveField( + model_name='eventpermission', + name='event', + ), + migrations.RemoveField( + model_name='eventpermission', + name='user', + ), + migrations.RemoveField( + model_name='organizerpermission', + name='organizer', + ), + migrations.RemoveField( + model_name='organizerpermission', + name='user', + ), + migrations.RemoveField( + model_name='event', + name='permitted', + ), + migrations.RemoveField( + model_name='organizer', + name='permitted', + ), + migrations.AlterField( + model_name='team', + name='can_change_teams', + field=models.BooleanField(default=False, verbose_name='Can change teams and permissions'), + ), + migrations.AlterField( + model_name='team', + name='limit_events', + field=models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events'), + ), + migrations.DeleteModel( + name='EventPermission', + ), + migrations.DeleteModel( + name='OrganizerPermission', + ), + ] diff --git a/src/pretix/base/migrations/0059_checkin_nonce.py b/src/pretix/base/migrations/0059_checkin_nonce.py new file mode 100644 index 0000000000..c9287b2231 --- /dev/null +++ b/src/pretix/base/migrations/0059_checkin_nonce.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-05-04 07:06 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0058_auto_20170429_1020'), + ] + + operations = [ + migrations.AddField( + model_name='checkin', + name='nonce', + field=models.CharField(blank=True, max_length=190, null=True), + ), + ] diff --git a/src/pretix/base/migrations/0060_auto_20170510_1027.py b/src/pretix/base/migrations/0060_auto_20170510_1027.py new file mode 100644 index 0000000000..23588bdca4 --- /dev/null +++ b/src/pretix/base/migrations/0060_auto_20170510_1027.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-05-10 10:27 +from __future__ import unicode_literals + +import i18nfield.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0059_checkin_nonce'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='date_admission', + field=models.DateTimeField(blank=True, null=True, verbose_name='Admission time'), + ), + migrations.AlterField( + model_name='event', + name='location', + field=i18nfield.fields.I18nTextField(blank=True, max_length=200, null=True, verbose_name='Location'), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index b44ae4122f..ffde2cb677 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -3,7 +3,7 @@ from .auth import U2FDevice, User from .base import CachedFile, LoggedModel, cachedfile_name from .checkin import Checkin from .event import ( - Event, Event_SettingsStore, EventLock, EventPermission, RequiredAction, + Event, Event_SettingsStore, EventLock, RequiredAction, generate_invite_token, ) from .invoices import Invoice, InvoiceLine, invoice_filename @@ -18,6 +18,6 @@ from .orders import ( cachedcombinedticket_name, cachedticket_name, generate_position_secret, generate_secret, ) -from .organizer import Organizer, Organizer_SettingsStore, OrganizerPermission +from .organizer import Organizer, Organizer_SettingsStore, Team, TeamInvite from .vouchers import Voucher from .waitinglist import WaitingListEntry diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index c36c1d52cc..3dab670f1b 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -1,9 +1,12 @@ +from typing import Union + from django.conf import settings from django.contrib.auth.models import ( AbstractBaseUser, BaseUserManager, PermissionsMixin, ) from django.contrib.contenttypes.models import ContentType from django.db import models +from django.db.models import Q from django.utils.translation import ugettext_lazy as _ from django_otp.models import Device @@ -81,6 +84,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): objects = UserManager() + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._teamcache = {} + class Meta: verbose_name = _("User") verbose_name_plural = _("Users") @@ -147,6 +154,103 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): return LogEntry.objects.filter(content_type=ContentType.objects.get_for_model(User), object_id=self.pk) + def _get_teams_for_organizer(self, organizer): + if 'o{}'.format(organizer.pk) not in self._teamcache: + self._teamcache['o{}'.format(organizer.pk)] = list(self.teams.filter(organizer=organizer)) + return self._teamcache['o{}'.format(organizer.pk)] + + def _get_teams_for_event(self, organizer, event): + if 'e{}'.format(event.pk) not in self._teamcache: + self._teamcache['e{}'.format(event.pk)] = list(self.teams.filter(organizer=organizer).filter( + Q(all_events=True) | Q(limit_events=event) + )) + return self._teamcache['e{}'.format(event.pk)] + + class SuperuserPermissionSet: + def __contains__(self, item): + return True + + def get_event_permission_set(self, organizer, event) -> Union[set, SuperuserPermissionSet]: + """ + Gets a set of permissions (as strings) that a user holds for a particular event + + :param organizer: The organizer of the event + :param event: The event to check + :return: set in case of a normal user and a SuperuserPermissionSet in case of a superuser (fake object where + a in b always returns true). + """ + if self.is_superuser: + return self.SuperuserPermissionSet() + + teams = self._get_teams_for_event(organizer, event) + return set.union(*[t.permission_set() for t in teams]) + + def get_organizer_permission_set(self, organizer) -> Union[set, SuperuserPermissionSet]: + """ + Gets a set of permissions (as strings) that a user holds for a particular organizer + + :param organizer: The organizer of the event + :return: set in case of a normal user and a SuperuserPermissionSet in case of a superuser (fake object where + a in b always returns true). + """ + if self.is_superuser: + return self.SuperuserPermissionSet() + + teams = self._get_teams_for_organizer(organizer) + return set.union(*[t.permission_set() for t in teams]) + + def has_event_permission(self, organizer, event, perm_name=None) -> bool: + """ + Checks if this user is part of any 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 + """ + if self.is_superuser: + return True + teams = self._get_teams_for_event(organizer, event) + if teams: + self._teamcache['e{}'.format(event.pk)] = teams + if not perm_name or any([team.has_permission(perm_name) for team in teams]): + return True + return False + + def has_organizer_permission(self, organizer, perm_name=None): + """ + Checks if this user is part of any 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 + """ + if self.is_superuser: + return True + teams = self._get_teams_for_organizer(organizer) + if teams: + if not perm_name or any([team.has_permission(perm_name) for team in teams]): + return True + return False + + def get_events_with_any_permission(self): + """ + Returns a queryset of events the user has any permissions to. + + :return: Iterable of Events + """ + from .event import Event + + if self.is_superuser: + return Event.objects.all() + + return Event.objects.filter( + Q(organizer_id__in=self.teams.filter(all_events=True).values_list('organizer', flat=True)) + | Q(id__in=self.teams.values_list('limit_events__id', flat=True)) + ) + class U2FDevice(Device): json_data = models.TextField() diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 3911d60094..37808779c2 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -8,3 +8,4 @@ class Checkin(models.Model): """ position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins') datetime = models.DateTimeField(default=now) + nonce = models.CharField(max_length=190, null=True, blank=True) diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 352acafb50..94e234ad77 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -13,7 +13,7 @@ from django.template.defaultfilters import date as _date from django.utils.crypto import get_random_string from django.utils.timezone import make_aware, now from django.utils.translation import ugettext_lazy as _ -from i18nfield.fields import I18nCharField +from i18nfield.fields import I18nCharField, I18nTextField from pretix.base.email import CustomSMTPBackend from pretix.base.models.base import LoggedModel @@ -21,7 +21,6 @@ from pretix.base.validators import EventSlugBlacklistValidator from pretix.helpers.daterange import daterange from ..settings import settings_hierarkey -from .auth import User from .organizer import Organizer @@ -79,8 +78,6 @@ class Event(LoggedModel): verbose_name=_("Short form"), ) live = models.BooleanField(default=False, verbose_name=_("Shop is live")) - permitted = models.ManyToManyField(User, through='EventPermission', - related_name="events", ) currency = models.CharField(max_length=10, verbose_name=_("Default currency"), choices=CURRENCY_CHOICES, @@ -88,6 +85,8 @@ class Event(LoggedModel): date_from = models.DateTimeField(verbose_name=_("Event start time")) date_to = models.DateTimeField(null=True, blank=True, verbose_name=_("Event end time")) + date_admission = models.DateTimeField(null=True, blank=True, + verbose_name=_("Admission time")) is_public = models.BooleanField(default=False, verbose_name=_("Visible in public lists"), help_text=_("If selected, this event may show up on the ticket system's start page " @@ -102,7 +101,7 @@ class Event(LoggedModel): verbose_name=_("Start of presale"), help_text=_("No products will be sold before this date."), ) - location = I18nCharField( + location = I18nTextField( null=True, blank=True, max_length=200, verbose_name=_("Location"), @@ -140,7 +139,7 @@ class Event(LoggedModel): return [] return self.plugins.split(",") - def get_date_from_display(self, tz=None) -> str: + def get_date_from_display(self, tz=None, show_times=True) -> str: """ Returns a formatted string containing the start date of the event with respect to the current locale and to the ``show_times`` setting. @@ -148,7 +147,17 @@ class Event(LoggedModel): tz = tz or pytz.timezone(self.settings.timezone) return _date( self.date_from.astimezone(tz), - "DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT" + "DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT" + ) + + def get_time_from_display(self, tz=None) -> str: + """ + Returns a formatted string containing the start time of the event, ignoring + the ``show_times`` setting. + """ + tz = tz or pytz.timezone(self.settings.timezone) + return _date( + self.date_from.astimezone(tz), "TIME_FORMAT" ) def get_date_to_display(self, tz=None) -> str: @@ -269,7 +278,8 @@ class Event(LoggedModel): q.event = self q.save() for i in items: - q.items.add(item_map[i.pk]) + if i.pk in item_map: + q.items.add(item_map[i.pk]) for v in vars: q.variations.add(variation_map[v.pk]) @@ -306,69 +316,6 @@ def generate_invite_token(): return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) -class EventPermission(models.Model): - """ - The relation between an Event and a User who has permissions to - access an event. - - :param event: The event this permission refers to - :type event: Event - :param user: The user this permission set applies to - :type user: User - :param can_change_settings: If ``True``, the user can change all basic settings for this event. - :type can_change_settings: bool - :param can_change_items: If ``True``, the user can change and add items and related objects for this event. - :type can_change_items: bool - :param can_view_orders: If ``True``, the user can inspect details of all orders. - :type can_view_orders: bool - :param can_change_orders: If ``True``, the user can change details of orders - :type can_change_orders: bool - """ - - event = models.ForeignKey(Event, related_name="user_perms", on_delete=models.CASCADE) - user = models.ForeignKey(User, related_name="event_perms", on_delete=models.CASCADE, null=True, blank=True) - invite_email = models.EmailField(null=True, blank=True) - invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True) - can_change_settings = models.BooleanField( - default=True, - verbose_name=_("Can change event settings") - ) - can_change_items = models.BooleanField( - default=True, - verbose_name=_("Can change product settings") - ) - can_view_orders = models.BooleanField( - default=True, - verbose_name=_("Can view orders") - ) - can_change_permissions = models.BooleanField( - default=True, - verbose_name=_("Can change permissions") - ) - can_change_orders = models.BooleanField( - default=True, - verbose_name=_("Can change orders") - ) - can_view_vouchers = models.BooleanField( - default=True, - verbose_name=_("Can view vouchers") - ) - can_change_vouchers = models.BooleanField( - default=True, - verbose_name=_("Can change vouchers") - ) - - class Meta: - verbose_name = _("Event permission") - verbose_name_plural = _("Event permissions") - - def __str__(self): - return _("%(name)s on %(object)s") % { - 'name': str(self.user), - 'object': str(self.event), - } - - class EventLock(models.Model): event = models.CharField(max_length=36, primary_key=True) date = models.DateTimeField(auto_now=True) diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 0a28b0e088..a72213669f 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -310,6 +310,8 @@ class ItemVariation(models.Model): :type item: Item :param value: A string defining this variation :type value: str + :param description: A short description + :type description: str :param active: Whether this variation is being sold. :type active: bool :param default_price: This variation's default price @@ -327,6 +329,11 @@ class ItemVariation(models.Model): default=True, verbose_name=_("Active"), ) + description = I18nTextField( + verbose_name=_("Description"), + help_text=_("This is shown below the variation name in lists."), + null=True, blank=True, + ) position = models.PositiveIntegerField( default=0, verbose_name=_("Position") @@ -413,9 +420,14 @@ class ItemAddOn(models.Model): default=1, verbose_name=_('Maximum number') ) + position = models.PositiveIntegerField( + default=0, + verbose_name=_("Position") + ) class Meta: unique_together = (('base_item', 'addon_category'),) + ordering = ('position', 'pk') def clean(self): if self.max_count < self.min_count: diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index a917e7f34d..d71e5049bd 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -42,8 +42,6 @@ class Organizer(LoggedModel): ], verbose_name=_("Short form"), ) - permitted = models.ManyToManyField(User, through='OrganizerPermission', - related_name="organizers") class Meta: verbose_name = _("Organizer") @@ -74,39 +72,131 @@ def generate_invite_token(): return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) -class OrganizerPermission(models.Model): +class Team(LoggedModel): """ - The relation between an Organizer and a User who has permissions to - access an organizer profile. + A team is a collection of people given certain access rights to one or more events of an organizer. - :param organizer: The organizer this relation refers to + :param name: The name of this team + :type name: str + :param organizer: The organizer this team belongs to :type organizer: Organizer - :param user: The user this set of permissions is valid for - :type user: User - :param can_create_events: Whether or not this user can create new events with this - organizer account. + :param members: A set of users who belong to this team + :param all_events: Whether this team has access to all events of this organizer + :type all_events: bool + :param limit_events: A set of events this team has access to. Irrelevant if ``all_events`` is ``True``. + :param can_create_events: Whether or not the members can create new events with this organizer account. :type can_create_events: bool + :param can_change_teams: If ``True``, the members can change the teams of this organizer account. + :type can_change_teams: bool + :param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account. + :type can_change_organizer_settings: bool + :param can_change_event_settings: If ``True``, the members can change the settings of the associated events. + :type can_change_event_settings: bool + :param can_change_items: If ``True``, the members can change and add items and related objects for the associated events. + :type can_change_items: bool + :param can_view_orders: If ``True``, the members can inspect details of all orders of the associated events. + :type can_view_orders: bool + :param can_change_orders: If ``True``, the members can change details of orders of the associated events. + :type can_change_orders: bool + :param can_view_vouchers: If ``True``, the members can inspect details of all vouchers of the associated events. + :type can_view_vouchers: bool + :param can_change_vouchers: If ``True``, the members can change and create vouchers for the associated events. + :type can_change_vouchers: bool """ + organizer = models.ForeignKey(Organizer, related_name="teams", on_delete=models.CASCADE) + name = models.CharField(max_length=190, verbose_name=_("Team name")) + members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members")) + all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)")) + limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True) - organizer = models.ForeignKey(Organizer, related_name="user_perms", on_delete=models.CASCADE) - user = models.ForeignKey(User, related_name="organizer_perms", on_delete=models.CASCADE, null=True, blank=True) - invite_email = models.EmailField(null=True, blank=True) - invite_token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True) can_create_events = models.BooleanField( - default=True, + default=False, verbose_name=_("Can create events"), ) - can_change_permissions = models.BooleanField( - default=True, - verbose_name=_("Can change permissions"), + can_change_teams = models.BooleanField( + default=False, + verbose_name=_("Can change teams and permissions"), + ) + can_change_organizer_settings = models.BooleanField( + default=False, + verbose_name=_("Can change organizer settings") ) - class Meta: - verbose_name = _("Organizer permission") - verbose_name_plural = _("Organizer permissions") + can_change_event_settings = models.BooleanField( + default=False, + verbose_name=_("Can change event settings") + ) + can_change_items = models.BooleanField( + default=False, + verbose_name=_("Can change product settings") + ) + can_view_orders = models.BooleanField( + default=False, + verbose_name=_("Can view orders") + ) + can_change_orders = models.BooleanField( + default=False, + verbose_name=_("Can change orders") + ) + can_view_vouchers = models.BooleanField( + default=False, + verbose_name=_("Can view vouchers") + ) + can_change_vouchers = models.BooleanField( + default=False, + verbose_name=_("Can change vouchers") + ) def __str__(self) -> str: return _("%(name)s on %(object)s") % { - 'name': str(self.user), + 'name': str(self.name), 'object': str(self.organizer), } + + def permission_set(self) -> set: + attribs = dir(self) + return { + a for a in attribs if a.startswith('can_') and self.has_permission(a) + } + + @property + def can_change_settings(self): # Legacy compatiblilty + return self.can_change_event_settings + + def has_permission(self, perm_name): + try: + return getattr(self, perm_name) + except AttributeError: + raise ValueError('Invalid required permission: %s' % perm_name) + + def permission_for_event(self, event): + if self.all_events: + return event.organizer_id == self.organizer_id + else: + return self.limit_events.filter(pk=event.pk).exists() + + class Meta: + verbose_name = _("Team") + verbose_name_plural = _("Teams") + + +class TeamInvite(models.Model): + """ + A TeamInvite represents someone who has been invited to a team but hasn't accept the invitation + yet. + + :param team: The team the person is invited to + :type team: Team + :param email: The email the invite has been sent to + :type email: str + :param token: The secret required to redeem the invite + :type token: str + """ + team = models.ForeignKey(Team, related_name="invites", on_delete=models.CASCADE) + email = models.EmailField(null=True, blank=True) + token = models.CharField(default=generate_invite_token, max_length=64, null=True, blank=True) + + def __str__(self) -> str: + return _("Invite to team '{team}' for '{email}'").format( + team=str(self.team), email=self.email + ) diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index fdd0af1195..195f075e68 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -1,7 +1,7 @@ from collections import OrderedDict from datetime import date from decimal import Decimal -from typing import Any, Dict +from typing import Any, Dict, Union import pytz from django import forms @@ -150,9 +150,9 @@ class BasePaymentProvider: forms.BooleanField( label=_('Calculate the fee from the total value including the fee.'), help_text=_('We recommend you to enable this if you want your users to pay the payment fees of your ' - 'payment provider. Click here ' + 'payment provider. Click here ' 'for detailled information on what this does. Don\'t forget to set the correct fees ' - 'above!'), + 'above!').format(docs_url='https://docs.pretix.eu/en/latest/user/payments/fees.html'), required=False )), ('_invoice_text', @@ -266,7 +266,7 @@ class BasePaymentProvider: """ raise NotImplementedError() # NOQA - def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> "bool|str": + def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]: """ Will be called after the user selects this provider as his payment method. If you provided a form to the user to enter payment data, this method should @@ -394,14 +394,14 @@ class BasePaymentProvider: """ return False - def retry_prepare(self, request: HttpRequest, order: Order) -> "bool|str": + def retry_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]: """ Deprecated, use order_prepare instead """ raise DeprecationWarning('retry_prepare is deprecated, use order_prepare instead') return self.order_prepare(request, order) - def order_prepare(self, request: HttpRequest, order: Order) -> "bool|str": + def order_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]: """ Will be called if the user retries to pay an unpaid order (after the user filled in e.g. the form returned by :py:meth:`payment_form`) or if the user changes the payment @@ -409,6 +409,10 @@ class BasePaymentProvider: It should return and report errors the same way as :py:meth:`checkout_prepare`, but receives an ``Order`` object instead of a cart object. + + Note: The ``Order`` object given to this method might be different from the version + stored in the database as it's total will already contain the payment fee for the + new payment method. """ form = self.payment_form(request) if form.is_valid(): @@ -458,7 +462,7 @@ class BasePaymentProvider: return '
%s
' % _('The money can not be automatically refunded, ' 'please transfer the money back manually.') - def order_control_refund_perform(self, request: HttpRequest, order: Order) -> "bool|str": + def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]: """ Will be called if the event administrator confirms the refund. @@ -523,7 +527,7 @@ class FreeOrderProvider(BasePaymentProvider): def order_control_refund_render(self, order: Order) -> str: return '' - def order_control_refund_perform(self, request: HttpRequest, order: Order) -> "bool|str": + def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]: """ Will be called if the event administrator confirms the refund. diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index 362461e9ac..4076bb4e71 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -114,6 +114,16 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString], subject = "[%s] %s" % (prefix, subject) body_plain += "\r\n\r\n-- \r\n" + + signature = str(event.settings.get('mail_text_signature')) + if signature: + signature = signature.format(event=event.name) + signature_md = signature.replace('\n', '
\n') + signature_md = bleach.linkify(bleach.clean(markdown.markdown(signature_md), tags=bleach.ALLOWED_TAGS + ['p', 'br'])) + htmlctx['signature'] = signature_md + body_plain += signature + body_plain += "\r\n\r\n-- \r\n" + body_plain += _( "You are receiving this email because you placed an order for {event}." ).format(event=event.name) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index f54ec52bc6..abb8d0f3bf 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -163,6 +163,10 @@ DEFAULTS = { 'default': 'False', 'type': bool }, + 'ticket_download_nonadm': { + 'default': 'True', + 'type': bool + }, 'last_order_modification_date': { 'default': None, 'type': datetime @@ -187,6 +191,10 @@ DEFAULTS = { 'default': settings.MAIL_FROM, 'type': str }, + 'mail_text_signature': { + 'type': LazyI18nString, + 'default': "" + }, 'mail_text_resend_link': { 'type': LazyI18nString, 'default': LazyI18nString.from_gettext(ugettext_noop("""Hello, diff --git a/src/pretix/base/templates/pretixbase/email/plainwrapper.html b/src/pretix/base/templates/pretixbase/email/plainwrapper.html index 9e6ec4584e..2371b4bf40 100644 --- a/src/pretix/base/templates/pretixbase/email/plainwrapper.html +++ b/src/pretix/base/templates/pretixbase/email/plainwrapper.html @@ -148,6 +148,18 @@ {% endif %} + {% if signature %} + + + + + +
+ {{ signature | safe }} +
+ + + {% endif %}
diff --git a/src/pretix/base/templatetags/rich_text.py b/src/pretix/base/templatetags/rich_text.py index 7536042bec..df7d39b393 100644 --- a/src/pretix/base/templatetags/rich_text.py +++ b/src/pretix/base/templatetags/rich_text.py @@ -11,6 +11,7 @@ ALLOWED_TAGS = [ 'acronym', 'b', 'blockquote', + 'br', 'code', 'em', 'i', diff --git a/src/pretix/base/ticketoutput.py b/src/pretix/base/ticketoutput.py index b16ed5aeea..eca309f239 100644 --- a/src/pretix/base/ticketoutput.py +++ b/src/pretix/base/ticketoutput.py @@ -54,12 +54,15 @@ class BaseTicketOutput: If you override this method, make sure that positions that are addons (i.e. ``addon_to`` is set) are only outputted if the event setting ``ticket_download_addons`` is active. + Do the same for positions that are non-admission without ``ticket_download_nonadm`` active. """ with tempfile.TemporaryDirectory() as d: with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf: for pos in order.positions.all(): if pos.addon_to_id and not self.event.settings.ticket_download_addons: continue + if not pos.item.admission and not self.event.settings.ticket_download_nonadm: + continue fname, __, content = self.generate(pos) zipf.writestr('{}-{}{}'.format( order.code, pos.positionid, os.path.splitext(fname)[1] diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 0a7e3c1aed..e2bb50690b 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -2,6 +2,7 @@ from django import forms from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import RegexValidator +from django.db.models import Q from django.utils.timezone import get_current_timezone_name from django.utils.translation import ugettext_lazy as _ from i18nfield.forms import I18nFormField, I18nTextarea @@ -26,7 +27,7 @@ class EventWizardFoundationForm(forms.Form): self.fields['organizer'] = forms.ModelChoiceField( label=_("Organizer"), queryset=Organizer.objects.filter( - id__in=self.user.organizer_perms.filter(can_create_events=True).values_list('organizer', flat=True) + id__in=self.user.teams.filter(can_create_events=True).values_list('organizer', flat=True) ), widget=forms.RadioSelect, empty_label=None, @@ -111,6 +112,16 @@ class EventWizardBasicsForm(I18nModelForm): class EventWizardCopyForm(forms.Form): + @staticmethod + def copy_from_queryset(user): + return Event.objects.filter( + Q(organizer_id__in=user.teams.filter( + all_events=True, can_change_event_settings=True, can_change_items=True + ).values_list('organizer', flat=True)) | Q(id__in=user.teams.filter( + can_change_event_settings=True, can_change_items=True + ).values_list('limit_events__id', flat=True)) + ) + def __init__(self, *args, **kwargs): kwargs.pop('organizer') kwargs.pop('locales') @@ -118,11 +129,7 @@ class EventWizardCopyForm(forms.Form): super().__init__(*args, **kwargs) self.fields['copy_from_event'] = forms.ModelChoiceField( label=_("Copy configuration from"), - queryset=Event.objects.filter( - id__in=self.user.event_perms.filter( - can_change_items=True, can_change_settings=True - ).values_list('event', flat=True) - ), + queryset=EventWizardCopyForm.copy_from_queryset(self.user), widget=forms.RadioSelect, empty_label=_('Do not copy'), required=False @@ -136,6 +143,7 @@ class EventUpdateForm(I18nModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['slug'].widget.attrs['readonly'] = 'readonly' + self.fields['location'].widget.attrs['rows'] = '3' class Meta: model = Event @@ -146,6 +154,7 @@ class EventUpdateForm(I18nModelForm): 'currency', 'date_from', 'date_to', + 'date_admission', 'is_public', 'presale_start', 'presale_end', @@ -154,6 +163,7 @@ class EventUpdateForm(I18nModelForm): widgets = { 'date_from': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), 'date_to': forms.DateTimeInput(attrs={'class': 'datetimepicker', 'data-date-after': '#id_date_from'}), + 'date_admission': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), 'presale_start': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), 'presale_end': forms.DateTimeInput(attrs={'class': 'datetimepicker', 'data-date-after': '#id_presale_start'}), @@ -467,6 +477,15 @@ class MailSettingsForm(SettingsForm): label=_("Sender address"), help_text=_("Sender address for outgoing emails") ) + + mail_text_signature = I18nFormField( + label=_("Signature"), + required=False, + widget=I18nTextarea, + help_text=_("This will be attached to every email. Available placeholders: {event}"), + validators=[PlaceholderValidator(['{event}'])] + ) + mail_text_order_placed = I18nFormField( label=_("Text"), required=False, @@ -586,7 +605,8 @@ class DisplaySettingsForm(SettingsForm): validators=[ RegexValidator(regex='^#[0-9a-fA-F]{6}$', message=_('Please enter the hexadecimal code of a color, e.g. #990000.')) - ] + ], + widget=forms.TextInput(attrs={'class': 'colorpickerfield'}) ) logo_image = ExtFileField( label=_('Logo image'), @@ -624,6 +644,11 @@ class TicketSettingsForm(SettingsForm): required=False, widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_ticket_download'}), ) + ticket_download_nonadm = forms.BooleanField( + label=_("Generate tickets for non-admission products"), + required=False, + widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_ticket_download'}), + ) def prepare_fields(self): # See clean() diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 974d31e66a..cbb3d33d46 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -211,6 +211,7 @@ class ItemVariationForm(I18nModelForm): 'value', 'active', 'default_price', + 'description', ] diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index f70ae341c5..c80c57f4b4 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -1,8 +1,10 @@ from django import forms +from django.core.exceptions import ValidationError from django.utils.translation import ugettext_lazy as _ -from pretix.base.forms import I18nModelForm -from pretix.base.models import Organizer +from pretix.base.forms import I18nModelForm, SettingsForm +from pretix.base.models import Organizer, Team +from pretix.control.forms import ExtFileField from pretix.multidomain.models import KnownDomain @@ -65,3 +67,46 @@ class OrganizerUpdateForm(OrganizerForm): instance.get_cache().clear() return instance + + +class TeamForm(forms.ModelForm): + + def __init__(self, *args, **kwargs): + organizer = kwargs.pop('organizer') + super().__init__(*args, **kwargs) + self.fields['limit_events'].queryset = organizer.events.all() + + class Meta: + model = Team + fields = ['name', 'all_events', 'limit_events', 'can_create_events', + 'can_change_teams', 'can_change_organizer_settings', + 'can_change_event_settings', 'can_change_items', + 'can_view_orders', 'can_change_orders', + 'can_view_vouchers', 'can_change_vouchers'] + widgets = { + 'limit_events': forms.CheckboxSelectMultiple(attrs={ + 'data-inverse-dependency': '#id_all_events' + }), + } + + def clean(self): + data = super().clean() + if self.instance.pk and not data['can_change_teams']: + if not self.instance.organizer.teams.exclude(pk=self.instance.pk).filter( + can_change_teams=True, members__isnull=False + ).exists(): + raise ValidationError(_('The changes could not be saved because there would be no remaining team with ' + 'the permission to change teams and permissions.')) + + return data + + +class OrganizerSettingsForm(SettingsForm): + + organizer_logo_image = ExtFileField( + label=_('Logo image'), + ext_whitelist=(".png", ".jpg", ".svg", ".gif", ".jpeg"), + required=False, + help_text=_('If you provide a logo image, we will by default not show your organization name ' + 'in the page header. We will show your logo with a maximal height of 120 pixels.') + ) diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 63dbd10e3c..63c140856e 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -121,7 +121,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.permissions.invited': _('A user has been invited to the event team.'), 'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'), 'pretix.event.permissions.deleted': _('A user has been removed from the event team.'), - 'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.') + 'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.'), + 'pretix.team.created': _('The team has been created.'), + 'pretix.team.changed': _('The team settings have been modified.'), + 'pretix.team.deleted': _('The team has been deleted.'), } data = json.loads(logentry.data) @@ -149,6 +152,23 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): if logentry.action_type.startswith('pretix.event.tickets.provider.'): return _('The settings of a ticket output provider have been changed.') + if logentry.action_type == 'pretix.team.member.added': + return _('{user} has been added to the team.').format(user=data.get('email')) + + if logentry.action_type == 'pretix.team.member.removed': + return _('{user} has been removed from the team.').format(user=data.get('email')) + + if logentry.action_type == 'pretix.team.member.joined': + return _('{user} has joined the team using the invite sent to {email}.').format( + user=data.get('email'), email=data.get('invite_email') + ) + + if logentry.action_type == 'pretix.team.invite.created': + return _('{user} has been invited to the team.').format(user=data.get('email')) + + 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.user.settings.changed': text = str(_('Your account settings have been changed.')) if 'email' in data: diff --git a/src/pretix/control/middleware.py b/src/pretix/control/middleware.py index c3d2d743ea..2e8bcf15cb 100644 --- a/src/pretix/control/middleware.py +++ b/src/pretix/control/middleware.py @@ -9,9 +9,7 @@ from django.utils.deprecation import MiddlewareMixin from django.utils.encoding import force_str from django.utils.translation import ugettext as _ -from pretix.base.models import ( - Event, EventPermission, Organizer, OrganizerPermission, -) +from pretix.base.models import Event, Organizer class PermissionMiddleware(MiddlewareMixin): @@ -61,53 +59,23 @@ class PermissionMiddleware(MiddlewareMixin): return redirect_to_login( path, resolved_login_url, REDIRECT_FIELD_NAME) - events = Event.objects.all() if request.user.is_superuser else request.user.events - request.user.events_cache = events.order_by( - "organizer", "date_from").prefetch_related("organizer") + events = request.user.get_events_with_any_permission() + request.user.events_cache = events.order_by("organizer", "date_from").prefetch_related("organizer") if 'event' in url.kwargs and 'organizer' in url.kwargs: - try: - if request.user.is_superuser: - request.event = Event.objects.filter( - slug=url.kwargs['event'], - organizer__slug=url.kwargs['organizer'], - ).select_related('organizer')[0] - request.eventperm = EventPermission( - event=request.event, - user=request.user - ) - else: - request.event = Event.objects.filter( - slug=url.kwargs['event'], - permitted__id__exact=request.user.id, - organizer__slug=url.kwargs['organizer'], - ).select_related('organizer')[0] - request.eventperm = EventPermission.objects.get( - event=request.event, - user=request.user - ) - request.organizer = request.event.organizer - except IndexError: + request.event = Event.objects.filter( + slug=url.kwargs['event'], + organizer__slug=url.kwargs['organizer'], + ).select_related('organizer').first() + if not request.event or not request.user.has_event_permission(request.event.organizer, request.event): raise Http404(_("The selected event was not found or you " "have no permission to administrate it.")) + request.organizer = request.event.organizer + request.eventpermset = request.user.get_event_permission_set(request.organizer, request.event) elif 'organizer' in url.kwargs: - try: - if request.user.is_superuser: - request.organizer = Organizer.objects.filter( - slug=url.kwargs['organizer'], - )[0] - request.orgaperm = OrganizerPermission( - organizer=request.organizer, - user=request.user - ) - else: - request.organizer = Organizer.objects.filter( - slug=url.kwargs['organizer'], - permitted__id__exact=request.user.id, - )[0] - request.orgaperm = OrganizerPermission.objects.get( - organizer=request.organizer, - user=request.user - ) - except IndexError: + request.organizer = Organizer.objects.filter( + slug=url.kwargs['organizer'], + ).first() + if not request.organizer or not request.user.has_organizer_permission(request.organizer): raise Http404(_("The selected organizer was not found or you " "have no permission to administrate it.")) + request.orgapermset = request.user.get_organizer_permission_set(request.organizer) diff --git a/src/pretix/control/permissions.py b/src/pretix/control/permissions.py index 088d72a9b9..80b5339dc6 100644 --- a/src/pretix/control/permissions.py +++ b/src/pretix/control/permissions.py @@ -1,37 +1,29 @@ from django.core.exceptions import PermissionDenied from django.utils.translation import ugettext as _ -from pretix.base.models import EventPermission, OrganizerPermission - def event_permission_required(permission): """ This view decorator rejects all requests with a 403 response which are not from users having the given permission for the event the request is associated with. """ + if permission == 'can_change_settings': + # Legacy support + permission = 'can_change_event_settings' + def decorator(function): def wrapper(request, *args, **kw): if not request.user.is_authenticated: # NOQA # just a double check, should not ever happen raise PermissionDenied() - if request.user.is_superuser: + + allowed = ( + request.user.is_superuser + or request.user.has_event_permission(request.organizer, request.event, permission) + ) + if allowed: return function(request, *args, **kw) - try: - perm = EventPermission.objects.get( - event=request.event, - user=request.user - ) - except EventPermission.DoesNotExist: - pass - else: - allowed = not permission - try: - if permission: - allowed = getattr(perm, permission) - except AttributeError: - pass - if allowed or request.user.is_superuser: - return function(request, *args, **kw) + raise PermissionDenied(_('You do not have permission to view this content.')) return wrapper return decorator @@ -55,29 +47,23 @@ def organizer_permission_required(permission): This view decorator rejects all requests with a 403 response which are not from users having the given permission for the event the request is associated with. """ + if permission == 'can_change_settings': + # Legacy support + permission = 'can_change_organizer_settings' + def decorator(function): def wrapper(request, *args, **kw): if not request.user.is_authenticated: # NOQA # just a double check, should not ever happen raise PermissionDenied() - if request.user.is_superuser: + + allowed = ( + request.user.is_superuser + or request.user.has_organizer_permission(request.organizer, permission) + ) + if allowed: return function(request, *args, **kw) - try: - perm = OrganizerPermission.objects.get( - organizer=request.organizer, - user=request.user - ) - except OrganizerPermission.DoesNotExist: - pass - else: - allowed = not permission - try: - if permission: - allowed = getattr(perm, permission) - except AttributeError: - pass - if allowed or request.user.is_superuser: - return function(request, *args, **kw) + raise PermissionDenied(_('You do not have permission to view this content.')) return wrapper return decorator diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index fef6124b7e..a87d1ccac8 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -35,6 +35,9 @@ + + + {% endcompress %} {{ html_head|safe }} @@ -65,7 +68,7 @@ {{ request.event }} {% endif %} - {% if request.eventperm.can_view_orders %} + {% if 'can_view_orders' in request.eventpermset %}
  • @@ -90,10 +90,16 @@ {% trans "Waiting list" %}
  • +
  • + + {% trans "Check-ins" %} + +
  • {% endif %} - {% if request.eventperm.can_view_vouchers %} + {% if 'can_view_vouchers' in request.eventpermset %}
  • diff --git a/src/pretix/control/templates/pretixcontrol/event/logs.html b/src/pretix/control/templates/pretixcontrol/event/logs.html index b288cb0021..81af0c75b1 100644 --- a/src/pretix/control/templates/pretixcontrol/event/logs.html +++ b/src/pretix/control/templates/pretixcontrol/event/logs.html @@ -15,9 +15,11 @@ {% trans "Customer actions" %} {% for up in userlist %} - + {% if up.user__id %} + + {% endif %} {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/event/mail.html b/src/pretix/control/templates/pretixcontrol/event/mail.html index 986f5e8665..900cb038e0 100644 --- a/src/pretix/control/templates/pretixcontrol/event/mail.html +++ b/src/pretix/control/templates/pretixcontrol/event/mail.html @@ -10,6 +10,7 @@ {% trans "E-mail settings" %} {% bootstrap_field form.mail_prefix layout="horizontal" %} {% bootstrap_field form.mail_from layout="horizontal" %} + {% bootstrap_field form.mail_text_signature layout="horizontal" %}
    {% trans "E-mail content" %} diff --git a/src/pretix/control/templates/pretixcontrol/event/permissions.html b/src/pretix/control/templates/pretixcontrol/event/permissions.html index 24d87ee28b..c7ccc67487 100644 --- a/src/pretix/control/templates/pretixcontrol/event/permissions.html +++ b/src/pretix/control/templates/pretixcontrol/event/permissions.html @@ -1,88 +1,19 @@ {% extends "pretixcontrol/event/settings_base.html" %} {% load i18n %} +{% load staticfiles %} {% load bootstrap3 %} {% block inside %} -
    - {% csrf_token %} -
    - {% trans "Permissions" %} - {% bootstrap_formset_errors formset %} - {{ formset.management_form }} -
    - - - - - - - - - - - - - - - - {% for form in formset %} - - - - - - - - - - - - {% endfor %} - - - - - - - - - - - - - - - - -
    {% trans "User" %}{% trans "Change settings" %}{% trans "Change products" %}{% trans "View orders" %}{% trans "Change orders" %}{% trans "Change permissions" %}{% trans "View vouchers" %}{% trans "Change vouchers" %}{% trans "Delete" %}
    - {{ form.id }} - {% if form.instance.user %} - {{ form.instance.user }} - {% else %} - {{ form.instance.invite_email }} - - {% endif %} - {{ form.can_change_settings }}{{ form.can_change_items }}{{ form.can_view_orders }}{{ form.can_change_orders }}{{ form.can_change_permissions }}{{ form.can_view_vouchers }}{{ form.can_change_vouchers }}{{ form.DELETE }}
    - {% trans "Adding a new user" %}
    - {% blocktrans trimmed %} - To add a new user, you can enter their email address here. If they already have a - pretix account, they will immediately be added to the event. Otherwise, they will - be sent an email with an invitation. - {% endblocktrans %} -
    -
    -
    - {% bootstrap_field add_form.user layout='inline' %} -
    -
    -
    {{ add_form.can_change_settings }}{{ add_form.can_change_items }}{{ add_form.can_view_orders }}{{ add_form.can_change_orders }}{{ add_form.can_change_permissions }}{{ add_form.can_view_vouchers }}{{ add_form.can_change_vouchers }}
    -
    -
    -
    - + - {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 82019bcf63..a98cd90833 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -12,6 +12,7 @@ {% bootstrap_field form.date_from layout="horizontal" %} {% bootstrap_field form.date_to layout="horizontal" %} {% bootstrap_field form.location layout="horizontal" %} + {% bootstrap_field form.date_admission layout="horizontal" %} {% bootstrap_field form.currency layout="horizontal" %} {% bootstrap_field form.is_public layout="horizontal" %}
    diff --git a/src/pretix/control/templates/pretixcontrol/event/settings_base.html b/src/pretix/control/templates/pretixcontrol/event/settings_base.html index 842c1a8bac..fe76187577 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings_base.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings_base.html @@ -5,7 +5,7 @@ {% block content %}

    {% trans "Settings" %}