diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1dae98ee7..569822798 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,7 +38,7 @@ jobs: restore-keys: | ${{ runner.os }}-pip- - name: Install system dependencies - run: sudo apt update && sudo apt install gettext unzip + run: sudo apt update && sudo apt install -y gettext unzip - name: Install Python dependencies run: pip3 install -U setuptools build pip check-manifest - name: Run check-manifest diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ab35c8001..7b1598129 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -37,7 +37,7 @@ jobs: restore-keys: | ${{ runner.os }}-pip- - name: Install system packages - run: sudo apt update && sudo apt install enchant-2 hunspell aspell-en + run: sudo apt update && sudo apt install -y enchant-2 hunspell aspell-en - name: Install Dependencies run: pip3 install -Ur requirements.txt working-directory: ./doc diff --git a/.github/workflows/strings.yml b/.github/workflows/strings.yml index b01d8ec7c..cfc580b9a 100644 --- a/.github/workflows/strings.yml +++ b/.github/workflows/strings.yml @@ -35,9 +35,9 @@ jobs: restore-keys: | ${{ runner.os }}-pip- - name: Install system packages - run: sudo apt update && sudo apt install gettext + run: sudo apt update && sudo apt -y install gettext - name: Install Dependencies - run: pip3 install -e ".[dev]" + run: pip3 install uv && uv pip install --system -e ".[dev]" - name: Compile messages run: python manage.py compilemessages working-directory: ./src @@ -62,7 +62,7 @@ jobs: - name: Install system packages run: sudo apt update && sudo apt install enchant-2 hunspell hunspell-de-de aspell-en aspell-de - name: Install Dependencies - run: pip3 install -e ".[dev]" + run: pip3 install uv && uv pip install --system -e ".[dev]" - name: Spellcheck translations run: potypo working-directory: ./src diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 0c284155a..4ecec238f 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -35,7 +35,7 @@ jobs: restore-keys: | ${{ runner.os }}-pip- - name: Install Dependencies - run: pip3 install -e ".[dev]" psycopg2-binary + run: pip3 install uv && uv pip install --system -e ".[dev]" psycopg2-binary - name: Run isort run: isort -c . working-directory: ./src @@ -55,7 +55,7 @@ jobs: restore-keys: | ${{ runner.os }}-pip- - name: Install Dependencies - run: pip3 install -e ".[dev]" psycopg2-binary + run: pip3 install uv && uv pip install --system -e ".[dev]" psycopg2-binary - name: Run flake8 run: flake8 . working-directory: ./src diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ee5068a43..975087c75 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,15 +30,21 @@ jobs: python-version: "3.9" - database: sqlite python-version: "3.10" + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: pretix + options: >- + --health-cmd "pg_isready -U postgres -d pretix" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 steps: - uses: actions/checkout@v4 - - uses: harmon758/postgresql-action@v1 - with: - postgresql version: '15' - postgresql db: 'pretix' - postgresql user: 'postgres' - postgresql password: 'postgres' - if: matrix.database == 'postgres' - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: @@ -50,9 +56,9 @@ jobs: restore-keys: | ${{ runner.os }}-pip- - name: Install system dependencies - run: sudo apt update && sudo apt install gettext + run: sudo apt update && sudo apt install -y gettext - name: Install Python dependencies - run: pip3 install --ignore-requires-python -e ".[dev]" psycopg2-binary # We ignore that flake8 needs newer python as we don't run flake8 during tests + run: pip3 install uv && uv pip install --system -e ".[dev]" psycopg2-binary - name: Run checks run: python manage.py check working-directory: ./src @@ -64,15 +70,15 @@ jobs: run: make all compress - name: Run tests working-directory: ./src - run: PRETIX_CONFIG_FILE=tests/travis_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml --reruns 3 tests --maxfail=100 + run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test -n 3 -p no:sugar --cov=./ --cov-report=xml tests --maxfail=100 - name: Run concurrency tests working-directory: ./src - run: PRETIX_CONFIG_FILE=tests/travis_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reruns 0 --reuse-db + run: PRETIX_CONFIG_FILE=tests/ci_${{ matrix.database }}.cfg py.test tests/concurrency_tests/ --reuse-db if: matrix.database == 'postgres' - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: file: src/coverage.xml token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true + fail_ci_if_error: false if: matrix.database == 'postgres' && matrix.python-version == '3.11' diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a9d4ef9f5..b354c958f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,7 +10,7 @@ tests: - cd src - python manage.py check - make all compress - - PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg py.test --reruns 3 -n 3 tests --maxfail=100 + - PRETIX_CONFIG_FILE=tests/ci_sqlite.cfg py.test -n 3 tests --maxfail=100 except: - pypi pypi: diff --git a/doc/admin/config.rst b/doc/admin/config.rst index b17aee56c..d8bd2a1e3 100644 --- a/doc/admin/config.rst +++ b/doc/admin/config.rst @@ -288,6 +288,7 @@ Example:: [django] secret=j1kjps5a5&4ilpn912s7a1!e2h!duz^i3&idu@_907s$wrz@x- debug=off + passwords_argon2=on ``secret`` The secret to be used by Django for signing and verification purposes. If this @@ -303,6 +304,10 @@ Example:: .. WARNING:: Never set this to ``True`` in production! +``passwords_argon`` + Use the ``argon2`` algorithm for password hashing. Disable on systems with a small number of CPU cores (currently + less than 8). + ``profile`` Enable code profiling for a random subset of requests. Disabled by default, see :ref:`perf-monitoring` for details. diff --git a/doc/admin/installation/docker_smallscale.rst b/doc/admin/installation/docker_smallscale.rst index cb8869ab3..72da3d381 100644 --- a/doc/admin/installation/docker_smallscale.rst +++ b/doc/admin/installation/docker_smallscale.rst @@ -231,11 +231,10 @@ The following snippet is an example on how to configure a nginx proxy for pretix } } server { - listen 443 default_server; - listen [::]:443 ipv6only=on default_server; + listen 443 ssl default_server; + listen [::]:443 ipv6only=on ssl default_server; server_name pretix.mydomain.com; - ssl on; ssl_certificate /path/to/cert.chain.pem; ssl_certificate_key /path/to/key.pem; diff --git a/doc/admin/installation/manual_smallscale.rst b/doc/admin/installation/manual_smallscale.rst index ac2722160..562a24e58 100644 --- a/doc/admin/installation/manual_smallscale.rst +++ b/doc/admin/installation/manual_smallscale.rst @@ -216,11 +216,10 @@ The following snippet is an example on how to configure a nginx proxy for pretix } } server { - listen 443 default_server; - listen [::]:443 ipv6only=on default_server; + listen 443 ssl default_server; + listen [::]:443 ipv6only=on ssl default_server; server_name pretix.mydomain.com; - ssl on; ssl_certificate /path/to/cert.chain.pem; ssl_certificate_key /path/to/key.pem; diff --git a/doc/api/resources/auto_checkin_rules.rst b/doc/api/resources/auto_checkin_rules.rst index 83c20c82f..cdd292861 100644 --- a/doc/api/resources/auto_checkin_rules.rst +++ b/doc/api/resources/auto_checkin_rules.rst @@ -71,7 +71,7 @@ Endpoints "mode": "placed", "all_sales_channels": false, "limit_sales_channels": ["web"], - "all_products": False, + "all_products": false, "limit_products": [2, 3], "limit_variations": [456], "all_payment_methods": true, @@ -113,7 +113,7 @@ Endpoints "mode": "placed", "all_sales_channels": false, "limit_sales_channels": ["web"], - "all_products": False, + "all_products": false, "limit_products": [2, 3], "limit_variations": [456], "all_payment_methods": true, @@ -146,7 +146,7 @@ Endpoints "mode": "placed", "all_sales_channels": false, "limit_sales_channels": ["web"], - "all_products": False, + "all_products": false, "limit_products": [2, 3], "limit_variations": [456], "all_payment_methods": true, @@ -167,7 +167,7 @@ Endpoints "mode": "placed", "all_sales_channels": false, "limit_sales_channels": ["web"], - "all_products": False, + "all_products": false, "limit_products": [2, 3], "limit_variations": [456], "all_payment_methods": true, @@ -216,7 +216,7 @@ Endpoints "mode": "placed", "all_sales_channels": false, "limit_sales_channels": ["web"], - "all_products": False, + "all_products": false, "limit_products": [2, 3], "limit_variations": [456], "all_payment_methods": true, diff --git a/doc/api/resources/categories.rst b/doc/api/resources/categories.rst index ec8613d87..715907698 100644 --- a/doc/api/resources/categories.rst +++ b/doc/api/resources/categories.rst @@ -23,6 +23,22 @@ position integer An integer, use is_addon boolean If ``true``, items within this category are not on sale on their own but the category provides a source for defining add-ons for other products. +cross_selling_mode string If ``null``, cross-selling is disabled for this category. + If ``"only"``, it is only visible in the cross-selling + step. + If ``"both"``, it is visible on the normal index page + as well. + Only available if ``is_addon`` is ``false``. +cross_selling_condition string Only relevant if ``cross_selling_mode`` is not ``null``. + If ``"always"``, always show in cross-selling step. + If ``"products"``, only show if the cart contains one of + the products listed in ``cross_selling_match_products``. + If ``"discounts"``, only show products that qualify for + a discount according to discount rules. +cross_selling_match_products list of integer Only relevant if ``cross_selling_condition`` is + ``"products"``. Internal ID of the items of which at + least one needs to be in the cart for this category to + be shown. ===================================== ========================== ======================================================= @@ -60,7 +76,10 @@ Endpoints "internal_name": "", "description": {"en": "Tickets are what you need to get in."}, "position": 1, - "is_addon": false + "is_addon": false, + "cross_selling_mode": null, + "cross_selling_condition": null, + "cross_selling_match_products": [] } ] } @@ -102,7 +121,10 @@ Endpoints "internal_name": "", "description": {"en": "Tickets are what you need to get in."}, "position": 1, - "is_addon": false + "is_addon": false, + "cross_selling_mode": null, + "cross_selling_condition": null, + "cross_selling_match_products": [] } :param organizer: The ``slug`` field of the organizer to fetch @@ -130,7 +152,10 @@ Endpoints "internal_name": "", "description": {"en": "Tickets are what you need to get in."}, "position": 1, - "is_addon": false + "is_addon": false, + "cross_selling_mode": null, + "cross_selling_condition": null, + "cross_selling_match_products": [] } **Example response**: @@ -147,7 +172,10 @@ Endpoints "internal_name": "", "description": {"en": "Tickets are what you need to get in."}, "position": 1, - "is_addon": false + "is_addon": false, + "cross_selling_mode": null, + "cross_selling_condition": null, + "cross_selling_match_products": [] } :param organizer: The ``slug`` field of the organizer of the event to create a category for @@ -193,7 +221,10 @@ Endpoints "internal_name": "", "description": {"en": "Tickets are what you need to get in."}, "position": 1, - "is_addon": true + "is_addon": true, + "cross_selling_mode": null, + "cross_selling_condition": null, + "cross_selling_match_products": [] } :param organizer: The ``slug`` field of the organizer to modify diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index a6bd3327b..0db56047d 100644 --- a/doc/api/resources/checkinlists.rst +++ b/doc/api/resources/checkinlists.rst @@ -31,8 +31,6 @@ subevent integer ID of the date position_count integer Number of tickets that match this list (read-only). checkin_count integer Number of check-ins performed on this list (read-only). include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state. -auto_checkin_sales_channels list of strings All items on the check-in list will be automatically marked as checked-in when purchased through any of the listed sales channels. - **Deprecated, will be removed in pretix 2024.10.** Use :ref:`rest-autocheckinrules`: instead. allow_multiple_entries boolean If ``true``, subsequent scans of a ticket on this list should not show a warning but instead be stored as an additional check-in. allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan. rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged. @@ -91,10 +89,7 @@ Endpoints "allow_entry_after_exit": true, "exit_all_at": null, "rules": {}, - "addon_match": false, - "auto_checkin_sales_channels": [ - "pretixpos" - ] + "addon_match": false } ] } @@ -146,10 +141,7 @@ Endpoints "allow_entry_after_exit": true, "exit_all_at": null, "rules": {}, - "addon_match": false, - "auto_checkin_sales_channels": [ - "pretixpos" - ] + "addon_match": false } :param organizer: The ``slug`` field of the organizer to fetch @@ -246,10 +238,7 @@ Endpoints "subevent": null, "allow_multiple_entries": false, "allow_entry_after_exit": true, - "addon_match": false, - "auto_checkin_sales_channels": [ - "pretixpos" - ] + "addon_match": false } **Example response**: @@ -271,10 +260,7 @@ Endpoints "subevent": null, "allow_multiple_entries": false, "allow_entry_after_exit": true, - "addon_match": false, - "auto_checkin_sales_channels": [ - "pretixpos" - ] + "addon_match": false } :param organizer: The ``slug`` field of the organizer of the event/item to create a list for @@ -326,10 +312,7 @@ Endpoints "subevent": null, "allow_multiple_entries": false, "allow_entry_after_exit": true, - "addon_match": false, - "auto_checkin_sales_channels": [ - "pretixpos" - ] + "addon_match": false } :param organizer: The ``slug`` field of the organizer to modify @@ -342,7 +325,7 @@ Endpoints .. http:delete:: /api/v1/organizers/(organizer)/events/(event)/checkinlist/(id)/ - Delete a check-in list. Note that this also deletes the information on all check-ins performed via this list. + Delete a check-in list. **Note that this also deletes the information on all check-ins performed via this list.** **Example request**: diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst index 4b64959c1..9732b128b 100644 --- a/doc/api/resources/invoices.rst +++ b/doc/api/resources/invoices.rst @@ -352,12 +352,12 @@ Fetching individual invoices :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch - :param invoice_no: The ``invoice_no`` field of the invoice to fetch + :param number: The ``number`` field of the invoice to fetch :statuscode 200: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. -.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/download/ +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/download/ Download an invoice in PDF format. @@ -384,7 +384,7 @@ Fetching individual invoices :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch - :param invoice_no: The ``invoice_no`` field of the invoice to fetch + :param number: The ``number`` field of the invoice to fetch :statuscode 200: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. @@ -397,7 +397,7 @@ Modifying invoices Invoices cannot be edited directly, but the following actions can be triggered: -.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/ +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/reissue/ Cancels the invoice and creates a new one. @@ -419,13 +419,13 @@ Invoices cannot be edited directly, but the following actions can be triggered: :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch - :param invoice_no: The ``invoice_no`` field of the invoice to reissue + :param number: The ``number`` field of the invoice to reissue :statuscode 200: no error :statuscode 400: The invoice has already been canceled :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. -.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/regenerate/ +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/regenerate/ Re-generates the invoice from order data. @@ -447,7 +447,7 @@ Invoices cannot be edited directly, but the following actions can be triggered: :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch - :param invoice_no: The ``invoice_no`` field of the invoice to regenerate + :param number: The ``number`` field of the invoice to regenerate :statuscode 200: no error :statuscode 400: The invoice has already been canceled :statuscode 401: Authentication failure diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index f204e260e..6cc91c0a2 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -104,6 +104,10 @@ url string The full URL to payments list of objects List of payment processes (see below) refunds list of objects List of refund processes (see below) last_modified datetime Last modification of this object +cancellation_date datetime Time of order cancellation (or ``null``). **Note**: + Will not be set for partial cancellations and is not + reliable for orders that have been cancelled, + reactivated and cancelled again. ===================================== ========================== ======================================================= @@ -151,6 +155,9 @@ last_modified datetime Last modificati The ``expires`` attribute can now be passed during order creation. +.. versionchanged:: 2024.11 + + The ``cancellation_date`` attribute has been added and can also be used as an ordering key. .. _order-position-resource: @@ -206,6 +213,17 @@ checkins list of objects List of **succe ├ device integer Internal ID of the device. Can be ``null``. **Deprecated**, since this ID is not otherwise used in the API and is therefore not very useful. ├ device_id integer Attribute ``device_id`` of the device. Can be ``null``. └ auto_checked_in boolean Indicates if this check-in been performed automatically by the system +print_logs list of objects List of print jobs recorded e.g. by the pretix apps +├ id integer Internal ID of the print job +├ successful boolean Whether the print job successfully resulted in a print. + This is not expected to be 100 % reliable information (since + printer feedback is never perfect) and there is no guarantee + that unsuccessful jobs will be logged. +├ device_id integer Attribute ``device_id`` of the device that recorded the print. Can be ``null``. +├ datetime datetime Time of printing +├ source string Source of print job, e.g. name of the app used. +├ type string Type of print (currently ``badge``, ``ticket``, ``certificate``, or ``other``) +└ info object Additional data with client-dependent structure. downloads list of objects List of ticket download options ├ output string Ticket output provider (e.g. ``pdf``, ``passbook``) └ url string Download URL @@ -233,6 +251,10 @@ pdf_data object Data object req The attributes ``blocked``, ``valid_from`` and ``valid_until`` have been added. +.. versionchanged:: 2024.9 + + The attribute ``print_logs`` has been added. + .. _order-payment-resource: Order payment resource @@ -399,10 +421,21 @@ List of all orders "type": "entry", "gate": null, "device": 2, + "device_id": 1, "datetime": "2017-12-25T12:45:23Z", "auto_checked_in": false } ], + "print_logs": [ + { + "id": 1, + "type": "badge", + "datetime": "2017-12-25T12:45:23Z", + "device_id": 1, + "source": "pretixSCAN", + "info": {} + } + ], "answers": [ { "question": 12, @@ -438,14 +471,15 @@ List of all orders "provider": "banktransfer" } ], - "refunds": [] + "refunds": [], + "cancellation_date": null } ] } :query integer page: The page number in case of a multi-page result set, default is 1 :query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``code``, - ``last_modified``, and ``status``. Default: ``datetime`` + ``last_modified``, ``status`` and ``cancellation_date``. Default: ``datetime`` :query string code: Only return orders that match the given order code :query string status: Only return orders in the given order status (see above) :query string search: Only return orders matching a given search query (matching for names, email addresses, and company names) @@ -626,10 +660,22 @@ Fetching individual orders "type": "entry", "gate": null, "device": 2, + "device_id": 1, "datetime": "2017-12-25T12:45:23Z", "auto_checked_in": false } ], + "print_logs": [ + { + "id": 1, + "type": "badge", + "successful": true, + "datetime": "2017-12-25T12:45:23Z", + "device_id": 1, + "source": "pretixSCAN", + "info": {} + } + ], "answers": [ { "question": 12, @@ -665,7 +711,8 @@ Fetching individual orders "provider": "banktransfer" } ], - "refunds": [] + "refunds": [], + "cancellation_date": null } :param organizer: The ``slug`` field of the organizer to fetch @@ -977,8 +1024,8 @@ Creating orders * ``internal_reference`` * ``vat_id`` * ``vat_id_validated`` (optional) – If you need support for reverse charge (rarely the case), you need to check - yourself if the passed VAT ID is a valid EU VAT ID. In that case, set this to ``true``. Only valid VAT IDs will - trigger reverse charge taxation. Don't forget to set ``is_business`` as well! + yourself if the passed VAT ID is a valid EU VAT ID. In that case, set this to ``true``. Only valid VAT IDs will + trigger reverse charge taxation. Don't forget to set ``is_business`` as well! * ``positions`` @@ -1581,10 +1628,22 @@ List of all order positions "type": "entry", "gate": null, "device": 2, + "device_id": 1, "datetime": "2017-12-25T12:45:23Z", "auto_checked_in": false } ], + "print_logs": [ + { + "id": 1, + "type": "badge", + "successful": true, + "datetime": "2017-12-25T12:45:23Z", + "device_id": 1, + "source": "pretixSCAN", + "info": {} + } + ], "answers": [ { "question": 12, @@ -1695,10 +1754,22 @@ Fetching individual positions "type": "entry", "gate": null, "device": 2, + "device_id": 1, "datetime": "2017-12-25T12:45:23Z", "auto_checked_in": false } ], + "print_logs": [ + { + "id": 1, + "type": "badge", + "successful": true, + "datetime": "2017-12-25T12:45:23Z", + "device_id": 1, + "source": "pretixSCAN", + "info": {} + } + ], "answers": [ { "question": 12, @@ -1795,6 +1866,10 @@ Manipulating individual positions The endpoints to manage blocks have been added. +.. versionchanged:: 2024.9 + + The API now supports logging ticket and badge prints. + .. http:patch:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/ Updates specific fields on an order position. Currently, only the following fields are supported: @@ -2054,6 +2129,59 @@ Manipulating individual positions :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to update this order position. +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/printlog/ + + Creates a print log, stating that this ticket has been printed. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/printlog/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "datetime": "2024-09-19T13:37:00+02:00", + "source": "pretixPOS", + "type": "badge", + "info": { + "cashier": 1234 + } + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/pdf + + { + "id": 1234, + "device_id": null, + "datetime": "2024-09-19T13:37:00+02:00", + "source": "pretixPOS", + "type": "badge", + "info": { + "cashier": 1234 + } + } + + :param organizer: The ``slug`` field of the organizer to create a log for + :param event: The ``slug`` field of the event to create a log for + :param id: The ``id`` field of the order position to create a log for + :statuscode 201: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource + **or** downloads are not available for this order position at this time. The response content will + contain more details. + :statuscode 404: The requested order position or download provider does not exist. + :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few + seconds. + Changing order contents ----------------------- diff --git a/doc/api/resources/scheduled_exports.rst b/doc/api/resources/scheduled_exports.rst index aaeae7dde..59007773b 100644 --- a/doc/api/resources/scheduled_exports.rst +++ b/doc/api/resources/scheduled_exports.rst @@ -313,7 +313,7 @@ Endpoints for event exports :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. Endpoints for organizer exports ---------------------------- +------------------------------- .. http:get:: /api/v1/organizers/(organizer)/scheduled_exports/ @@ -553,4 +553,4 @@ Endpoints for organizer exports :statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource. -.. _RFC 5545: https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.5.3 \ No newline at end of file +.. _RFC 5545: https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.5.3 diff --git a/doc/api/resources/seats.rst b/doc/api/resources/seats.rst index b9c6e60cc..b040f1f8b 100644 --- a/doc/api/resources/seats.rst +++ b/doc/api/resources/seats.rst @@ -1,4 +1,4 @@ -.. _`rest-reusablemedia`: +.. _`rest-seats`: Seats ===== @@ -249,7 +249,7 @@ Endpoints "orderposition": null, "cartposition": null, "voucher": null - }, + } :param organizer: The ``slug`` field of the organizer to modify :param event: The ``slug`` field of the event to modify @@ -260,3 +260,114 @@ Endpoints :statuscode 401: Authentication failure :statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource. :statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa. + + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/seats/bulk_block/ +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/bulk_block/ + + Set the ``blocked`` attribute to ``true`` for a large number of seats at once. + You can pass either a list of ``id`` values or a list of ``seat_guid`` values. + You can pass up to 10,000 seats in one request. + + The endpoint will return an error if you pass a seat ID that does not exist. + However, it will not return an error if one of the passed seats is already blocked or sold. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_block/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "ids": [12, 45, 56] + } + + or + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_block/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "seat_guids": ["6c0e29e5-05d6-421f-99f3-afd01478ecad", "c2899340-e2e7-4d05-8100-000a4b6d7cf4"] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + {} + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param subevent_id: The ``id`` field of the subevent to modify + :statuscode 200: no error + :statuscode 400: The seat could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource. + :statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/seats/bulk_unblock/ +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/bulk_unblock/ + + Set the ``blocked`` attribute to ``false`` for a large number of seats at once. + You can pass either a list of ``id`` values or a list of ``seat_guid`` values. + You can pass up to 10,000 seats in one request. + + The endpoint will return an error if you pass a seat ID that does not exist. + However, it will not return an error if one of the passed seat is already unblocked or is sold. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_unblock/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "ids": [12, 45, 56] + } + + or + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_unblock/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "seat_guids": ["6c0e29e5-05d6-421f-99f3-afd01478ecad", "c2899340-e2e7-4d05-8100-000a4b6d7cf4"] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + {} + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param subevent_id: The ``id`` field of the subevent to modify + :statuscode 200: no error + :statuscode 400: The seat could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource. + :statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa. diff --git a/doc/api/resources/subevents.rst b/doc/api/resources/subevents.rst index 544e2af39..768ad07f0 100644 --- a/doc/api/resources/subevents.rst +++ b/doc/api/resources/subevents.rst @@ -136,6 +136,7 @@ Endpoints } :query page: The page number in case of a multi-page result set, default is 1 + :query is_public: If set to ``true``/``false``, only subevents with a matching value of ``is_public`` are returned. :query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned. :query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. :query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. @@ -467,6 +468,7 @@ Endpoints } :query page: The page number in case of a multi-page result set, default is 1 + :query is_public: If set to ``true``/``false``, only subevents with a matching value of ``is_public`` are returned. :query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned. :query event__live: If set to ``true``/``false``, only events with a matching value of ``live`` on the parent event are returned. :query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. diff --git a/doc/development/api/cookieconsent.rst b/doc/development/api/cookieconsent.rst index 64e67f5ba..75fb2a4e4 100644 --- a/doc/development/api/cookieconsent.rst +++ b/doc/development/api/cookieconsent.rst @@ -17,6 +17,7 @@ First, you need to declare that you are using non-essential cookies by respondin signal: .. automodule:: pretix.presale.signals + :no-index: :members: register_cookie_providers You are expected to return a list of ``CookieProvider`` objects instantiated from the following class: diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index 5b558ef96..fdd34717b 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -22,12 +22,14 @@ Order events There are multiple signals that will be sent out in the ordering cycle: .. automodule:: pretix.base.signals + :no-index: :members: validate_cart, validate_cart_addons, validate_order, order_valid_if_pending, order_fee_calculation, order_paid, order_placed, order_canceled, order_reactivated, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split, order_gracefully_delete, invoice_line_text Check-ins """"""""" .. automodule:: pretix.base.signals + :no-index: :members: checkin_created @@ -39,18 +41,21 @@ Frontend .. automodule:: pretix.presale.signals + :no-index: :members: order_info, order_info_top, order_meta_from_request, order_api_meta_from_request Request flow """""""""""" .. automodule:: pretix.presale.signals + :no-index: :members: process_request, process_response Vouchers """""""" .. automodule:: pretix.presale.signals + :no-index: :members: voucher_redeem_info Backend @@ -62,24 +67,28 @@ Backend item_formsets, order_search_filter_q, order_search_forms .. automodule:: pretix.base.signals + :no-index: :members: logentry_display, logentry_object_link, requiredaction_display, timeline_events, orderposition_blocked_display, customer_created, customer_signed_in Vouchers """""""" .. automodule:: pretix.control.signals + :no-index: :members: item_forms, voucher_form_class, voucher_form_html, voucher_form_validation Dashboards """""""""" .. automodule:: pretix.control.signals + :no-index: :members: event_dashboard_widgets, user_dashboard_widgets, event_dashboard_top Ticket designs """""""""""""" .. automodule:: pretix.base.signals + :no-index: :members: layout_text_variables, layout_image_variables .. automodule:: pretix.plugins.ticketoutputpdf.signals @@ -89,4 +98,9 @@ API --- .. automodule:: pretix.base.signals + :no-index: :members: validate_event_settings, api_event_settings_fields + +.. automodule:: pretix.api.signals + :no-index: + :members: register_device_security_profile diff --git a/doc/development/api/import.rst b/doc/development/api/import.rst index 4a2388ac3..a0dd1f794 100644 --- a/doc/development/api/import.rst +++ b/doc/development/api/import.rst @@ -60,6 +60,7 @@ that we'll provide in this plugin: Similar signals exist for other objects: .. automodule:: pretix.base.signals + :no-index: :members: voucher_import_columns diff --git a/doc/development/api/invoice.rst b/doc/development/api/invoice.rst index 7b62a3727..69dcc222d 100644 --- a/doc/development/api/invoice.rst +++ b/doc/development/api/invoice.rst @@ -84,8 +84,6 @@ convenient to you: .. automethod:: _register_fonts - .. automethod:: _register_event_fonts - .. automethod:: _on_first_page .. automethod:: _on_other_page diff --git a/doc/development/api/placeholder.rst b/doc/development/api/placeholder.rst index 281d244ee..70ad9b74a 100644 --- a/doc/development/api/placeholder.rst +++ b/doc/development/api/placeholder.rst @@ -86,7 +86,10 @@ Signals ------- .. automodule:: pretix.base.signals + :no-index: :members: register_text_placeholders + .. automodule:: pretix.base.signals + :no-index: :members: register_mail_placeholders diff --git a/doc/plugins/kulturpass.rst b/doc/plugins/kulturpass.rst index 2babe4180..2a08b8fd3 100644 --- a/doc/plugins/kulturpass.rst +++ b/doc/plugins/kulturpass.rst @@ -1,5 +1,5 @@ KulturPass -========= +========== .. note:: diff --git a/doc/plugins/presale-saml.rst b/doc/plugins/presale-saml.rst index 41fb56b83..908eb827b 100644 --- a/doc/plugins/presale-saml.rst +++ b/doc/plugins/presale-saml.rst @@ -158,7 +158,7 @@ expects and - more importantly - supports. for a sample configuration in an academic context. Note, that you can have multiple attributes with the same ``friendlyName`` - but different ``name``s. This is often used in systems, where the same + but different ``name`` value. This is often used in systems, where the same information (for example a persons name) is saved in different fields - for example because one institution is returning SAML 1.0 and other institutions are returning SAML 2.0 style attributes. Typically, this only diff --git a/doc/plugins/ticketoutputpdf.rst b/doc/plugins/ticketoutputpdf.rst index e82fe5cc3..d784d89bc 100644 --- a/doc/plugins/ticketoutputpdf.rst +++ b/doc/plugins/ticketoutputpdf.rst @@ -29,8 +29,8 @@ item_assignments list of objects Products this l ===================================== ========================== ======================================================= -Endpoints ---------- +Layout endpoints +---------------- .. http:get:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/ @@ -268,5 +268,75 @@ Endpoints :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. +Ticket rendering endpoint +----------------------------- + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/ticketpdfrenderer/render_batch/ + + With this API call, you can instruct the system to render a set of tickets into one combined PDF file. To specify + which tickets to render, you need to submit a list of "parts". For every part, the following fields are supported: + + * ``orderposition`` (``integer``, required): The ID of the order position to render. + * ``override_channel`` (``string``, optional): The sales channel ID to be used for layout selection instead of the + original channel of the order. + * ``override_layout`` (``integer``, optional): The ticket layout ID to be used instead of the auto-selected one. + + If your input parameters validate correctly, a ``202 Accepted`` status code is returned. + The body points you to the download URL of the result. Running a ``GET`` request on that result URL will + yield one of the following status codes: + + * ``200 OK`` – The export succeeded. The body will be your resulting file. Might be large! + * ``409 Conflict`` – Your export is still running. The body will be JSON with the structure ``{"status": "running"}``. ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do. + * ``410 Gone`` – Running the export has failed permanently. The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}`` + * ``404 Not Found`` – The export does not exist / is expired. + + .. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice. + + .. note:: To avoid performance issues, a maximum number of 1000 parts is currently allowed. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/ticketpdfrenderer/render_batch/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "parts": [ + { + "orderposition": 55412 + }, + { + "orderposition": 55412, + "override_channel": "web" + }, + { + "orderposition": 55412, + "override_layout": 56 + } + ] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "download": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/ticketpdfrenderer/download/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/" + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 202: no error + :statuscode 400: Invalid input options + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + .. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/pdf-layout.schema.json diff --git a/pyproject.toml b/pyproject.toml index 7a382bc35..8f7185ace 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "arabic-reshaper==3.0.0", # Support for Arabic in reportlab "babel", "BeautifulSoup4==4.12.*", - "bleach==5.0.*", + "bleach==6.2.*", "celery==5.4.*", "chardet==5.2.*", "cryptography>=3.4.2", @@ -43,8 +43,8 @@ dependencies = [ "django-formset-js-improved==0.5.0.3", "django-formtools==2.5.1", "django-hierarkey==1.2.*", - "django-hijack==3.6.*", - "django-i18nfield==1.9.*,>=1.9.4", + "django-hijack==3.7.*", + "django-i18nfield==1.9.*,>=1.9.5", "django-libsass==0.9", "django-localflavor==4.0", "django-markup", @@ -53,9 +53,9 @@ dependencies = [ "django-phonenumber-field==7.3.*", "django-redis==5.4.*", "django-scopes==2.0.*", - "django-statici18n==2.5.*", + "django-statici18n==2.6.*", "djangorestframework==3.15.*", - "dnspython==2.6.*", + "dnspython==2.7.*", "drf_ujson2==1.7.*", "geoip2==4.*", "importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+ @@ -74,55 +74,53 @@ dependencies = [ "paypal-checkout-serversdk==1.0.*", "PyJWT==2.9.*", "phonenumberslite==8.13.*", - "Pillow==10.4.*", + "Pillow==11.0.*", "pretix-plugin-build", - "protobuf==5.28.*", + "protobuf==5.29.*", "psycopg2-binary", "pycountry", "pycparser==2.22", "pycryptodome==3.21.*", - "pypdf==5.0.*", + "pypdf==5.1.*", "python-bidi==0.6.*", # Support for Arabic in reportlab "python-dateutil==2.9.*", "pytz", "pytz-deprecation-shim==0.1.*", "pyuca", "qrcode==8.0", - "redis==5.1.*", + "redis==5.2.*", "reportlab==4.2.*", "requests==2.31.*", - "sentry-sdk==2.15.*", + "sentry-sdk==2.18.*", "sepaxml==2.6.*", - "slimit", "stripe==7.9.*", "text-unidecode==1.*", "tlds>=2020041600", "tqdm==4.*", - "ua-parser==0.18.*", + "ua-parser==1.0.*", "vat_moss_forked==2020.3.20.0.11.0", "vobject==0.9.*", - "webauthn==2.2.*", - "zeep==4.2.*" + "webauthn==2.3.*", + "zeep==4.3.*" ] [project.optional-dependencies] memcached = ["pylibmc"] dev = [ - "aiohttp==3.10.*", + "aiohttp==3.11.*", "coverage", "coveralls", - "fakeredis==2.24.*", + "fakeredis==2.26.*", "flake8==7.1.*", "freezegun", "isort==5.13.*", "pep8-naming==0.14.*", "potypo", - "pytest-asyncio", + "pytest-asyncio>=0.24", "pytest-cache", "pytest-cov", "pytest-django==4.*", "pytest-mock==3.14.*", - "pytest-rerunfailures==14.*", "pytest-sugar", "pytest-xdist==3.6.*", "pytest==8.3.*", diff --git a/src/pretix/__init__.py b/src/pretix/__init__.py index 1bf5b50ea..03617b434 100644 --- a/src/pretix/__init__.py +++ b/src/pretix/__init__.py @@ -19,4 +19,4 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -__version__ = "2024.10.0.dev0" +__version__ = "2024.12.0.dev0" diff --git a/src/pretix/api/auth/device.py b/src/pretix/api/auth/device.py index ac09de159..dd9b4c287 100644 --- a/src/pretix/api/auth/device.py +++ b/src/pretix/api/auth/device.py @@ -27,7 +27,7 @@ from rest_framework import exceptions from rest_framework.authentication import TokenAuthentication from pretix.api.auth.devicesecurity import ( - DEVICE_SECURITY_PROFILES, FullAccessSecurityProfile, + FullAccessSecurityProfile, get_all_security_profiles, ) from pretix.base.models import Device @@ -58,7 +58,8 @@ class DeviceTokenAuthentication(TokenAuthentication): def authenticate(self, request): r = super().authenticate(request) if r and isinstance(r[1], Device): - profile = DEVICE_SECURITY_PROFILES.get(r[1].security_profile, FullAccessSecurityProfile) + profiles = get_all_security_profiles() + profile = profiles.get(r[1].security_profile, FullAccessSecurityProfile()) if not profile.is_allowed(request): raise exceptions.PermissionDenied('Request denied by device security profile.') return r diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index 54f905ffa..d6be03e72 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -20,13 +20,40 @@ # . # import logging +from collections import OrderedDict +from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ +from pretix.api.signals import register_device_security_profile + logger = logging.getLogger(__name__) +_ALL_PROFILES = None -class FullAccessSecurityProfile: +class BaseSecurityProfile: + @property + def identifier(self) -> str: + """ + Unique identifier for this profile. + """ + raise NotImplementedError() + + @property + def verbose_name(self) -> str: + """ + Human-readable name (can be a ``gettext_lazy`` object). + """ + raise NotImplementedError() + + def is_allowed(self, request) -> bool: + """ + Return whether a given request should be allowed. + """ + raise NotImplementedError() + + +class FullAccessSecurityProfile(BaseSecurityProfile): identifier = 'full' verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)') @@ -34,7 +61,7 @@ class FullAccessSecurityProfile: return True -class AllowListSecurityProfile: +class AllowListSecurityProfile(BaseSecurityProfile): allowlist = () def is_allowed(self, request): @@ -77,6 +104,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile): ('GET', 'api-v1:blockedsecrets-list'), ('GET', 'api-v1:order-list'), ('GET', 'api-v1:orderposition-pdf_image'), + ('POST', 'api-v1:orderposition-printlog'), ('GET', 'api-v1:event.settings'), ('POST', 'api-v1:upload'), ('POST', 'api-v1:checkinrpc.redeem'), @@ -112,6 +140,7 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile): ('GET', 'api-v1:revokedsecrets-list'), ('GET', 'api-v1:blockedsecrets-list'), ('GET', 'api-v1:orderposition-pdf_image'), + ('POST', 'api-v1:orderposition-printlog'), ('GET', 'api-v1:event.settings'), ('POST', 'api-v1:upload'), ('POST', 'api-v1:checkinrpc.redeem'), @@ -147,6 +176,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile): ('GET', 'api-v1:revokedsecrets-list'), ('GET', 'api-v1:blockedsecrets-list'), ('GET', 'api-v1:orderposition-pdf_image'), + ('POST', 'api-v1:orderposition-printlog'), ('GET', 'api-v1:event.settings'), ('POST', 'api-v1:upload'), ('POST', 'api-v1:checkinrpc.redeem'), @@ -154,87 +184,28 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile): ) -class PretixPosSecurityProfile(AllowListSecurityProfile): - identifier = 'pretixpos' - verbose_name = _('pretixPOS') - allowlist = ( - ('GET', 'api-v1:version'), - ('GET', 'api-v1:device.eventselection'), - ('GET', 'api-v1:idempotency.query'), - ('GET', 'api-v1:device.info'), - ('POST', 'api-v1:device.update'), - ('POST', 'api-v1:device.revoke'), - ('POST', 'api-v1:device.roll'), - ('GET', 'api-v1:event-list'), - ('GET', 'api-v1:event-detail'), - ('GET', 'api-v1:subevent-list'), - ('GET', 'api-v1:subevent-detail'), - ('GET', 'api-v1:itemcategory-list'), - ('GET', 'api-v1:item-list'), - ('GET', 'api-v1:question-list'), - ('GET', 'api-v1:quota-list'), - ('GET', 'api-v1:taxrule-list'), - ('GET', 'api-v1:ticketlayout-list'), - ('GET', 'api-v1:ticketlayoutitem-list'), - ('GET', 'api-v1:badgelayout-list'), - ('GET', 'api-v1:badgeitem-list'), - ('GET', 'api-v1:voucher-list'), - ('GET', 'api-v1:voucher-detail'), - ('GET', 'api-v1:order-list'), - ('POST', 'api-v1:order-list'), - ('GET', 'api-v1:order-detail'), - ('DELETE', 'api-v1:orderposition-detail'), - ('PATCH', 'api-v1:orderposition-detail'), - ('GET', 'api-v1:orderposition-list'), - ('GET', 'api-v1:orderposition-answer'), - ('GET', 'api-v1:orderposition-pdf_image'), - ('POST', 'api-v1:order-mark-canceled'), - ('POST', 'api-v1:orderpayment-list'), - ('POST', 'api-v1:orderrefund-list'), - ('POST', 'api-v1:orderrefund-done'), - ('POST', 'api-v1:cartposition-list'), - ('POST', 'api-v1:cartposition-bulk-create'), - ('GET', 'api-v1:checkinlist-list'), - ('POST', 'api-v1:checkinlistpos-redeem'), - ('POST', 'plugins:pretix_posbackend:order.posprintlog'), - ('POST', 'plugins:pretix_posbackend:order.poslock'), - ('DELETE', 'plugins:pretix_posbackend:order.poslock'), - ('DELETE', 'api-v1:cartposition-detail'), - ('GET', 'api-v1:giftcard-list'), - ('POST', 'api-v1:giftcard-transact'), - ('PATCH', 'api-v1:giftcard-detail'), - ('GET', 'plugins:pretix_posbackend:posclosing-list'), - ('POST', 'plugins:pretix_posbackend:posreceipt-list'), - ('POST', 'plugins:pretix_posbackend:posclosing-list'), - ('POST', 'plugins:pretix_posbackend:posdebugdump-list'), - ('POST', 'plugins:pretix_posbackend:posdebuglogentry-list'), - ('POST', 'plugins:pretix_posbackend:posdebuglogentry-bulk-create'), - ('GET', 'plugins:pretix_posbackend:poscashier-list'), - ('POST', 'plugins:pretix_posbackend:stripeterminal.token'), - ('POST', 'plugins:pretix_posbackend:stripeterminal.paymentintent'), - ('PUT', 'plugins:pretix_posbackend:file.upload'), - ('GET', 'api-v1:revokedsecrets-list'), - ('GET', 'api-v1:blockedsecrets-list'), - ('GET', 'api-v1:event.settings'), - ('GET', 'plugins:pretix_seating:event.event'), - ('GET', 'plugins:pretix_seating:event.event.subevent'), - ('GET', 'plugins:pretix_seating:event.plan'), - ('GET', 'plugins:pretix_seating:selection.simple'), - ('POST', 'api-v1:upload'), - ('POST', 'api-v1:checkinrpc.redeem'), - ('GET', 'api-v1:checkinrpc.search'), - ('POST', 'api-v1:reusablemedium-lookup'), - ('GET', 'api-v1:reusablemedium-list'), - ('POST', 'api-v1:reusablemedium-list'), - ) +def get_all_security_profiles(): + global _ALL_PROFILES + + if _ALL_PROFILES: + return _ALL_PROFILES + + types = OrderedDict() + for recv, ret in register_device_security_profile.send(None): + if isinstance(ret, (list, tuple)): + for r in ret: + types[r.identifier] = r + else: + types[ret.identifier] = ret + _ALL_PROFILES = types + return types -DEVICE_SECURITY_PROFILES = { - k.identifier: k() for k in ( - FullAccessSecurityProfile, - PretixScanSecurityProfile, - PretixScanNoSyncSecurityProfile, - PretixScanNoSyncNoSearchSecurityProfile, - PretixPosSecurityProfile, +@receiver(register_device_security_profile, dispatch_uid="base_register_default_security_profiles") +def register_default_webhook_events(sender, **kwargs): + return ( + FullAccessSecurityProfile(), + PretixScanSecurityProfile(), + PretixScanNoSyncSecurityProfile(), + PretixScanNoSyncNoSearchSecurityProfile(), ) -} diff --git a/src/pretix/api/serializers/__init__.py b/src/pretix/api/serializers/__init__.py index cf1ce268b..a0c83e3de 100644 --- a/src/pretix/api/serializers/__init__.py +++ b/src/pretix/api/serializers/__init__.py @@ -110,6 +110,10 @@ class SalesChannelMigrationMixin: data["all_sales_channels"] = False data["limit_sales_channels"] = data["sales_channels"] del data["sales_channels"] + + if data.get("all_sales_channels"): + data["limit_sales_channels"] = [] + return super().to_internal_value(data) def to_representation(self, value): diff --git a/src/pretix/api/serializers/cart.py b/src/pretix/api/serializers/cart.py index 6c7f457c2..ecf6b0754 100644 --- a/src/pretix/api/serializers/cart.py +++ b/src/pretix/api/serializers/cart.py @@ -235,7 +235,7 @@ class CartPositionCreateSerializer(BaseCartPositionCreateSerializer): return cid def create(self, validated_data): - validated_data.pop('sales_channel') + validated_data.pop('sales_channel', None) addons_data = validated_data.pop('addons', None) bundled_data = validated_data.pop('bundled', None) diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index fea029dad..c13a14a62 100644 --- a/src/pretix/api/serializers/checkin.py +++ b/src/pretix/api/serializers/checkin.py @@ -26,31 +26,22 @@ from rest_framework.exceptions import ValidationError from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.media import MEDIA_TYPES -from pretix.base.models import Checkin, CheckinList, SalesChannel +from pretix.base.models import Checkin, CheckinList class CheckinListSerializer(I18nAwareModelSerializer): checkin_count = serializers.IntegerField(read_only=True) position_count = serializers.IntegerField(read_only=True) - auto_checkin_sales_channels = serializers.SlugRelatedField( - slug_field="identifier", - queryset=SalesChannel.objects.none(), - required=False, - allow_empty=True, - many=True, - ) class Meta: model = CheckinList fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count', - 'include_pending', 'auto_checkin_sales_channels', 'allow_multiple_entries', 'allow_entry_after_exit', + 'include_pending', 'allow_multiple_entries', 'allow_entry_after_exit', 'rules', 'exit_all_at', 'addon_match', 'ignore_in_statistics', 'consider_tickets_used') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields['auto_checkin_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all() - if 'subevent' in self.context['request'].query_params.getlist('expand'): self.fields['subevent'] = SubEventSerializer(read_only=True) diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index dcd9f2945..160e0ce43 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -35,7 +35,7 @@ import logging from django.conf import settings -from django.core.exceptions import PermissionDenied, ValidationError +from django.core.exceptions import PermissionDenied from django.db import transaction from django.utils.crypto import get_random_string from django.utils.functional import cached_property @@ -43,6 +43,7 @@ from django.utils.translation import gettext as _ from django_countries.serializers import CountryFieldMixin from pytz import common_timezones from rest_framework import serializers +from rest_framework.exceptions import ValidationError from rest_framework.fields import ChoiceField, Field from rest_framework.relations import SlugRelatedField @@ -772,6 +773,7 @@ class EventSettingsSerializer(SettingsSerializer): 'invoice_address_company_required', 'invoice_address_beneficiary', 'invoice_address_custom_field', + 'invoice_address_custom_field_helptext', 'invoice_name_required', 'invoice_address_not_asked_free', 'invoice_show_payments', @@ -916,6 +918,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer): 'invoice_address_company_required', 'invoice_address_beneficiary', 'invoice_address_custom_field', + 'invoice_address_custom_field_helptext', 'invoice_name_required', 'invoice_address_not_asked_free', 'invoice_address_from_name', @@ -987,6 +990,40 @@ def prefetch_by_id(items, qs, id_attr, target_attr): setattr(item, target_attr, result.get(getattr(item, id_attr))) +class SeatBulkBlockInputSerializer(serializers.Serializer): + ids = serializers.ListField(child=serializers.IntegerField(), required=False, allow_empty=True) + seat_guids = serializers.ListField(child=serializers.CharField(), required=False, allow_empty=True) + + def to_internal_value(self, data): + data = super().to_internal_value(data) + + if data.get("seat_guids") and data.get("ids"): + raise ValidationError("Please pass either seat_guids or ids.") + + if data.get("seat_guids"): + seat_ids = data["seat_guids"] + if len(seat_ids) > 10000: + raise ValidationError({"seat_guids": ["Please do not pass over 10000 seats."]}) + + seats = {s.seat_guid: s for s in self.context["queryset"].filter(seat_guid__in=seat_ids)} + for s in seat_ids: + if s not in seats: + raise ValidationError({"seat_guids": [f"The seat '{s}' does not exist."]}) + elif data.get("ids"): + seat_ids = data["ids"] + if len(seat_ids) > 10000: + raise ValidationError({"ids": ["Please do not pass over 10000 seats."]}) + + seats = self.context["queryset"].in_bulk(seat_ids) + for s in seat_ids: + if s not in seats: + raise ValidationError({"ids": [f"The seat '{s}' does not exist."]}) + else: + raise ValidationError("Please pass either seat_guids or ids.") + + return {"seats": seats.values()} + + class SeatSerializer(I18nAwareModelSerializer): orderposition = serializers.IntegerField(source='orderposition_id') cartposition = serializers.IntegerField(source='cartposition_id') diff --git a/src/pretix/api/serializers/i18n.py b/src/pretix/api/serializers/i18n.py index 80a94c016..3fc91e041 100644 --- a/src/pretix/api/serializers/i18n.py +++ b/src/pretix/api/serializers/i18n.py @@ -19,57 +19,8 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -from django.conf import settings from django.core.validators import URLValidator -from i18nfield.fields import I18nCharField, I18nTextField -from i18nfield.strings import LazyI18nString -from rest_framework.exceptions import ValidationError -from rest_framework.fields import Field -from rest_framework.serializers import ModelSerializer - - -class I18nField(Field): - def __init__(self, **kwargs): - self.allow_blank = kwargs.pop('allow_blank', False) - self.trim_whitespace = kwargs.pop('trim_whitespace', True) - self.max_length = kwargs.pop('max_length', None) - self.min_length = kwargs.pop('min_length', None) - super().__init__(**kwargs) - - def to_representation(self, value): - if hasattr(value, 'data'): - if isinstance(value.data, dict): - return value.data - elif value.data is None: - return None - else: - return { - settings.LANGUAGE_CODE: str(value.data) - } - elif value is None: - return None - else: - return { - settings.LANGUAGE_CODE: str(value) - } - - def to_internal_value(self, data): - if isinstance(data, str): - return LazyI18nString(data) - elif isinstance(data, dict): - if any([k not in dict(settings.LANGUAGES) for k in data.keys()]): - raise ValidationError('Invalid languages included.') - return LazyI18nString(data) - else: - raise ValidationError('Invalid data type.') - - -class I18nAwareModelSerializer(ModelSerializer): - pass - - -I18nAwareModelSerializer.serializer_field_mapping[I18nCharField] = I18nField -I18nAwareModelSerializer.serializer_field_mapping[I18nTextField] = I18nField +from i18nfield.rest_framework import I18nAwareModelSerializer, I18nField class I18nURLField(I18nField): @@ -84,3 +35,10 @@ class I18nURLField(I18nField): else: URLValidator()(value.data) return value + + +__all__ = [ + "I18nAwareModelSerializer", # for backwards compatibility + "I18nField", # for backwards compatibility + "I18nURLField", +] diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 988e80962..7133776f8 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -369,7 +369,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): require_membership_types = validated_data.pop('require_membership_types', []) limit_sales_channels = validated_data.pop('limit_sales_channels', []) item = Item.objects.create(**validated_data) - if limit_sales_channels: + if limit_sales_channels and not validated_data.get('all_sales_channels'): item.limit_sales_channels.add(*limit_sales_channels) if picture: item.picture.save(os.path.basename(picture.name), picture) @@ -441,7 +441,22 @@ class ItemCategorySerializer(I18nAwareModelSerializer): class Meta: model = ItemCategory - fields = ('id', 'name', 'internal_name', 'description', 'position', 'is_addon') + fields = ( + 'id', 'name', 'internal_name', 'description', 'position', + 'is_addon', 'cross_selling_mode', + 'cross_selling_condition', 'cross_selling_match_products' + ) + + def validate(self, data): + data = super().validate(data) + + full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} + full_data.update(data) + + if full_data.get('is_addon') and full_data.get('cross_selling_mode'): + raise ValidationError('is_addon and cross_selling_mode are mutually exclusive') + + return data class QuestionOptionSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 807dd5579..b1e0102c6 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -55,7 +55,7 @@ from pretix.base.models import ( ) from pretix.base.models.orders import ( BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, - RevokedTicketSecret, + PrintLog, RevokedTicketSecret, ) from pretix.base.pdf import get_images, get_variables from pretix.base.services.cart import error_messages @@ -284,6 +284,26 @@ class CheckinSerializer(I18nAwareModelSerializer): fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'device_id', 'type') +class PrintLogSerializer(serializers.ModelSerializer): + device_id = serializers.SlugRelatedField( + source='device', + slug_field='device_id', + read_only=True, + ) + + class Meta: + model = PrintLog + fields = ( + "id", + "successful", + "datetime", + "source", + "type", + "device_id", + "info", + ) + + class FailedCheckinSerializer(I18nAwareModelSerializer): error_reason = serializers.ChoiceField(choices=Checkin.REASONS, required=True, allow_null=False) raw_barcode = serializers.CharField(required=True, allow_null=False) @@ -476,6 +496,7 @@ class OrderPositionListSerializer(serializers.ListSerializer): class OrderPositionSerializer(I18nAwareModelSerializer): checkins = CheckinSerializer(many=True, read_only=True) + print_logs = PrintLogSerializer(many=True, read_only=True) answers = AnswerSerializer(many=True) downloads = PositionDownloadsField(source='*', read_only=True) order = serializers.SlugRelatedField(slug_field='code', read_only=True) @@ -490,7 +511,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer): fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', - 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled', + 'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use') read_only_fields = ( 'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret', @@ -577,9 +598,9 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer): fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'company', 'street', 'zipcode', 'city', 'country', 'state', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', - 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention', - 'order__status', 'order__valid_if_pending', 'order__require_approval', 'valid_from', 'valid_until', - 'blocked') + 'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', + 'require_attention', 'order__status', 'order__valid_if_pending', 'order__require_approval', + 'valid_from', 'valid_until', 'blocked') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -732,12 +753,12 @@ class OrderSerializer(I18nAwareModelSerializer): 'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date', 'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', - 'url', 'customer', 'valid_if_pending', 'api_meta' + 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date' ) read_only_fields = ( 'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date', 'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer', - 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel' + 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date' ) def __init__(self, *args, **kwargs): @@ -1494,6 +1515,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): pos.answers = answers pos.pseudonymization_id = "PREVIEW" pos.checkins = [] + pos.print_logs = [] pos_map[pos.positionid] = pos else: if pos.voucher: diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index cb47fed20..c49d86b32 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -29,6 +29,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.exceptions import ValidationError +from pretix.api.auth.devicesecurity import get_all_security_profiles from pretix.api.serializers import AsymmetricField from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.order import CompatibleJSONField @@ -297,6 +298,7 @@ class DeviceSerializer(serializers.ModelSerializer): revoked = serializers.BooleanField(read_only=True) initialized = serializers.DateTimeField(read_only=True) initialization_token = serializers.DateTimeField(read_only=True) + security_profile = serializers.ChoiceField(choices=[], required=False, default="full") class Meta: model = Device @@ -306,6 +308,10 @@ class DeviceSerializer(serializers.ModelSerializer): 'os_name', 'os_version', 'software_brand', 'software_version', 'security_profile' ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['security_profile'].choices = [(k, v.verbose_name) for k, v in get_all_security_profiles().items()] + class TeamInviteSerializer(serializers.ModelSerializer): class Meta: diff --git a/src/pretix/api/signals.py b/src/pretix/api/signals.py index a44119419..40383d5d8 100644 --- a/src/pretix/api/signals.py +++ b/src/pretix/api/signals.py @@ -32,10 +32,17 @@ from pretix.helpers.periodic import minimum_interval register_webhook_events = Signal() """ This signal is sent out to get all known webhook events. Receivers should return an -instance of a subclass of pretix.api.webhooks.WebhookEvent or a list of such +instance of a subclass of ``pretix.api.webhooks.WebhookEvent`` or a list of such instances. """ +register_device_security_profile = Signal() +""" +This signal is sent out to get all known device security_profiles. Receivers should +return an instance of a subclass of ``pretix.api.auth.devicesecurity.BaseSecurityProfile`` +or a list of such instances. +""" + @receiver(periodic_task) @scopes_disabled() diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 668b2e19c..82f0eac19 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -62,6 +62,7 @@ from pretix.base.models import ( CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition, Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken, ) +from pretix.base.models.orders import PrintLog from pretix.base.services.checkin import ( CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, ) @@ -115,7 +116,7 @@ class CheckinListViewSet(viewsets.ModelViewSet): if 'subevent' in self.request.query_params.getlist('expand'): qs = qs.prefetch_related( 'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set', - 'subevent__seat_category_mappings', 'subevent__meta_values', 'auto_checkin_sales_channels' + 'subevent__seat_category_mappings', 'subevent__meta_values', ) return qs @@ -142,7 +143,9 @@ class CheckinListViewSet(viewsets.ModelViewSet): data=self.request.data ) + @transaction.atomic def perform_destroy(self, instance): + instance.checkins.all().delete() instance.log_action( 'pretix.event.checkinlist.deleted', user=self.request.user, @@ -365,8 +368,9 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr qs = qs.prefetch_related( Prefetch( lookup='checkins', - queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]) + queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device') ), + Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), 'answers', 'answers__options', 'answers__question', Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')), Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related( @@ -378,6 +382,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr 'positions', OrderPosition.objects.prefetch_related( Prefetch('checkins', queryset=Checkin.objects.select_related('device')), + Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), 'item', 'variation', 'answers', 'answers__options', 'answers__question', ) ) @@ -389,8 +394,9 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr qs = qs.prefetch_related( Prefetch( lookup='checkins', - queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]) + queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device') ), + Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), 'answers', 'answers__options', 'answers__question', Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')) ).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat') diff --git a/src/pretix/api/views/device.py b/src/pretix/api/views/device.py index 770498bd9..6db8e5a56 100644 --- a/src/pretix/api/views/device.py +++ b/src/pretix/api/views/device.py @@ -20,6 +20,7 @@ # . # import base64 +import copy import logging from cryptography.hazmat.backends.openssl.backend import Backend @@ -146,6 +147,8 @@ class InitializeView(APIView): permission_classes = () def post(self, request, format=None): + from pretix.base.signals import device_info_updated + serializer = InitializationRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -160,6 +163,8 @@ class InitializeView(APIView): if device.revoked: raise ValidationError({'token': ['This initialization token has been revoked.']}) + old_instance = copy.copy(device) + device.initialized = now() device.hardware_brand = serializer.validated_data.get('hardware_brand') device.hardware_model = serializer.validated_data.get('hardware_model') @@ -174,6 +179,10 @@ class InitializeView(APIView): device.log_action('pretix.device.initialized', data=serializer.validated_data, auth=device) + device_info_updated.send( + sender=Device, old_device=old_instance, new_device=device + ) + serializer = DeviceSerializer(device) return Response(serializer.data) @@ -182,9 +191,12 @@ class UpdateView(APIView): authentication_classes = (DeviceTokenAuthentication,) def post(self, request, format=None): + from pretix.base.signals import device_info_updated + serializer = UpdateRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) device = request.auth + old_instance = copy.copy(device) device.hardware_brand = serializer.validated_data.get('hardware_brand') device.hardware_model = serializer.validated_data.get('hardware_model') device.os_name = serializer.validated_data.get('os_name') @@ -200,9 +212,8 @@ class UpdateView(APIView): device.save() device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device) - from ...base.signals import device_info_updated device_info_updated.send( - sender=Device, old_device=request.auth, new_device=device + sender=Device, old_device=old_instance, new_device=device ) serializer = DeviceSerializer(device) diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index 0a33f58e5..659d41e3c 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -40,6 +40,7 @@ from django.utils.timezone import now from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_scopes import scopes_disabled from rest_framework import serializers, views, viewsets +from rest_framework.decorators import action from rest_framework.exceptions import ( NotFound, PermissionDenied, ValidationError, ) @@ -50,8 +51,9 @@ from pretix.api.auth.permission import EventCRUDPermission from pretix.api.pagination import TotalOrderingFilter from pretix.api.serializers.event import ( CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer, - EventSettingsSerializer, ItemMetaPropertiesSerializer, SeatSerializer, - SubEventSerializer, TaxRuleSerializer, + EventSettingsSerializer, ItemMetaPropertiesSerializer, + SeatBulkBlockInputSerializer, SeatSerializer, SubEventSerializer, + TaxRuleSerializer, ) from pretix.api.views import ConditionalListView from pretix.base.models import ( @@ -237,9 +239,9 @@ class EventViewSet(viewsets.ModelViewSet): disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value} changed = merge_dicts(enabled, disabled) - for module, action in changed.items(): + for module, operation in changed.items(): serializer.instance.log_action( - 'pretix.event.plugins.' + action, + 'pretix.event.plugins.' + operation, user=self.request.user, auth=self.request.auth, data={'plugin': module} @@ -297,7 +299,8 @@ class EventViewSet(viewsets.ModelViewSet): if 'all_sales_channels' in serializer.validated_data and 'sales_channels' in serializer.validated_data: new_event.all_sales_channels = serializer.validated_data['all_sales_channels'] - new_event.limit_sales_channels.set(serializer.validated_data['limit_sales_channels']) + if not new_event.all_sales_channels: + new_event.limit_sales_channels.set(serializer.validated_data['limit_sales_channels']) else: serializer.instance.set_defaults() @@ -370,7 +373,7 @@ with scopes_disabled(): class Meta: model = SubEvent - fields = ['active', 'event__live'] + fields = ['is_public', 'active', 'event__live'] def ends_after_qs(self, queryset, name, value): expr = Q( @@ -743,3 +746,24 @@ class SeatViewSet(ConditionalListView, viewsets.ModelViewSet): auth=self.request.auth, data={"seats": [serializer.instance.pk]}, ) + + def bulk_change_blocked(self, blocked): + s = SeatBulkBlockInputSerializer( + data=self.request.data, + context={"event": self.request.event, "queryset": self.get_queryset()}, + ) + s.is_valid(raise_exception=True) + + seats = s.validated_data["seats"] + for seat in seats: + seat.blocked = blocked + Seat.objects.bulk_update(seats, ["blocked"], batch_size=1000) + return Response({}) + + @action(methods=["POST"], detail=False) + def bulk_block(self, request, *args, **kwargs): + return self.bulk_change_blocked(True) + + @action(methods=["POST"], detail=False) + def bulk_unblock(self, request, *args, **kwargs): + return self.bulk_change_blocked(False) diff --git a/src/pretix/api/views/media.py b/src/pretix/api/views/media.py index e70f1bf76..4954a9fe0 100644 --- a/src/pretix/api/views/media.py +++ b/src/pretix/api/views/media.py @@ -42,6 +42,7 @@ from pretix.base.models import ( Checkin, GiftCard, GiftCardAcceptance, GiftCardTransaction, OrderPosition, ReusableMedium, ) +from pretix.base.models.orders import PrintLog from pretix.helpers import OF_SELF from pretix.helpers.dicts import merge_dicts @@ -79,6 +80,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet): 'order', 'order__event', 'order__event__organizer', 'seat', ).prefetch_related( Prefetch('checkins', queryset=Checkin.objects.select_related('device')), + Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), 'answers', 'answers__options', 'answers__question', ) ), diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index a96c37344..64f2ffa3b 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -57,7 +57,8 @@ from pretix.api.serializers.order import ( OrderPaymentCreateSerializer, OrderPaymentSerializer, OrderPositionSerializer, OrderRefundCreateSerializer, OrderRefundSerializer, OrderSerializer, PriceCalcSerializer, - RevokedTicketSecretSerializer, SimulatedOrderSerializer, + PrintLogSerializer, RevokedTicketSecretSerializer, + SimulatedOrderSerializer, ) from pretix.api.serializers.orderchange import ( BlockNameSerializer, OrderChangeOperationSerializer, @@ -75,7 +76,7 @@ from pretix.base.models import ( TeamAPIToken, generate_secret, ) from pretix.base.models.orders import ( - BlockedTicketSecret, QuestionAnswer, RevokedTicketSecret, + BlockedTicketSecret, PrintLog, QuestionAnswer, RevokedTicketSecret, ) from pretix.base.payment import PaymentException from pretix.base.pdf import get_images @@ -214,7 +215,7 @@ class OrderViewSetMixin: queryset = Order.objects.none() filter_backends = (DjangoFilterBackend, TotalOrderingFilter) ordering = ('datetime',) - ordering_fields = ('datetime', 'code', 'status', 'last_modified') + ordering_fields = ('datetime', 'code', 'status', 'last_modified', 'cancellation_date') filterset_class = OrderFilter lookup_field = 'code' @@ -259,6 +260,7 @@ class OrderViewSetMixin: 'positions', opq.all().prefetch_related( Prefetch('checkins', queryset=Checkin.objects.select_related('device')), + Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), Prefetch('item', queryset=self.request.event.items.prefetch_related( Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached') )), @@ -280,6 +282,7 @@ class OrderViewSetMixin: 'positions', opq.all().prefetch_related( Prefetch('checkins', queryset=Checkin.objects.select_related('device')), + Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), 'item', 'variation', Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options', 'question').order_by('question__position')), 'seat', @@ -1093,6 +1096,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet): ) qs = qs.prefetch_related( Prefetch('checkins', queryset=Checkin.objects.select_related("device")), + Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), Prefetch('item', queryset=self.request.event.items.prefetch_related( Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached') @@ -1136,6 +1140,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet): else: qs = qs.prefetch_related( Prefetch('checkins', queryset=Checkin.objects.select_related("device")), + Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), 'answers', 'answers__options', 'answers__question', ).select_related( 'item', 'order', 'order__event', 'order__event__organizer', 'seat' @@ -1254,6 +1259,34 @@ class OrderPositionViewSet(viewsets.ModelViewSet): ) return resp + @action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"]) + def printlog(self, request, **kwargs): + pos = self.get_object() + serializer = PrintLogSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + serializer.save( + position=pos, + device=request.auth if isinstance(request.auth, Device) else None, + user=request.user if request.user.is_authenticated else None, + api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None, + oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None, + ) + + pos.order.log_action( + "pretix.event.order.print", + data={ + "position": pos.pk, + "positionid": pos.positionid, + **serializer.validated_data, + }, + auth=request.auth, + user=request.user, + ) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + @action(detail=True, url_name='pdf_image', url_path=r'pdf_image/(?P[^/]+)') def pdf_image(self, request, key, **kwargs): pos = self.get_object() diff --git a/src/pretix/base/auth.py b/src/pretix/base/auth.py index 297905a39..977815f14 100644 --- a/src/pretix/base/auth.py +++ b/src/pretix/base/auth.py @@ -152,7 +152,7 @@ class NativeAuthBackend(BaseAuthBackend): to log in. """ d = OrderedDict([ - ('email', forms.EmailField(label=_("E-mail"), max_length=254, + ('email', forms.EmailField(label=_("Email"), max_length=254, widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))), ('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput, max_length=4096)), diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index 137d3fb51..b17f5b266 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -35,6 +35,7 @@ from django.utils.translation import get_language, gettext_lazy as _ from pretix.base.models import Event from pretix.base.signals import register_html_mail_renderers from pretix.base.templatetags.rich_text import markdown_compile_email +from pretix.helpers.format import SafeFormatter, format_map from pretix.base.services.placeholders import ( # noqa get_available_placeholders, PlaceholderContext @@ -68,7 +69,7 @@ def test_custom_smtp_backend(backend: T, from_addr: str) -> None: class BaseHTMLMailRenderer: """ - This is the base class for all HTML e-mail renderers. + This is the base class for all HTML email renderers. """ def __init__(self, event: Event, organizer=None): @@ -79,7 +80,7 @@ class BaseHTMLMailRenderer: return self.identifier def render(self, plain_body: str, plain_signature: str, subject: str, order=None, - position=None) -> str: + position=None, context=None) -> str: """ This method should generate the HTML part of the email. @@ -88,6 +89,7 @@ class BaseHTMLMailRenderer: :param subject: The email subject. :param order: The order if this email is connected to one, otherwise ``None``. :param position: The order position if this email is connected to one, otherwise ``None``. + :param context: Context to use to render placeholders in the plain body :return: An HTML string """ raise NotImplementedError() @@ -134,8 +136,10 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer): def compile_markdown(self, plaintext): return markdown_compile_email(plaintext) - def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str: + def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str: body_md = self.compile_markdown(plain_body) + if context: + body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML) htmlctx = { 'site': settings.PRETIX_INSTANCE_NAME, 'site_url': settings.SITE_URL, diff --git a/src/pretix/base/exporters/customers.py b/src/pretix/base/exporters/customers.py index 56cfd805a..642f8f18f 100644 --- a/src/pretix/base/exporters/customers.py +++ b/src/pretix/base/exporters/customers.py @@ -64,7 +64,7 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter): _('Customer ID'), _('SSO provider'), _('External identifier'), - _('E-mail'), + _('Email'), _('Phone number'), _('Full name'), ] diff --git a/src/pretix/base/exporters/invoices.py b/src/pretix/base/exporters/invoices.py index 16b036d55..0cc9b6903 100644 --- a/src/pretix/base/exporters/invoices.py +++ b/src/pretix/base/exporters/invoices.py @@ -199,7 +199,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter): _('Invoice number'), _('Date'), _('Order code'), - _('E-mail address'), + _('Email address'), _('Invoice type'), _('Cancellation of'), _('Language'), @@ -326,7 +326,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter): _('Event start date'), _('Date'), _('Order code'), - _('E-mail address'), + _('Email address'), _('Invoice type'), _('Cancellation of'), _('Invoice sender:') + ' ' + _('Name'), diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index bf3c9e87c..887115772 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -284,7 +284,7 @@ class OrderListExporter(MultiSheetListExporter): headers.append(_('Comment')) headers.append(_('Follow-up date')) headers.append(_('Positions')) - headers.append(_('E-mail address verified')) + headers.append(_('Email address verified')) headers.append(_('External customer ID')) headers.append(_('Payment providers')) if form_data.get('include_payment_amounts'): @@ -655,7 +655,7 @@ class OrderListExporter(MultiSheetListExporter): headers += [ _('Sales channel'), _('Order locale'), - _('E-mail address verified'), + _('Email address verified'), _('External customer ID'), _('Check-in lists'), _('Payment providers'), diff --git a/src/pretix/base/forms/auth.py b/src/pretix/base/forms/auth.py index 1681e4d14..4fc801c4f 100644 --- a/src/pretix/base/forms/auth.py +++ b/src/pretix/base/forms/auth.py @@ -254,7 +254,7 @@ class PasswordRecoverForm(forms.Form): class PasswordForgotForm(forms.Form): email = forms.EmailField( - label=_('E-mail'), + label=_('Email'), ) def __init__(self, *args, **kwargs): diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index a2acc2f48..2cd593679 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -54,6 +54,7 @@ from django.core.validators import ( from django.db.models import QuerySet from django.forms import Select, widgets from django.forms.widgets import FILE_INPUT_CONTRADICTION +from django.urls import reverse from django.utils.formats import date_format from django.utils.html import escape from django.utils.safestring import mark_safe @@ -77,7 +78,7 @@ from pretix.base.i18n import ( get_babel_locale, get_language_without_region, language, ) from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption -from pretix.base.models.tax import VAT_ID_COUNTRIES, ask_for_vat_id +from pretix.base.models.tax import ask_for_vat_id from pretix.base.services.tax import ( VATIDFinalError, VATIDTemporaryError, validate_vat_id, ) @@ -276,6 +277,10 @@ class NamePartsFormField(forms.MultiValueField): return value +def name_parts_is_empty(name_parts_dict): + return not any(k != "_scheme" and v for k, v in name_parts_dict.items()) + + class WrappedPhonePrefixSelect(Select): initial = None @@ -602,6 +607,7 @@ class BaseQuestionsForm(forms.Form): questions = pos.item.questions_to_ask event = kwargs.pop('event') self.all_optional = kwargs.pop('all_optional', False) + self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional super().__init__(*args, **kwargs) @@ -676,7 +682,7 @@ class BaseQuestionsForm(forms.Form): if item.ask_attendee_data and event.settings.attendee_addresses_asked: add_fields['street'] = forms.CharField( - required=event.settings.attendee_addresses_required and not self.all_optional, + required=self.attendee_addresses_required, label=_('Address'), widget=forms.Textarea(attrs={ 'rows': 2, @@ -686,7 +692,7 @@ class BaseQuestionsForm(forms.Form): initial=(cartpos.street if cartpos else orderpos.street), ) add_fields['zipcode'] = forms.CharField( - required=event.settings.attendee_addresses_required and not self.all_optional, + required=False, max_length=30, label=_('ZIP code'), initial=(cartpos.zipcode if cartpos else orderpos.zipcode), @@ -695,7 +701,7 @@ class BaseQuestionsForm(forms.Form): }), ) add_fields['city'] = forms.CharField( - required=event.settings.attendee_addresses_required and not self.all_optional, + required=False, label=_('City'), max_length=255, initial=(cartpos.city if cartpos else orderpos.city), @@ -707,11 +713,12 @@ class BaseQuestionsForm(forms.Form): add_fields['country'] = CountryField( countries=CachedCountries ).formfield( - required=event.settings.attendee_addresses_required and not self.all_optional, + required=self.attendee_addresses_required, label=_('Country'), initial=country, widget=forms.Select(attrs={ 'autocomplete': 'country', + 'data-country-information-url': reverse('js_helpers.states'), }), ) c = [('', pgettext_lazy('address', 'Select state'))] @@ -946,9 +953,9 @@ class BaseQuestionsForm(forms.Form): d = super().clean() if self.address_validation: - self.cleaned_data = d = validate_address(d, True) + self.cleaned_data = d = validate_address(d, all_optional=not self.attendee_addresses_required) - if d.get('city') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS: + if d.get('street') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS: if not d.get('state'): self.add_error('state', _('This field is required.')) @@ -1005,7 +1012,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): 'street': forms.Textarea(attrs={ 'rows': 2, 'placeholder': _('Street and Number'), - 'autocomplete': 'street-address' + 'autocomplete': 'street-address', }), 'beneficiary': forms.Textarea(attrs={'rows': 3}), 'country': forms.Select(attrs={ @@ -1021,7 +1028,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): 'data-display-dependency': '#id_is_business_1', 'autocomplete': 'organization', }), - 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-with-vat-id': ','.join(VAT_ID_COUNTRIES)}), + 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}), 'internal_reference': forms.TextInput, } labels = { @@ -1055,6 +1062,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): ]) self.fields['country'].choices = CachedCountries() + self.fields['country'].widget.attrs['data-country-information-url'] = reverse('js_helpers.states') c = [('', pgettext_lazy('address', 'Select state'))] fprefix = self.prefix + '-' if self.prefix else '' @@ -1083,6 +1091,10 @@ class BaseInvoiceAddressForm(forms.ModelForm): ) self.fields['state'].widget.is_required = True + self.fields['street'].required = False + self.fields['zipcode'].required = False + self.fields['city'].required = False + # Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected. if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data: self.data = self.data.copy() @@ -1122,6 +1134,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): if event.settings.invoice_address_custom_field: self.fields['custom_field'].label = event.settings.invoice_address_custom_field + self.fields['custom_field'].help_text = event.settings.invoice_address_custom_field_helptext else: del self.fields['custom_field'] @@ -1134,16 +1147,19 @@ class BaseInvoiceAddressForm(forms.ModelForm): validate_address # local import to prevent impact on startup time data = self.cleaned_data + if not data.get('is_business'): data['company'] = '' data['vat_id'] = '' if data.get('is_business') and not ask_for_vat_id(data.get('country')): data['vat_id'] = '' - if self.event.settings.invoice_address_required: + if self.address_validation and self.event.settings.invoice_address_required and not self.all_optional: if data.get('is_business') and not data.get('company'): - raise ValidationError(_('You need to provide a company name.')) - if not data.get('is_business') and not data.get('name_parts'): + raise ValidationError({"company": _('You need to provide a company name.')}) + if not data.get('is_business') and name_parts_is_empty(data.get('name_parts', {})): raise ValidationError(_('You need to provide your name.')) + if not data.get('street') and not data.get('zipcode') and not data.get('city'): + raise ValidationError({"street": _('This field is required.')}) if 'vat_id' in self.changed_data or not data.get('vat_id'): self.instance.vat_id_validated = False @@ -1155,7 +1171,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): if all( not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts') - ) and len(data.get('name_parts', {})) == 1: + ) and name_parts_is_empty(data.get('name_parts', {})): # Do not save the country if it is the only field set -- we don't know the user even checked it! self.cleaned_data['country'] = '' diff --git a/src/pretix/base/forms/user.py b/src/pretix/base/forms/user.py index 9cecbe05e..e52bc51a4 100644 --- a/src/pretix/base/forms/user.py +++ b/src/pretix/base/forms/user.py @@ -48,10 +48,10 @@ from pretix.control.forms import SingleLanguageWidget class UserSettingsForm(forms.ModelForm): error_messages = { - 'duplicate_identifier': _("There already is an account associated with this e-mail address. " + 'duplicate_identifier': _("There already is an account associated with this email address. " "Please choose a different one."), - 'pw_current': _("Please enter your current password if you want to change your e-mail " - "address or password."), + 'pw_current': _("Please enter your current password if you want to change your email address " + "or password."), 'pw_current_wrong': _("The current password you entered was not correct."), 'pw_mismatch': _("Please enter the same password twice"), 'rate_limit': _("For security reasons, please wait 5 minutes before you try again."), diff --git a/src/pretix/base/invoice.py b/src/pretix/base/invoice.py index 30b1d3207..30de1433e 100644 --- a/src/pretix/base/invoice.py +++ b/src/pretix/base/invoice.py @@ -289,7 +289,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer): def _clean_text(self, text, tags=None): return self._normalize(bleach.clean( text, - tags=tags or [] + tags=set(tags) if tags else set() ).strip().replace('
', '
').replace('\n', '
\n')) @@ -461,7 +461,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer): def _draw_event(self, canvas): def shorten(txt): txt = str(txt) - txt = bleach.clean(txt, tags=[]).strip() + txt = bleach.clean(txt, tags=set()).strip() p = Paragraph(self._normalize(txt.strip().replace('\n', '
\n')), style=self.stylesheet['Normal']) p_size = p.wrap(self.event_width, self.event_height) diff --git a/src/pretix/base/management/commands/runperiodic.py b/src/pretix/base/management/commands/runperiodic.py index a6ff0b035..cd07aad83 100644 --- a/src/pretix/base/management/commands/runperiodic.py +++ b/src/pretix/base/management/commands/runperiodic.py @@ -36,6 +36,7 @@ import time import traceback from django.conf import settings +from django.core.cache import cache from django.core.management.base import BaseCommand from django.dispatch.dispatcher import NO_RECEIVERS @@ -57,6 +58,8 @@ class Command(BaseCommand): def handle(self, *args, **options): verbosity = int(options['verbosity']) + cache.set("pretix_runperiodic_executed", True, 3600 * 12) + if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS: return @@ -78,7 +81,7 @@ class Command(BaseCommand): try: r = receiver(signal=periodic_task, sender=self) except Exception as err: - if isinstance(Exception, KeyboardInterrupt): + if isinstance(err, KeyboardInterrupt): raise err if settings.SENTRY_ENABLED: from sentry_sdk import capture_exception diff --git a/src/pretix/base/media.py b/src/pretix/base/media.py index 6e3b635fb..0808e6869 100644 --- a/src/pretix/base/media.py +++ b/src/pretix/base/media.py @@ -37,6 +37,16 @@ class BaseMediaType: def verbose_name(self): raise NotImplementedError() + @property + def icon(self): + """ + This can be: + + - The name of a Font Awesome icon to represent this channel type. + - The name of a SVG icon file that is resolvable through the static file system. We recommend to design for a size of 18x14 pixels. + """ + return "circle" + def generate_identifier(self, organizer): if self.medium_created_by_server: raise NotImplementedError() @@ -59,6 +69,7 @@ class BaseMediaType: class BarcodePlainMediaType(BaseMediaType): identifier = 'barcode' verbose_name = _('Barcode / QR-Code') + icon = 'qrcode' medium_created_by_server = True supports_giftcard = False supports_orderposition = True @@ -75,6 +86,7 @@ class BarcodePlainMediaType(BaseMediaType): class NfcUidMediaType(BaseMediaType): identifier = 'nfc_uid' verbose_name = _('NFC UID-based') + icon = 'pretixbase/img/media/nfc_uid.svg' medium_created_by_server = False supports_giftcard = True supports_orderposition = False @@ -114,6 +126,7 @@ class NfcUidMediaType(BaseMediaType): class NfcMf0aesMediaType(BaseMediaType): identifier = 'nfc_mf0aes' verbose_name = 'NFC Mifare Ultralight AES' + icon = 'pretixbase/img/media/nfc_secure.svg' medium_created_by_server = False supports_giftcard = True supports_orderposition = False diff --git a/src/pretix/base/migrations/0001_initial.py b/src/pretix/base/migrations/0001_initial.py index 3ce22946c..b05198c08 100644 --- a/src/pretix/base/migrations/0001_initial.py +++ b/src/pretix/base/migrations/0001_initial.py @@ -29,7 +29,7 @@ class Migration(migrations.Migration): ('password', models.CharField(verbose_name='password', max_length=128)), ('last_login', models.DateTimeField(verbose_name='last login', blank=True, null=True)), ('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')), - ('email', models.EmailField(max_length=191, blank=True, unique=True, verbose_name='E-mail', null=True, + ('email', models.EmailField(max_length=191, blank=True, unique=True, verbose_name='Email', null=True, db_index=True)), ('givenname', models.CharField(verbose_name='Given name', max_length=255, blank=True, null=True)), ('familyname', models.CharField(verbose_name='Family name', max_length=255, blank=True, null=True)), diff --git a/src/pretix/base/migrations/0001_squashed_0028_auto_20160816_1242.py b/src/pretix/base/migrations/0001_squashed_0028_auto_20160816_1242.py index 181de4a92..89ea02ab4 100644 --- a/src/pretix/base/migrations/0001_squashed_0028_auto_20160816_1242.py +++ b/src/pretix/base/migrations/0001_squashed_0028_auto_20160816_1242.py @@ -9,6 +9,7 @@ from decimal import Decimal import django.core.validators import django.db.models.deletion import i18nfield.fields +from argon2.exceptions import HashingError from django.conf import settings from django.contrib.auth.hashers import make_password from django.db import migrations, models @@ -25,7 +26,14 @@ def initial_user(apps, schema_editor): user = User(email='admin@localhost') user.is_staff = True user.is_superuser = True - user.password = make_password('admin') + try: + user.password = make_password('admin') + except HashingError: + raise Exception( + "Could not hash password of initial user with argon2id. If this is a system with less than 8 CPU cores, " + "you might need to disable argon2id by setting `passwords_argon2=off` in the `[django]` section of the " + "pretix.cfg configuration file." + ) user.save() @@ -48,7 +56,7 @@ class Migration(migrations.Migration): ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), - ('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, unique=True, verbose_name='E-mail')), + ('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, unique=True, verbose_name='Email')), ('givenname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Given name')), ('familyname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Family name')), ('is_active', models.BooleanField(default=True, verbose_name='Is active')), @@ -232,7 +240,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('code', models.CharField(max_length=16, verbose_name='Order code')), ('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')), - ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail')), + ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')), ('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')), ('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)), ('datetime', models.DateTimeField(verbose_name='Date')), diff --git a/src/pretix/base/migrations/0002_auto_20160209_0940.py b/src/pretix/base/migrations/0002_auto_20160209_0940.py index fd885933b..99fd4c634 100644 --- a/src/pretix/base/migrations/0002_auto_20160209_0940.py +++ b/src/pretix/base/migrations/0002_auto_20160209_0940.py @@ -187,7 +187,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('code', models.CharField(max_length=16, verbose_name='Order code')), ('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')), - ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail')), + ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')), ('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')), ('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)), ('datetime', models.DateTimeField(verbose_name='Date')), diff --git a/src/pretix/base/migrations/0051_auto_20170206_2027.py b/src/pretix/base/migrations/0051_auto_20170206_2027.py index 9d1b69b3f..336a05978 100644 --- a/src/pretix/base/migrations/0051_auto_20170206_2027.py +++ b/src/pretix/base/migrations/0051_auto_20170206_2027.py @@ -20,7 +20,7 @@ class Migration(migrations.Migration): 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')), + ('email', models.EmailField(max_length=254, verbose_name='Email 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')), 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 index e92c3ce2d..d052ccae4 100644 --- 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 @@ -35,7 +35,7 @@ class Migration(migrations.Migration): 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')), + ('email', models.EmailField(max_length=254, verbose_name='Email 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')), diff --git a/src/pretix/base/migrations/0077_auto_20171124_1629_squashed_0088_auto_20180328_1217.py b/src/pretix/base/migrations/0077_auto_20171124_1629_squashed_0088_auto_20180328_1217.py index 83623e220..376df726d 100644 --- a/src/pretix/base/migrations/0077_auto_20171124_1629_squashed_0088_auto_20180328_1217.py +++ b/src/pretix/base/migrations/0077_auto_20171124_1629_squashed_0088_auto_20180328_1217.py @@ -163,7 +163,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('action_type', models.CharField(max_length=255)), - ('method', models.CharField(choices=[('mail', 'E-mail')], max_length=255)), + ('method', models.CharField(choices=[('mail', 'Email')], max_length=255)), ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), diff --git a/src/pretix/base/migrations/0078_auto_20171206_1603.py b/src/pretix/base/migrations/0078_auto_20171206_1603.py index 88f449494..76b5656fa 100644 --- a/src/pretix/base/migrations/0078_auto_20171206_1603.py +++ b/src/pretix/base/migrations/0078_auto_20171206_1603.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('action_type', models.CharField(max_length=255)), - ('method', models.CharField(choices=[('mail', 'E-mail')], max_length=255)), + ('method', models.CharField(choices=[('mail', 'Email')], max_length=255)), ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('enabled', models.BooleanField(default=True)), diff --git a/src/pretix/base/migrations/0271_itemcategory_cross_selling.py b/src/pretix/base/migrations/0271_itemcategory_cross_selling.py new file mode 100644 index 000000000..43c8c4416 --- /dev/null +++ b/src/pretix/base/migrations/0271_itemcategory_cross_selling.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.11 on 2024-05-27 13:19 + +from django.db import migrations, models + +import pretix.base.models.orders + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0270_historicpassword"), + ] + + operations = [ + migrations.AddField( + model_name="itemcategory", + name="cross_selling_condition", + field=models.CharField(null=True, max_length=10), + ), + migrations.AddField( + model_name="itemcategory", + name="cross_selling_mode", + field=models.CharField(null=True, max_length=5), + ), + migrations.AddField( + model_name="itemcategory", + name="cross_selling_match_products", + field=models.ManyToManyField( + related_name="matched_by_cross_selling_categories", to="pretixbase.item" + ), + ), + ] diff --git a/src/pretix/base/migrations/0272_printlog.py b/src/pretix/base/migrations/0272_printlog.py new file mode 100644 index 000000000..24064f7dc --- /dev/null +++ b/src/pretix/base/migrations/0272_printlog.py @@ -0,0 +1,79 @@ +# Generated by Django 4.2.16 on 2024-09-19 10:41 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ("pretixbase", "0271_itemcategory_cross_selling"), + ] + + operations = [ + migrations.CreateModel( + name="PrintLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("datetime", models.DateTimeField(default=django.utils.timezone.now)), + ("created", models.DateTimeField(auto_now_add=True, null=True)), + ("successful", models.BooleanField(default=True)), + ("source", models.CharField(max_length=255)), + ("type", models.CharField(max_length=255)), + ("info", models.JSONField(default=dict)), + ( + "api_token", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="pretixbase.teamapitoken", + ), + ), + ( + "device", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="print_logs", + to="pretixbase.device", + ), + ), + ( + "oauth_application", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, + ), + ), + ( + "position", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="print_logs", + to="pretixbase.orderposition", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="print_logs", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ("-datetime",), + }, + ), + ] diff --git a/src/pretix/base/migrations/0273_remove_checkinlist_auto_checkin_sales_channels.py b/src/pretix/base/migrations/0273_remove_checkinlist_auto_checkin_sales_channels.py new file mode 100644 index 000000000..9517a882b --- /dev/null +++ b/src/pretix/base/migrations/0273_remove_checkinlist_auto_checkin_sales_channels.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.16 on 2024-10-29 15:03 + +from django.db import migrations + + +def migrate_autocheckin(apps, schema_editor): + CheckinList = apps.get_model("pretixbase", "CheckinList") + AutoCheckinRule = apps.get_model("autocheckin", "AutoCheckinRule") + + for cl in CheckinList.objects.filter(auto_checkin_sales_channels__isnull=False).select_related("event", "event__organizer"): + sales_channels = cl.auto_checkin_sales_channels.all() + all_sales_channels = cl.event.organizer.sales_channels.all() + + if "pretix.plugins.autocheckin" not in cl.event.plugins: + cl.event.plugins = cl.event.plugins + ",pretix.plugins.autocheckin" + cl.event.save() + + r = AutoCheckinRule.objects.get_or_create( + list=cl, + event=cl.event, + all_products=True, + all_payment_methods=True, + defaults=dict( + mode="placed", + all_sales_channels=len(sales_channels) == len(all_sales_channels), + ) + )[0] + if len(sales_channels) != len(all_sales_channels): + r.limit_sales_channels.set(sales_channels) + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0272_printlog"), + ("autocheckin", "0001_initial"), + ] + + operations = [ + migrations.RunPython( + migrate_autocheckin, + migrations.RunPython.noop, + ), + migrations.RemoveField( + model_name="checkinlist", + name="auto_checkin_sales_channels", + ), + ] diff --git a/src/pretix/base/modelimport.py b/src/pretix/base/modelimport.py index d15396e95..acf25c373 100644 --- a/src/pretix/base/modelimport.py +++ b/src/pretix/base/modelimport.py @@ -256,6 +256,9 @@ class SubeventColumnMixin: ] def clean(self, value, previous_values): + if not value: + return None + if value in self._subevent_cache: return self._subevent_cache[value] diff --git a/src/pretix/base/modelimport_orders.py b/src/pretix/base/modelimport_orders.py index 6d9ef2e29..bce3a1c85 100644 --- a/src/pretix/base/modelimport_orders.py +++ b/src/pretix/base/modelimport_orders.py @@ -56,7 +56,7 @@ from pretix.base.signals import order_import_columns class EmailColumn(ImportColumn): identifier = 'email' - verbose_name = gettext_lazy('E-mail address') + verbose_name = gettext_lazy('Email address') def clean(self, value, previous_values): if value: @@ -322,7 +322,7 @@ class AttendeeNamePart(ImportColumn): class AttendeeEmail(ImportColumn): identifier = 'attendee_email' - verbose_name = gettext_lazy('Attendee e-mail address') + verbose_name = gettext_lazy('Attendee email address') def clean(self, value, previous_values): if value: diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index 3dbe4874f..297316e94 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -241,7 +241,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): REQUIRED_FIELDS = [] email = models.EmailField(unique=True, db_index=True, null=True, blank=True, - verbose_name=_('E-mail'), max_length=190) + verbose_name=_('Email'), max_length=190) fullname = models.CharField(max_length=255, blank=True, null=True, verbose_name=_('Full name')) is_active = models.BooleanField(default=True, diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 3f0f3bea7..9f42a9844 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -99,14 +99,6 @@ class CheckinList(LoggedModel): verbose_name=_('Automatically check out everyone at'), null=True, blank=True ) - auto_checkin_sales_channels = models.ManyToManyField( - "SalesChannel", - verbose_name=_('Sales channels to automatically check in'), - help_text=_('This option is deprecated and will be removed in the next months. As a replacement, our new plugin ' - '"Auto check-in" can be used. When we remove this option, we will automatically migrate your event ' - 'to use the new plugin.'), - blank=True, - ) rules = models.JSONField(default=dict, blank=True) objects = ScopedManager(organizer='event__organizer') @@ -141,7 +133,7 @@ class CheckinList(LoggedModel): return self.positions_query(ignore_status=False) @scopes_disabled() - def positions_inside_query(self, ignore_status=False, at_time=None): + def _filter_positions_inside(self, qs, at_time=None): if at_time is None: c_q = [] else: @@ -149,7 +141,7 @@ class CheckinList(LoggedModel): if "postgresql" not in settings.DATABASES["default"]["ENGINE"]: # Use a simple approach that works on all databases - qs = self.positions_query(ignore_status=ignore_status).annotate( + qs = qs.annotate( last_entry=Subquery( Checkin.objects.filter( *c_q, @@ -202,7 +194,7 @@ class CheckinList(LoggedModel): .values("position_id", "type", "datetime", "cnt_exists_after") .query.sql_with_params() ) - return self.positions_query(ignore_status=ignore_status).filter( + return qs.filter( pk__in=RawSQL( f""" SELECT "position_id" @@ -214,6 +206,10 @@ class CheckinList(LoggedModel): ) ) + @scopes_disabled() + def positions_inside_query(self, ignore_status=False, at_time=None): + return self._filter_positions_inside(self.positions_query(ignore_status=ignore_status), at_time=at_time) + @property def positions_inside(self): return self.positions_inside_query(None) diff --git a/src/pretix/base/models/customers.py b/src/pretix/base/models/customers.py index b93acdebb..b4b602344 100644 --- a/src/pretix/base/models/customers.py +++ b/src/pretix/base/models/customers.py @@ -91,7 +91,7 @@ class Customer(LoggedModel): ), ], ) - email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190) + email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('Email'), max_length=190) phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number')) password = models.CharField(verbose_name=_('Password'), max_length=128) name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True) @@ -392,7 +392,7 @@ class CustomerSSOClient(LoggedModel): SCOPE_CHOICES = ( ('openid', _('OpenID Connect access (required)')), ('profile', _('Profile data (name, addresses)')), - ('email', _('E-mail address')), + ('email', _('Email address')), ('phone', _('Phone number')), ) diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py index a45d9c1fc..71c614159 100644 --- a/src/pretix/base/models/devices.py +++ b/src/pretix/base/models/devices.py @@ -28,7 +28,6 @@ from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ from django_scopes import ScopedManager, scopes_disabled -from pretix.api.auth.devicesecurity import DEVICE_SECURITY_PROFILES from pretix.base.models import LoggedModel @@ -161,7 +160,6 @@ class Device(LoggedModel): ) security_profile = models.CharField( max_length=190, - choices=[(k, v.verbose_name) for k, v in DEVICE_SECURITY_PROFILES.items()], default='full', null=True, blank=False diff --git a/src/pretix/base/models/discount.py b/src/pretix/base/models/discount.py index d547de5c7..be2b3d0ac 100644 --- a/src/pretix/base/models/discount.py +++ b/src/pretix/base/models/discount.py @@ -20,11 +20,11 @@ # . # -from collections import defaultdict +from collections import defaultdict, namedtuple from decimal import Decimal from itertools import groupby -from math import ceil -from typing import Dict, Optional, Tuple +from math import ceil, inf +from typing import Dict from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator @@ -36,6 +36,8 @@ from django_scopes import ScopedManager from pretix.base.decimal import round_decimal from pretix.base.models.base import LoggedModel +PositionInfo = namedtuple('PositionInfo', ['item_id', 'subevent_id', 'line_price_gross', 'is_addon_to', 'voucher_discount']) + class Discount(LoggedModel): SUBEVENT_MODE_MIXED = 'mixed' @@ -245,22 +247,26 @@ class Discount(LoggedModel): return False return True - def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result): - if self.condition_min_value and sum(positions[idx][2] for idx in condition_idx_group) < self.condition_min_value: + def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id): + if self.condition_min_value and sum(positions[idx].line_price_gross for idx in condition_idx_group) < self.condition_min_value: return if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches: raise ValueError('Validation invariant violated.') for idx in benefit_idx_group: - previous_price = positions[idx][2] + previous_price = positions[idx].line_price_gross new_price = round_decimal( previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'), self.event.currency, ) result[idx] = new_price - def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result): + if collect_potential_discounts is not None: + for idx in condition_idx_group: + collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)] + + def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id): if len(condition_idx_group) < self.condition_min_count: return @@ -268,23 +274,53 @@ class Discount(LoggedModel): raise ValueError('Validation invariant violated.') if self.benefit_only_apply_to_cheapest_n_matches: - if not self.condition_min_count: - raise ValueError('Validation invariant violated.') - - condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price - benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price + # sort by line_price + condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx)) + benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx)) # Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only # want to match multiples of 3 - n_groups = min(len(condition_idx_group) // self.condition_min_count, ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches)) + + # how many discount applications are allowed according to condition products in cart + possible_applications_cond = len(condition_idx_group) // self.condition_min_count + + # how many discount applications are possible according to benefitting products in cart + possible_applications_benefit = ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches) + + n_groups = min(possible_applications_cond, possible_applications_benefit) consume_idx = condition_idx_group[:n_groups * self.condition_min_count] benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches] + + if collect_potential_discounts is not None: + if n_groups * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group): + # partially used discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket + # but only 1 t-shirt) -> 1 shirt definitiv potential discount + for idx in consume_idx: + collect_potential_discounts[idx] = [ + (self, n_groups * self.benefit_only_apply_to_cheapest_n_matches - len(benefit_idx_group), -1, subevent_id) + ] + + if possible_applications_cond * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group): + # unused discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket + # but 0 t-shirts) -> 2 shirt maybe potential discount (if the 1 ticket is not consumed by a later discount) + for i, idx in enumerate(condition_idx_group[ + n_groups * self.condition_min_count: + possible_applications_cond * self.condition_min_count + ]): + collect_potential_discounts[idx] += [ + (self, self.benefit_only_apply_to_cheapest_n_matches, i // self.condition_min_count, subevent_id) + ] + else: consume_idx = condition_idx_group benefit_idx = benefit_idx_group + if collect_potential_discounts is not None: + for idx in consume_idx: + collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)] + for idx in benefit_idx: - previous_price = positions[idx][2] + previous_price = positions[idx].line_price_gross new_price = round_decimal( previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'), self.event.currency, @@ -292,15 +328,16 @@ class Discount(LoggedModel): result[idx] = new_price for idx in consume_idx: - result.setdefault(idx, positions[idx][2]) + result.setdefault(idx, positions[idx].line_price_gross) - def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]]) -> Dict[int, Decimal]: + def apply(self, positions: Dict[int, PositionInfo], + collect_potential_discounts=None) -> Dict[int, Decimal]: """ Tries to apply this discount to a cart - :param positions: Dictionary mapping IDs to tuples of the form - ``(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)``. + :param positions: Dictionary mapping IDs to PositionInfo tuples. Bundled positions may not be included. + :param collect_potential_discounts: For detailed description, see pretix.base.services.pricing.apply_discounts :return: A dictionary mapping keys from the input dictionary to new prices. All positions contained in this dictionary are considered "consumed" and should not be considered @@ -342,13 +379,13 @@ class Discount(LoggedModel): if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events if self.condition_min_count: - self._apply_min_count(positions, condition_candidates, benefit_candidates, result) + self._apply_min_count(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts, None) else: - self._apply_min_value(positions, condition_candidates, benefit_candidates, result) + self._apply_min_value(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts, None) elif self.subevent_mode == self.SUBEVENT_MODE_SAME: def key(idx): - return positions[idx][1] or 0 # subevent_id + return positions[idx].subevent_id or 0 # Build groups of candidates with the same subevent, then apply our regular algorithm # to each group @@ -357,11 +394,11 @@ class Discount(LoggedModel): candidate_groups = [(k, list(g)) for k, g in _groups] for subevent_id, g in candidate_groups: - benefit_g = [idx for idx in benefit_candidates if positions[idx][1] == subevent_id] + benefit_g = [idx for idx in benefit_candidates if positions[idx].subevent_id == subevent_id] if self.condition_min_count: - self._apply_min_count(positions, g, benefit_g, result) + self._apply_min_count(positions, g, benefit_g, result, collect_potential_discounts, subevent_id) else: - self._apply_min_value(positions, g, benefit_g, result) + self._apply_min_value(positions, g, benefit_g, result, collect_potential_discounts, subevent_id) elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT: if self.condition_min_value or not self.benefit_same_products: @@ -377,9 +414,9 @@ class Discount(LoggedModel): # Build a list of subevent IDs in descending order of frequency subevent_to_idx = defaultdict(list) for idx, p in positions.items(): - subevent_to_idx[p[1]].append(idx) + subevent_to_idx[p.subevent_id].append(idx) for v in subevent_to_idx.values(): - v.sort(key=lambda idx: positions[idx][2]) + v.sort(key=lambda idx: positions[idx].line_price_gross) subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True) # Build groups of exactly condition_min_count distinct subevents @@ -394,7 +431,7 @@ class Discount(LoggedModel): l = [ll for ll in l if ll in condition_candidates and ll not in current_group] if cardinality and len(l) != cardinality: continue - if se not in {positions[idx][1] for idx in current_group}: + if se not in {positions[idx].subevent_id for idx in current_group}: candidates += l cardinality = len(l) @@ -403,7 +440,7 @@ class Discount(LoggedModel): # Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start # and 2 from the end" scheme to optimize price distribution among groups - candidates = sorted(candidates, key=lambda idx: positions[idx][2]) + candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross) if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0): candidate = candidates[0] else: @@ -415,14 +452,14 @@ class Discount(LoggedModel): if len(current_group) >= max(self.condition_min_count, 1): candidate_groups.append(current_group) for c in current_group: - subevent_to_idx[positions[c][1]].remove(c) + subevent_to_idx[positions[c].subevent_id].remove(c) current_group = [] # Distribute "leftovers" for se in subevent_order: if subevent_to_idx[se]: for group in candidate_groups: - if se not in {positions[idx][1] for idx in group}: + if se not in {positions[idx].subevent_id for idx in group}: group.append(subevent_to_idx[se].pop()) if not subevent_to_idx[se]: break @@ -432,6 +469,8 @@ class Discount(LoggedModel): positions, [idx for idx in g if idx in condition_candidates], [idx for idx in g if idx in benefit_candidates], - result + result, + None, + None ) return result diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 8a02cf5eb..3de4f68bc 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -823,6 +823,9 @@ class Event(EventMixin, LoggedModel): self.save() self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk}) + if hasattr(other, 'alternative_domain_assignment'): + other.alternative_domain_assignment.domain.event_assignments.create(event=self) + if not self.all_sales_channels: self.limit_sales_channels.set( self.organizer.sales_channels.filter( @@ -870,10 +873,12 @@ class Event(EventMixin, LoggedModel): for i in Item.objects.filter(event=other).prefetch_related( 'variations', 'limit_sales_channels', 'require_membership_types', 'variations__limit_sales_channels', 'variations__require_membership_types', + 'matched_by_cross_selling_categories', ): vars = list(i.variations.all()) require_membership_types = list(i.require_membership_types.all()) limit_sales_channels = list(i.limit_sales_channels.all()) + matched_by_cross_selling_categories = list(i.matched_by_cross_selling_categories.all()) item_map[i.pk] = i i.pk = None i.event = self @@ -911,6 +916,9 @@ class Event(EventMixin, LoggedModel): if not v.all_sales_channels: v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels])) + if matched_by_cross_selling_categories: + i.matched_by_cross_selling_categories.set([category_map[c.pk] for c in matched_by_cross_selling_categories]) + for i in self.items.filter(hidden_if_item_available__isnull=False): i.hidden_if_item_available = item_map[i.hidden_if_item_available_id] i.save() @@ -1019,10 +1027,9 @@ class Event(EventMixin, LoggedModel): checkin_list_map = {} for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related( - 'limit_products', 'auto_checkin_sales_channels' + 'limit_products' ): items = list(cl.limit_products.all()) - auto_checkin_sales_channels = list(cl.auto_checkin_sales_channels.all()) checkin_list_map[cl.pk] = cl cl.pk = None cl._prefetched_objects_cache = {} @@ -1034,8 +1041,6 @@ class Event(EventMixin, LoggedModel): cl.log_action('pretix.object.cloned') for i in items: cl.limit_products.add(item_map[i.pk]) - if auto_checkin_sales_channels: - cl.auto_checkin_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in auto_checkin_sales_channels])) if other.seating_plan: if other.seating_plan.organizer_id == self.organizer_id: diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 04af18500..47295b0d1 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -63,14 +63,13 @@ from django_countries.fields import Country from django_scopes import ScopedManager from i18nfield.fields import I18nCharField, I18nTextField +from pretix.base.media import MEDIA_TYPES +from pretix.base.models import Event, SubEvent from pretix.base.models.base import LoggedModel from pretix.base.models.fields import MultiStringField from pretix.base.models.tax import TaxedPrice from pretix.base.timemachine import time_machine_now - -from ...helpers.images import ImageSizeValidator -from ..media import MEDIA_TYPES -from .event import Event, SubEvent +from pretix.helpers.images import ImageSizeValidator class ItemCategory(LoggedModel): @@ -111,6 +110,33 @@ class ItemCategory(LoggedModel): 'only be bought in combination with a product that has this category configured as a possible ' 'source for add-ons.') ) + CROSS_SELLING_MODES = ( + (None, _('Normal category')), + ('both', _('Normal + cross-selling category')), + ('only', _('Cross-selling category')), + ) + cross_selling_mode = models.CharField( + choices=CROSS_SELLING_MODES, + null=True, + max_length=5 + ) + CROSS_SELLING_CONDITION = ( + ('always', _('Always show in cross-selling step')), + ('discounts', _('Only show products that qualify for a discount according to discount rules')), + ('products', _('Only show if the cart contains one of the following products')), + ) + cross_selling_condition = models.CharField( + verbose_name=_("Cross-selling condition"), + choices=CROSS_SELLING_CONDITION, + null=True, + max_length=10, + ) + cross_selling_match_products = models.ManyToManyField( + 'pretixbase.Item', + blank=True, + verbose_name=_("Cross-selling condition products"), + related_name="matched_by_cross_selling_categories", + ) class Meta: verbose_name = _("Product category") @@ -119,19 +145,31 @@ class ItemCategory(LoggedModel): def __str__(self): name = self.internal_name or self.name - if self.is_addon: - return _('{category} (Add-On products)').format(category=str(name)) + if self.category_type != 'normal': + return _('{category} ({category_type})').format(category=str(name), + category_type=self.get_category_type_display()) return str(name) def get_category_type_display(self): if self.is_addon: - return _('Add-On products') + return _('Add-on category') + elif self.cross_selling_mode: + return self.get_cross_selling_mode_display() else: - return None + return _('Normal category') @property def category_type(self): - return 'addon' if self.is_addon else 'normal' + return 'addon' if self.is_addon else self.cross_selling_mode or 'normal' + + @category_type.setter + def category_type(self, new_value): + if new_value == 'addon': + self.is_addon = True + self.cross_selling_mode = None + else: + self.is_addon = False + self.cross_selling_mode = None if new_value == 'normal' else new_value def delete(self, *args, **kwargs): super().delete(*args, **kwargs) @@ -270,7 +308,7 @@ class SubEventItemVariation(models.Model): return True -def filter_available(qs, channel='web', voucher=None, allow_addons=False): +def filter_available(qs, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False): # Channel can currently be a SalesChannel or a str, since we need that compatibility, but a SalesChannel # makes the query SIGNIFICANTLY faster from .organizer import SalesChannel @@ -291,6 +329,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False): if not allow_addons: q &= Q(Q(category__isnull=True) | Q(category__is_addon=False)) + if not allow_cross_sell: + q &= Q(Q(category__isnull=True) | ~Q(category__cross_selling_mode='only')) if voucher: if voucher.item_id: @@ -304,8 +344,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False): class ItemQuerySet(models.QuerySet): - def filter_available(self, channel='web', voucher=None, allow_addons=False): - return filter_available(self, channel, voucher, allow_addons) + def filter_available(self, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False): + return filter_available(self, channel, voucher, allow_addons, allow_cross_sell) class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__): @@ -313,8 +353,8 @@ class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__) super().__init__() self._queryset_class = ItemQuerySet - def filter_available(self, channel='web', voucher=None, allow_addons=False): - return filter_available(self.get_queryset(), channel, voucher, allow_addons) + def filter_available(self, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False): + return filter_available(self.get_queryset(), channel, voucher, allow_addons, allow_cross_sell) class Item(LoggedModel): @@ -1078,13 +1118,12 @@ class ItemVariation(models.Model): :param original_price: The item's "original" price. Will not be used for any calculations, will just be shown. :type original_price: decimal.Decimal :param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after - approval by an administrator + approval by an administrator :type require_approval: bool :param all_sales_channels: A flag indicating that this variation is available on all channels and limit_sales_channels will be ignored. :type all_sales_channels: bool :param limit_sales_channels: A list of sales channel identifiers, that this variation is available for sale on. :type limit_sales_channels: list - """ item = models.ForeignKey( Item, diff --git a/src/pretix/base/models/memberships.py b/src/pretix/base/models/memberships.py index 540cba623..14640b2cf 100644 --- a/src/pretix/base/models/memberships.py +++ b/src/pretix/base/models/memberships.py @@ -159,10 +159,24 @@ class Membership(models.Model): de = date_format(self.date_end, 'SHORT_DATE_FORMAT') return f'{self.membership_type.name}: {self.attendee_name} ({ds} – {de})' + @property + def percentage_used(self): + if self.membership_type.max_usages and self.usages: + return int(self.usages / self.membership_type.max_usages * 100) + return 0 + @property def attendee_name(self): return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme) + @property + def expired(self): + return time_machine_now() > self.date_end + + @property + def not_yet_valid(self): + return time_machine_now() < self.date_start + def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False): if valid_from_not_chosen: return not self.canceled and self.date_end >= time_machine_now() diff --git a/src/pretix/base/models/notifications.py b/src/pretix/base/models/notifications.py index c89cb5bbd..6b23c9260 100644 --- a/src/pretix/base/models/notifications.py +++ b/src/pretix/base/models/notifications.py @@ -43,7 +43,7 @@ class NotificationSetting(models.Model): :type enabled: bool """ CHANNELS = ( - ('mail', _('E-mail')), + ('mail', _('Email')), ) user = models.ForeignKey('User', on_delete=models.CASCADE, related_name='notification_settings') diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index ce9a6dbc9..80f506bf8 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -242,7 +242,7 @@ class Order(LockModel, LoggedModel): ) email = models.EmailField( null=True, blank=True, - verbose_name=_('E-mail') + verbose_name=_('Email') ) phone = PhoneNumberField( null=True, blank=True, @@ -317,7 +317,7 @@ class Order(LockModel, LoggedModel): ) email_known_to_work = models.BooleanField( default=False, - verbose_name=_('E-mail address verified') + verbose_name=_('Email address verified') ) invoice_dirty = models.BooleanField( # Invoice needs to be re-issued when the order is paid again @@ -2275,6 +2275,7 @@ class OrderFee(models.Model): FEE_TYPE_SERVICE = "service" FEE_TYPE_CANCELLATION = "cancellation" FEE_TYPE_INSURANCE = "insurance" + FEE_TYPE_LATE = "late" FEE_TYPE_OTHER = "other" FEE_TYPE_GIFTCARD = "giftcard" FEE_TYPES = ( @@ -2283,6 +2284,7 @@ class OrderFee(models.Model): (FEE_TYPE_SERVICE, _("Service fee")), (FEE_TYPE_CANCELLATION, _("Cancellation fee")), (FEE_TYPE_INSURANCE, _("Insurance fee")), + (FEE_TYPE_LATE, _("Late fee")), (FEE_TYPE_OTHER, _("Other fees")), (FEE_TYPE_GIFTCARD, _("Gift card")), ) @@ -3204,9 +3206,9 @@ class InvoiceAddress(models.Model): company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name')) name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True) name_parts = models.JSONField(default=dict) - street = models.TextField(verbose_name=_('Address'), blank=False) - zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False) - city = models.CharField(max_length=255, verbose_name=_('City'), blank=False) + street = models.TextField(verbose_name=_('Address'), blank=True) + zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True) + city = models.CharField(max_length=255, verbose_name=_('City'), blank=True) country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False) country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'), countries=CachedCountries) @@ -3391,6 +3393,74 @@ class BlockedTicketSecret(models.Model): unique_together = (('event', 'secret'),) +class PrintLog(models.Model): + """ + A print log object is created when a ticket or badge is printed with our apps. + """ + TYPE_BADGE = 'badge' + TYPE_TICKET = 'ticket' + TYPE_CERTIFICATE = 'certificate' + TYPE_OTHER = 'other' + PRINT_TYPES = ( + (TYPE_BADGE, _('Badge')), + (TYPE_TICKET, _('Ticket')), + (TYPE_CERTIFICATE, _('Certificate')), + (TYPE_OTHER, _('Other')), + ) + + position = models.ForeignKey( + 'pretixbase.OrderPosition', + related_name='print_logs', + on_delete=models.CASCADE, + ) + successful = models.BooleanField( + default=True, + ) + + # Datetime of checkin, might be different from created if past scans are uploaded + datetime = models.DateTimeField(default=now) + + # Datetime of creation on server + created = models.DateTimeField(auto_now_add=True, null=True, blank=True) + + # Who printed? + device = models.ForeignKey('Device', related_name='print_logs', null=True, blank=True, on_delete=models.PROTECT) + user = models.ForeignKey('User', related_name='print_logs', null=True, blank=True, on_delete=models.PROTECT) + api_token = models.ForeignKey('TeamAPIToken', null=True, blank=True, on_delete=models.PROTECT) + oauth_application = models.ForeignKey('pretixapi.OAuthApplication', null=True, blank=True, on_delete=models.PROTECT) + + # Source = Tag field with undefined values, e.g. name of app ("pretixscan") + source = models.CharField(max_length=255) + + # Type = Type of object printed ("badge", "ticket") + type = models.CharField(max_length=255, choices=PRINT_TYPES) + + info = models.JSONField(default=dict) + + objects = ScopedManager(organizer='position__order__event__organizer') + + class Meta: + ordering = (('-datetime'),) + + def __repr__(self): + return "".format( + self.position, self.datetime, self.source + ) + + def save(self, **kwargs): + super().save(**kwargs) + if self.position: + self.position.order.touch() + + def delete(self, **kwargs): + super().delete(**kwargs) + self.position.order.touch() + + @property + def is_late_upload(self): + return self.created and abs(self.created - self.datetime) > timedelta(minutes=2) + + @receiver(post_delete, sender=CachedTicket) def cachedticket_delete(sender, instance, **kwargs): if instance.file: diff --git a/src/pretix/base/models/seating.py b/src/pretix/base/models/seating.py index b96a03489..372131d66 100644 --- a/src/pretix/base/models/seating.py +++ b/src/pretix/base/models/seating.py @@ -53,6 +53,30 @@ class SeatingPlanLayoutValidator: e = str(e).replace('%', '%%') raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(e)) + try: + seat_guids = set() + for z in val["zones"]: + for r in z["rows"]: + for s in r["seats"]: + if not s.get("seat_guid"): + raise ValidationError( + _("Seat with zone {zone}, row {row}, and number {number} has no seat ID.").format( + zone=z["name"], + row=r["row_number"], + number=s["seat_number"], + ) + ) + elif s["seat_guid"] in seat_guids: + raise ValidationError( + _("Multiple seats have the same ID: {id}").format( + id=s["seat_guid"], + ) + ) + + seat_guids.add(s["seat_guid"]) + except ValidationError as e: + raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(", ".join(e.message for e in e.error_list))) + class SeatingPlan(LoggedModel): """ diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index d496e41cb..b6b9ef36a 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -304,10 +304,21 @@ class TaxRule(LoggedModel): subtract_from_gross = Decimal('0.00') rate = adjust_rate + def _limit_subtract(base_price, subtract_from_gross): + if not subtract_from_gross: + return base_price + if base_price >= Decimal('0.00'): + # For positive prices, make sure they don't go negative because of bundles + return max(Decimal('0.00'), base_price - subtract_from_gross) + else: + # If the price is already negative, we don't really care any more + return base_price - subtract_from_gross + if rate == Decimal('0.00'): + gross = _limit_subtract(base_price, subtract_from_gross) return TaxedPrice( - net=max(Decimal('0.00'), base_price - subtract_from_gross), - gross=max(Decimal('0.00'), base_price - subtract_from_gross), + net=gross, + gross=gross, tax=Decimal('0.00'), rate=rate, name=self.name, @@ -320,19 +331,14 @@ class TaxRule(LoggedModel): base_price_is = 'net' if base_price_is == 'gross': - if base_price >= Decimal('0.00'): - # For positive prices, make sure they don't go negative because of bundles - gross = max(Decimal('0.00'), base_price - subtract_from_gross) - else: - # If the price is already negative, we don't really care any more - gross = base_price - subtract_from_gross + gross = _limit_subtract(base_price, subtract_from_gross) net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))), currency) elif base_price_is == 'net': net = base_price gross = round_decimal((net * (1 + rate / 100)), currency) if subtract_from_gross: - gross -= subtract_from_gross + gross = _limit_subtract(gross, subtract_from_gross) net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))), currency) else: diff --git a/src/pretix/base/models/waitinglist.py b/src/pretix/base/models/waitinglist.py index e9bfd1126..df5d97992 100644 --- a/src/pretix/base/models/waitinglist.py +++ b/src/pretix/base/models/waitinglist.py @@ -73,7 +73,7 @@ class WaitingListEntry(LoggedModel): blank=True, default=dict ) email = models.EmailField( - verbose_name=_("E-mail address") + verbose_name=_("Email address") ) phone = PhoneNumberField( null=True, blank=True, diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 7e9c9611c..81fa9b9e7 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -1419,50 +1419,51 @@ class GiftCardPayment(BasePaymentProvider): def payment_refund_supported(self, payment: OrderPayment) -> bool: return True - def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]: - from pretix.base.services.cart import add_payment_to_cart + def _add_giftcard_to_cart(self, cs, gc): + from pretix.base.services.cart import add_payment_to_cart_session + if gc.currency != self.event.currency: + raise ValidationError(_("This gift card does not support this currency.")) + if gc.testmode and not self.event.testmode: + raise ValidationError(_("This gift card can only be used in test mode.")) + if not gc.testmode and self.event.testmode: + raise ValidationError(_("Only test gift cards can be used in test mode.")) + if gc.expires and gc.expires < time_machine_now(): + raise ValidationError(_("This gift card is no longer valid.")) + if gc.value <= Decimal("0.00"): + raise ValidationError(_("All credit on this gift card has been used.")) + + for p in cs.get('payments', []): + if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk: + raise ValidationError(_("This gift card is already used for your payment.")) + + add_payment_to_cart_session( + cs, + self, + max_value=gc.value, + info_data={ + 'gift_card': gc.pk, + 'gift_card_secret': gc.secret, + } + ) + + def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]: for p in get_cart(request): if p.item.issue_giftcard: messages.error(request, _("You cannot pay with gift cards when buying a gift card.")) return - cs = cart_session(request) try: gc = self.event.organizer.accepted_gift_cards.get( secret=request.POST.get("giftcard").strip() ) - if gc.currency != self.event.currency: - messages.error(request, _("This gift card does not support this currency.")) + cs = cart_session(request) + try: + self._add_giftcard_to_cart(cs, gc) + return True + except ValidationError as e: + messages.error(request, str(e.message)) return - if gc.testmode and not self.event.testmode: - messages.error(request, _("This gift card can only be used in test mode.")) - return - if not gc.testmode and self.event.testmode: - messages.error(request, _("Only test gift cards can be used in test mode.")) - return - if gc.expires and gc.expires < time_machine_now(): - messages.error(request, _("This gift card is no longer valid.")) - return - if gc.value <= Decimal("0.00"): - messages.error(request, _("All credit on this gift card has been used.")) - return - - for p in cs.get('payments', []): - if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk: - messages.error(request, _("This gift card is already used for your payment.")) - return - - add_payment_to_cart( - request, - self, - max_value=gc.value, - info_data={ - 'gift_card': gc.pk, - 'gift_card_secret': gc.secret, - } - ) - return True except GiftCard.DoesNotExist: if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists(): messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below " diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 5b6ebe325..8fe0f142b 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -343,11 +343,13 @@ class CartManager: err = error_messages['some_subevent_not_started'] cp.addons.all().delete() cp.delete() + continue if cp.subevent and cp.subevent.presale_end and time_machine_now(self.real_now_dt) > cp.subevent.presale_end: err = error_messages['some_subevent_ended'] cp.addons.all().delete() cp.delete() + continue if cp.subevent: tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) @@ -360,6 +362,7 @@ class CartManager: err = error_messages['some_subevent_ended'] cp.addons.all().delete() cp.delete() + continue return err def _update_subevents_cache(self, se_ids: List[int]): @@ -1423,6 +1426,28 @@ class CartManager: raise CartError(err) +def add_payment_to_cart_session(cart_session, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None): + """ + :param cart_session: The current cart session. + :param provider: The instance of your payment provider. + :param min_value: The minimum value this payment instrument supports, or ``None`` for unlimited. + :param max_value: The maximum value this payment instrument supports, or ``None`` for unlimited. Highly discouraged + to use for payment providers which charge a payment fee, as this can be very user-unfriendly if + users need a second payment method just for the payment fee of the first method. + :param info_data: A dictionary of information that will be passed through to the ``OrderPayment.info_data`` attribute. + :return: + """ + cart_session.setdefault('payments', []) + cart_session['payments'].append({ + 'id': str(uuid.uuid4()), + 'provider': provider.identifier, + 'multi_use_supported': provider.multi_use_supported, + 'min_value': str(min_value) if min_value is not None else None, + 'max_value': str(max_value) if max_value is not None else None, + 'info_data': info_data or {}, + }) + + def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None): """ :param request: The current HTTP request context. @@ -1437,16 +1462,7 @@ def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: D from pretix.presale.views.cart import cart_session cs = cart_session(request) - cs.setdefault('payments', []) - - cs['payments'].append({ - 'id': str(uuid.uuid4()), - 'provider': provider.identifier, - 'multi_use_supported': provider.multi_use_supported, - 'min_value': str(min_value) if min_value is not None else None, - 'max_value': str(max_value) if max_value is not None else None, - 'info_data': info_data or {}, - }) + add_payment_to_cart_session(cs, provider, min_value, max_value, info_data) def get_fees(event, request, total, invoice_address, payments, positions): @@ -1542,10 +1558,9 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: """ - Removes a list of items from a user's cart. :param event: The event ID in question :param voucher: A voucher code - :param session: Session ID of a guest + :param cart_id: The cart ID of the cart to modify """ with language(locale), time_machine_now_assigned(override_now_dt): try: @@ -1566,10 +1581,10 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: """ - Removes a list of items from a user's cart. + Removes an item specified by its position ID from a user's cart. :param event: The event ID in question :param position: A cart position ID - :param session: Session ID of a guest + :param cart_id: The cart ID of the cart to modify """ with language(locale), time_machine_now_assigned(override_now_dt): try: @@ -1590,9 +1605,9 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: """ - Removes a list of items from a user's cart. + Removes all items from a user's cart. :param event: The event ID in question - :param session: Session ID of a guest + :param cart_id: The cart ID of the cart to modify """ with language(locale), time_machine_now_assigned(override_now_dt): try: @@ -1611,13 +1626,15 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) -def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en', +def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: List[dict], cart_id: str=None, locale='en', invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None: """ - Removes a list of items from a user's cart. + Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to + ensure the requested addon state. :param event: The event ID in question :param addons: A list of dicts with the keys addon_to, item, variation - :param session: Session ID of a guest + :param add_to_cart_items: A list of dicts with the keys item, variation, count, custom_price, voucher, seat ID + :param cart_id: The cart ID of the cart to modify """ with language(locale), time_machine_now_assigned(override_now_dt): ia = False @@ -1635,6 +1652,7 @@ def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, l try: cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel) cm.set_addons(addons) + cm.add_new_items(add_to_cart_items) cm.commit() except LockTimeoutException: self.retry() diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 9b5731ec5..7c2e6237f 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -57,7 +57,7 @@ from pretix.base.models import ( Checkin, CheckinList, Device, Event, Gate, Item, ItemVariation, Order, OrderPosition, QuestionOption, ) -from pretix.base.signals import checkin_created, order_placed, periodic_task +from pretix.base.signals import checkin_created, periodic_task from pretix.helpers import OF_SELF from pretix.helpers.jsonlogic import Logic from pretix.helpers.jsonlogic_boolalg import convert_to_dnf @@ -1154,23 +1154,6 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, ) -@receiver(order_placed, dispatch_uid="legacy_autocheckin_order_placed") -def order_placed(sender, **kwargs): - order = kwargs['order'] - event = sender - - cls = list(event.checkin_lists.filter(auto_checkin_sales_channels=order.sales_channel).prefetch_related( - 'limit_products')) - if not cls: - return - for op in order.positions.all(): - for cl in cls: - if cl.all_products or op.item_id in {i.pk for i in cl.limit_products.all()}: - if not cl.subevent_id or cl.subevent_id == op.subevent_id: - ci = Checkin.objects.create(position=op, list=cl, auto_checked_in=True, type=Checkin.TYPE_ENTRY) - checkin_created.send(event, checkin=ci) - - @receiver(periodic_task, dispatch_uid="autocheckout_exit_all") @scopes_disabled() def process_exit_all(sender, **kwargs): diff --git a/src/pretix/base/services/cross_selling.py b/src/pretix/base/services/cross_selling.py new file mode 100644 index 000000000..e81abd665 --- /dev/null +++ b/src/pretix/base/services/cross_selling.py @@ -0,0 +1,234 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# + +from collections import defaultdict +from decimal import Decimal +from itertools import groupby +from math import inf +from typing import List + +from django.utils.functional import cached_property + +from pretix.base.models import CartPosition, ItemCategory, SalesChannel +from pretix.presale.views.event import get_grouped_items + + +class DummyCategory: + """ + Used to create fake category objects for displaying the same cross-selling category multiple times, + once for each subevent + """ + + def __init__(self, category: ItemCategory, subevent): + self.id = category.id + self.name = str(category.name) + self.subevent_name = str(subevent) + self.description = category.description + + +class CrossSellingService: + def __init__(self, event, sales_channel: SalesChannel, cartpositions: List[CartPosition], customer): + self.event = event + self.sales_channel = sales_channel + self.cartpositions = cartpositions + self.customer = customer + + def get_data(self): + if self.event.has_subevents: + subevents = set(pos.subevent for pos in self.cartpositions) + result = ( + (DummyCategory(category, subevent), + self._prepare_items(subevent, items_qs, discount_info), + f'subevent_{subevent.pk}_') + for subevent in subevents + for (category, items_qs, discount_info) in self._applicable_categories(subevent.pk) + ) + else: + result = ( + (category, + self._prepare_items(None, items_qs, discount_info), + '') + for (category, items_qs, discount_info) in self._applicable_categories(0) + ) + result = [(category, items, form_prefix) for (category, items, form_prefix) in result if len(items) > 0] + for category, items, form_prefix in result: + category.category_has_discount = any(item.original_price or ( + item.has_variations and any(var.original_price for var in item.available_variations) + ) for item in items) + return result + + def _applicable_categories(self, subevent_id): + return [ + (c, products_qs, discount_info) for (c, products_qs, discount_info) in + ( + (c, *self._get_visible_items_for_category(subevent_id, c)) + for c in self.event.categories.filter(cross_selling_mode__isnull=False).prefetch_related('items') + ) + if products_qs is not None + ] + + def _get_visible_items_for_category(self, filter_subevent_id, category: ItemCategory): + """ + If this category should be visible in the cross-selling step for a given cart and sales_channel, this method + returns a queryset of the items that should be displayed, as well as a dict giving additional information on them. + + :returns: (QuerySet, dict<(subevent_id, item_pk): (max_count, discount_rule)>) + max_count is `inf` if the item should not be limited + discount_rule is None if the item will not be discounted + """ + if category.cross_selling_mode is None: + return None, {} + if category.cross_selling_condition == 'always': + return category.items.all(), {} + if category.cross_selling_condition == 'products': + match = set(match.pk for match in category.cross_selling_match_products.only('pk')) # TODO prefetch this + return (category.items.all(), {}) if any(pos.item.pk in match for pos in self.cartpositions) else (None, {}) + if category.cross_selling_condition == 'discounts': + my_item_pks = [item.id for item in category.items.all()] + potential_discount_items = { + item.pk: (max_count, discount_rule) + for subevent_id, item, max_count, discount_rule in self._potential_discounts_by_subevent_and_item_for_current_cart + if max_count > 0 and item.pk in my_item_pks and item.is_available() and (subevent_id == filter_subevent_id or subevent_id is None) + } + return category.items.filter(pk__in=potential_discount_items), potential_discount_items + + @cached_property + def _potential_discounts_by_subevent_and_item_for_current_cart(self): + potential_discounts_by_cartpos = defaultdict(list) + + from ..services.pricing import apply_discounts + self._discounted_prices = apply_discounts( + self.event, + self.sales_channel, + [ + (cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, + cp.listed_price - cp.price_after_voucher) + for cp in self.cartpositions + ], + collect_potential_discounts=potential_discounts_by_cartpos + ) + + # flatten potential_discounts_by_cartpos (a dict of lists of potential discounts) into a set of potential discounts + # (which is technically stored as a dict, but we use it as an OrderedSet here) + potential_discount_set = dict.fromkeys( + info for lst in potential_discounts_by_cartpos.values() for info in lst) + + # sum up the max_counts and pass them on (also pass on the discount_rules so we can calculate actual discounted prices later): + # group by benefit product + # - max_count for product: sum up max_counts + # - discount_rule for product: take first discount_rule + + def discount_info(subevent_id, item, infos_for_item): + infos_for_item = list(infos_for_item) + return ( + subevent_id, + item, + sum(max_count for (subevent_id, item, discount_rule, max_count, i) in infos_for_item), + next(discount_rule for (subevent_id, item, discount_rule, max_count, i) in infos_for_item), + ) + + return [ + discount_info(subevent_id, item, infos_for_item) for (subevent_id, item), infos_for_item in + groupby( + sorted( + ( + (subevent_id, item, discount_rule, max_count, i) + for (discount_rule, max_count, i, subevent_id) in potential_discount_set.keys() + for item in discount_rule.benefit_limit_products.all() + ), + key=lambda tup: (tup[0], tup[1].pk) + ), + lambda tup: (tup[0], tup[1])) + ] + + def _prepare_items(self, subevent, items_qs, discount_info): + items, _btn = get_grouped_items( + self.event, + subevent=subevent, + voucher=None, + channel=self.sales_channel, + base_qs=items_qs, + allow_addons=False, + allow_cross_sell=True, + memberships=( + self.customer.usable_memberships( + for_event=subevent or self.event, + testmode=self.event.testmode + ) + if self.customer else None + ), + ) + new_items = list() + for item in items: + max_count = inf + if item.pk in discount_info: + (max_count, discount_rule) = discount_info[item.pk] + + # only benefit_only_apply_to_cheapest_n_matches discounted items have a max_count, all others get 'inf' + if not max_count: + max_count = inf + + # calculate discounted price + if discount_rule and discount_rule.benefit_discount_matching_percent > 0: + if not item.has_variations: + item.original_price = item.original_price or item.display_price + previous_price = item.display_price + new_price = ( + previous_price * ( + (Decimal('100.00') - discount_rule.benefit_discount_matching_percent) / Decimal('100.00')) + ) + item.display_price = new_price + else: + # discounts always match "whole" items, not specific variations -> we apply the discount to all + # available variations of the item + for var in item.available_variations: + var.original_price = var.original_price or var.display_price + previous_price = var.display_price + new_price = ( + previous_price * ( + (Decimal('100.00') - discount_rule.benefit_discount_matching_percent) / Decimal('100.00')) + ) + var.display_price = new_price + + if not item.has_variations: + # reduce order_max by number of items already in cart (prevent recommending a product the user can't add anyway) + item.order_max = min( + item.order_max - sum(1 for pos in self.cartpositions if pos.item_id == item.pk), + max_count + ) + if item.order_max > 0: + new_items.append(item) + else: + new_vars = list() + for var in item.available_variations: + # reduce order_max by number of items already in cart (prevent recommending a product the user can't add anyway) + var.order_max = min( + var.order_max - sum(1 for pos in self.cartpositions if pos.item_id == item.pk and pos.variation_id == var.pk), + max_count + ) + if var.order_max > 0: + new_vars.append(var) + if len(new_vars): + item.available_variations = new_vars + new_items.append(item) + + return new_items diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index 9d30d927a..fea30a174 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -76,7 +76,7 @@ from pretix.base.services.tasks import TransactionAwareTask from pretix.base.services.tickets import get_tickets_for_order from pretix.base.signals import email_filter, global_email_filter from pretix.celery_app import app -from pretix.helpers.format import format_map +from pretix.helpers.format import SafeFormatter, format_map from pretix.helpers.hierarkey import clean_filename from pretix.multidomain.urlreverse import build_absolute_uri from pretix.presale.ical import get_private_icals @@ -311,11 +311,17 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La try: if plain_text_only: body_html = None + elif 'context' in inspect.signature(renderer.render).parameters: + body_html = renderer.render(content_plain, signature, raw_subject, order, position, context) elif 'position' in inspect.signature(renderer.render).parameters: + # Backwards compatibility + warnings.warn('Email renderer called without context argument because context argument is not ' + 'supported.', + DeprecationWarning) body_html = renderer.render(content_plain, signature, raw_subject, order, position) else: # Backwards compatibility - warnings.warn('E-mail renderer called without position argument because position argument is not ' + warnings.warn('Email renderer called without position argument because position argument is not ' 'supported.', DeprecationWarning) body_html = renderer.render(content_plain, signature, raw_subject, order) @@ -323,6 +329,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La logger.exception('Could not render HTML body') body_html = None + body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN) + send_task = mail_send_task.si( to=[email] if isinstance(email, str) else list(email), cc=cc, @@ -655,7 +663,7 @@ def render_mail(template, context): if isinstance(template, LazyI18nString): body = str(template) if context: - body = format_map(body, context) + body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH) else: tpl = get_template(template) body = tpl.render(context) diff --git a/src/pretix/base/services/placeholders.py b/src/pretix/base/services/placeholders.py index 65d4530b7..53fc2918f 100644 --- a/src/pretix/base/services/placeholders.py +++ b/src/pretix/base/services/placeholders.py @@ -26,6 +26,7 @@ from decimal import Decimal from django.dispatch import receiver from django.utils.formats import date_format +from django.utils.html import escape from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ @@ -39,7 +40,8 @@ from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized from pretix.base.signals import ( register_mail_placeholders, register_text_placeholders, ) -from pretix.helpers.format import SafeFormatter +from pretix.base.templatetags.rich_text import markdown_compile_email +from pretix.helpers.format import PlainHtmlAlternativeString, SafeFormatter logger = logging.getLogger('pretix.base.services.placeholders') @@ -107,6 +109,91 @@ class SimpleFunctionalTextPlaceholder(BaseTextPlaceholder): return self._sample +class BaseRichTextPlaceholder(BaseTextPlaceholder): + """ + This is the base class for all placeholders which can render either to plain text + or to a rich HTML element. + """ + + def __init__(self, identifier, args): + self._identifier = identifier + self._args = args + + @property + def identifier(self): + return self._identifier + + @property + def required_context(self): + return self._args + + @property + def is_block(self): + return False + + def render(self, context): + return PlainHtmlAlternativeString( + self.render_plain(**{k: context[k] for k in self._args}), + self.render_html(**{k: context[k] for k in self._args}), + self.is_block, + ) + + def render_html(self, **kwargs): + """ + HTML rendering of the placeholder. Should return "safe" HTML, i.e. everything needs to be + escaped. + """ + raise NotImplementedError + + def render_plain(self, **kwargs): + """ + Plain text rendering of the placeholder. + """ + raise NotImplementedError + + def render_sample(self, event): + return PlainHtmlAlternativeString( + self.render_sample_plain(event=event), + self.render_sample_html(event=event), + self.is_block, + ) + + def render_sample_html(self, event): + raise NotImplementedError + + def render_sample_plain(self, event): + raise NotImplementedError + + +class SimpleButtonPlaceholder(BaseRichTextPlaceholder): + def __init__(self, identifier, args, url_func, text_func, sample_url_func, sample_text_func): + super().__init__(identifier, args) + self._url_func = url_func + self._text_func = text_func + self._sample_url_func = sample_url_func + self._sample_text_func = sample_text_func + + def render_html(self, **context): + text = self._text_func(**{k: context[k] for k in self._args}) + url = self._url_func(**{k: context[k] for k in self._args}) + return f'{escape(text)}' + + def render_plain(self, **context): + text = self._text_func(**{k: context[k] for k in self._args}) + url = self._url_func(**{k: context[k] for k in self._args}) + return f'{text}: {url}' + + def render_sample_html(self, event): + text = self._sample_text_func(event) + url = self._sample_url_func(event) + return f'{escape(text)}' + + def render_sample_plain(self, event): + text = self._sample_text_func(event) + url = self._sample_url_func(event) + return f'{text}: {url}' + + class PlaceholderContext(SafeFormatter): """ Holds the contextual arguments and corresponding list of available placeholders for formatting @@ -209,13 +296,24 @@ def get_best_name(position_or_address, parts=False): def base_placeholders(sender, **kwargs): from pretix.multidomain.urlreverse import build_absolute_uri + def _event_sample(event): + if event.has_subevents: + se = event.subevents.first() + if se: + return se.name + return event.name + ph = [ SimpleFunctionalTextPlaceholder( 'event', ['event'], lambda event: event.name, lambda event: event.name ), SimpleFunctionalTextPlaceholder( 'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name, - lambda event_or_subevent: event_or_subevent.name + _event_sample, + ), + SimpleFunctionalTextPlaceholder( + 'event_series_name', ['event', 'event_or_subevent'], lambda event, event_or_subevent: event.name, + lambda event: event.name ), SimpleFunctionalTextPlaceholder( 'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug @@ -273,6 +371,27 @@ def base_placeholders(sender, **kwargs): } ), ), + SimpleButtonPlaceholder( + 'url_button', ['order', 'event'], + url_func=lambda order, event: build_absolute_uri( + event, + 'presale:event.order.open', kwargs={ + 'order': order.code, + 'secret': order.secret, + 'hash': order.email_confirm_secret() + } + ), + text_func=lambda order, event: _("View order details"), + sample_url_func=lambda event: build_absolute_uri( + event, + 'presale:event.order.open', kwargs={ + 'order': 'F8VVL', + 'secret': '6zzjnumtsx136ddy', + 'hash': '98kusd8ofsj8dnkd' + } + ), + sample_text_func=lambda event: _("View order details"), + ), SimpleFunctionalTextPlaceholder( 'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri( event, @@ -337,6 +456,27 @@ def base_placeholders(sender, **kwargs): } ), ), + SimpleButtonPlaceholder( + 'url_button', ['event', 'position'], + url_func=lambda event, position: build_absolute_uri( + event, + 'presale:event.order.position', kwargs={ + 'order': position.order.code, + 'secret': position.web_secret, + 'position': position.positionid + } + ), + text_func=lambda event, position: _("View registration details"), + sample_url_func=lambda event: build_absolute_uri( + event, + 'presale:event.order.position', kwargs={ + 'order': 'F8VVL', + 'secret': '6zzjnumtsx136ddy', + 'position': '123' + } + ), + sample_text_func=lambda event: _("View registration details"), + ), SimpleFunctionalTextPlaceholder( 'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri( event, @@ -592,8 +732,8 @@ def base_placeholders(sender, **kwargs): class FormPlaceholderMixin: - def _set_field_placeholders(self, fn, base_parameters): - placeholders = get_available_placeholders(self.event, base_parameters) + def _set_field_placeholders(self, fn, base_parameters, rich=False): + placeholders = get_available_placeholders(self.event, base_parameters, rich=rich) ht = format_placeholders_help_text(placeholders, self.event) if self.fields[fn].help_text: self.fields[fn].help_text += ' ' + str(ht) @@ -604,7 +744,7 @@ class FormPlaceholderMixin: ) -def get_available_placeholders(event, base_parameters): +def get_available_placeholders(event, base_parameters, rich=False): if 'order' in base_parameters: base_parameters.append('invoice_address') base_parameters.append('position_or_address') @@ -613,6 +753,35 @@ def get_available_placeholders(event, base_parameters): if not isinstance(val, (list, tuple)): val = [val] for v in val: + if isinstance(v, BaseRichTextPlaceholder) and not rich: + continue if all(rp in base_parameters for rp in v.required_context): params[v.identifier] = v return params + + +def get_sample_context(event, context_parameters, rich=True): + context_dict = {} + lbl = _('This value will be replaced based on dynamic parameters.') + for k, v in get_available_placeholders(event, context_parameters, rich=rich).items(): + sample = v.render_sample(event) + if isinstance(sample, PlainHtmlAlternativeString): + context_dict[k] = PlainHtmlAlternativeString( + sample.plain, + '<{el} class="placeholder placeholder-html" title="{title}">{html}'.format( + el='div' if sample.is_block else 'span', + title=lbl, + html=sample.html, + ) + ) + elif str(sample).strip().startswith('* ') or str(sample).startswith(' '): + context_dict[k] = '
{}
'.format( + lbl, + markdown_compile_email(str(sample)) + ) + else: + context_dict[k] = '{}'.format( + lbl, + escape(sample) + ) + return context_dict diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index acfdc44b0..8fbd024af 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -20,8 +20,9 @@ # . # import re +from collections import defaultdict from decimal import Decimal -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Union from django import forms from django.db.models import Q @@ -31,6 +32,7 @@ from pretix.base.models import ( AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, SalesChannel, Voucher, ) +from pretix.base.models.discount import Discount, PositionInfo from pretix.base.models.event import Event, SubEvent from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule from pretix.base.timemachine import time_machine_now @@ -155,14 +157,22 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu return price -def apply_discounts(event: Event, sales_channel: str, - positions: List[Tuple[int, Optional[int], Decimal, bool, bool]]) -> List[Decimal]: +def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel], + positions: List[Tuple[int, Optional[int], Decimal, bool, bool, Decimal]], + collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]: """ Applies any dynamic discounts to a cart :param event: Event the cart belongs to :param sales_channel: Sales channel the cart was created with :param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)`` + :param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart + based on the "consumed" items, but lack matching "benefitting" items will be collected therein. + The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list + of tuples describing the discounts that could be applied in the form `(discount, max_count, grouping_id)`. + `max_count` is either the maximum number of benefitting items that the discount would apply to, or `inf` if that number + is not limited. The `grouping_id` can be used to distinguish several occurrences of the same discount. + :return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input """ if isinstance(sales_channel, SalesChannel): @@ -177,10 +187,10 @@ def apply_discounts(event: Event, sales_channel: str, ).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk') for discount in discount_qs: result = discount.apply({ - idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) + idx: PositionInfo(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions) if not is_bundled and idx not in new_prices - }) + }, collect_potential_discounts) for k in result.keys(): result[k] = (result[k], discount) new_prices.update(result) diff --git a/src/pretix/base/services/vouchers.py b/src/pretix/base/services/vouchers.py index bbbab6108..d0cbb9d68 100644 --- a/src/pretix/base/services/vouchers.py +++ b/src/pretix/base/services/vouchers.py @@ -53,7 +53,7 @@ def vouchers_send(event: Event, vouchers: list, subject: str, message: str, reci v.tag = r.get('tag') if v.comment: v.comment += '\n\n' - v.comment = gettext('The voucher has been sent to {recipient}.').format(recipient=r['email']) + v.comment += gettext('The voucher has been sent to {recipient}.').format(recipient=r['email']) logs.append(v.log_action( 'pretix.voucher.sent', user=user, diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 48b5cfa47..7a4d29eb0 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -56,6 +56,7 @@ from django.utils.translation import ( from django_countries.fields import Country from hierarkey.models import GlobalSettingsBase, Hierarkey from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput +from i18nfield.rest_framework import I18nField from i18nfield.strings import LazyI18nString from phonenumbers import PhoneNumber, parse from rest_framework import serializers @@ -63,7 +64,7 @@ from rest_framework import serializers from pretix.api.serializers.fields import ( ListMultipleChoiceField, UploadedFileField, ) -from pretix.api.serializers.i18n import I18nField, I18nURLField +from pretix.api.serializers.i18n import I18nURLField from pretix.base.forms import I18nMarkdownTextarea, I18nURLFormField from pretix.base.models.tax import VAT_ID_COUNTRIES, TaxRule from pretix.base.reldate import ( @@ -550,7 +551,7 @@ DEFAULTS = { 'serializer_class': serializers.BooleanField, 'type': bool, 'form_kwargs': dict( - label=_("Require a business addresses"), + label=_("Require a business address"), help_text=_('This will require users to enter a company name.'), widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_required'}), ) @@ -571,7 +572,7 @@ DEFAULTS = { 'form_class': I18nFormField, 'serializer_class': I18nField, 'form_kwargs': dict( - label=_("Custom recipient field"), + label=_("Custom recipient field label"), widget=I18nTextInput, help_text=_("If you want to add a custom text field, e.g. for a country-specific registration number, to " "your invoice address form, please fill in the label here. This label will both be used for " @@ -580,6 +581,18 @@ DEFAULTS = { "The field will not be required.") ) }, + 'invoice_address_custom_field_helptext': { + 'default': '', + 'type': LazyI18nString, + 'form_class': I18nFormField, + 'serializer_class': I18nField, + 'form_kwargs': dict( + label=_("Custom recipient field help text"), + widget=I18nTextInput, + help_text=_("If you use the custom recipient field, you can specify a help text which will be displayed " + "underneath the field. It will not be displayed on the invoice.") + ) + }, 'invoice_address_vatid': { 'default': 'False', 'type': bool, diff --git a/src/pretix/base/shredder.py b/src/pretix/base/shredder.py index c1c873a89..dd24b9f7f 100644 --- a/src/pretix/base/shredder.py +++ b/src/pretix/base/shredder.py @@ -287,9 +287,9 @@ class PhoneNumberShredder(BaseDataShredder): class EmailAddressShredder(BaseDataShredder): - verbose_name = _('E-mails') + verbose_name = _('Emails') identifier = 'order_emails' - description = _('This will remove all e-mail addresses from orders and attendees, as well as logged email ' + description = _('This will remove all email addresses from orders and attendees, as well as logged email ' 'contents. This will also remove the association to customer accounts.') def generate_files(self) -> List[Tuple[str, str, str]]: diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index ff207cf20..a841fb702 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -415,7 +415,7 @@ validate_cart_addons = EventPluginSignal() Arguments: ``addons``, ``base_position``, ``iao`` This signal is sent when a user tries to select a combination of addons. In contrast to - ``validate_cart``, this is executed before the cart is actually modified. You are passed +``validate_cart``, this is executed before the cart is actually modified. You are passed an argument ``addons`` containing a dict of ``(item, variation or None) → count`` tuples as well as the ``ItemAddOn`` object as the argument ``iao`` and the base cart position as ``base_position``. diff --git a/src/pretix/base/templates/pretixbase/email/base.html b/src/pretix/base/templates/pretixbase/email/base.html index a6f4066b6..ef8809ac1 100644 --- a/src/pretix/base/templates/pretixbase/email/base.html +++ b/src/pretix/base/templates/pretixbase/email/base.html @@ -131,6 +131,9 @@ text-align: left; padding: 0; } + .content table td.align-right { + text-align: right; + } a.button { display: inline-block; @@ -178,6 +181,9 @@ pre, pre code { white-space: pre-line; } + .text-right, .content table td.text-right { + text-align: right; + } {% if rtl %} body { @@ -186,6 +192,9 @@ .content { text-align: right; } + .text-right, .content table td.text-right { + text-align: left; + } {% endif %} {% block addcss %}{% endblock %} diff --git a/src/pretix/base/templatetags/icon.py b/src/pretix/base/templatetags/icon.py new file mode 100644 index 000000000..d71319b01 --- /dev/null +++ b/src/pretix/base/templatetags/icon.py @@ -0,0 +1,34 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from django import template +from django.utils.html import format_html + +register = template.Library() + + +@register.simple_tag +def icon(key, *args, **kwargs): + return format_html( + '', + key, + kwargs["class"] if "class" in kwargs else "", + ) diff --git a/src/pretix/base/templatetags/money.py b/src/pretix/base/templatetags/money.py index fe9ff7d4b..ad4eff1cc 100644 --- a/src/pretix/base/templatetags/money.py +++ b/src/pretix/base/templatetags/money.py @@ -52,12 +52,12 @@ def money_filter(value: Decimal, arg='', hide_currency=False): # would make the numbers incorrect. If this branch executes, it's likely a bug in # pretix, but we won't show wrong numbers! if hide_currency: - return floatformat(value, 2) + return floatformat(value, "2g") else: - return '{} {}'.format(arg, floatformat(value, 2)) + return '{} {}'.format(arg, floatformat(value, "2g")) if hide_currency: - return floatformat(value, places) + return floatformat(value, f"{places}g") locale_parts = translation.get_language().split("-", 1) locale = locale_parts[0] @@ -70,7 +70,7 @@ def money_filter(value: Decimal, arg='', hide_currency=False): try: return format_currency(value, arg, locale=locale) except: - return '{} {}'.format(arg, floatformat(value, places)) + return '{} {}'.format(arg, floatformat(value, f"{places}g")) @register.filter("money_numberfield") diff --git a/src/pretix/base/templatetags/rich_text.py b/src/pretix/base/templatetags/rich_text.py index d2324ef07..d77d9ce9c 100644 --- a/src/pretix/base/templatetags/rich_text.py +++ b/src/pretix/base/templatetags/rich_text.py @@ -54,7 +54,7 @@ from tlds import tld_set register = template.Library() -ALLOWED_TAGS_SNIPPET = [ +ALLOWED_TAGS_SNIPPET = { 'a', 'abbr', 'acronym', @@ -68,8 +68,8 @@ ALLOWED_TAGS_SNIPPET = [ 'strike', 's', # Update doc/user/markdown.rst if you change this! -] -ALLOWED_TAGS = ALLOWED_TAGS_SNIPPET + [ +} +ALLOWED_TAGS = ALLOWED_TAGS_SNIPPET | { 'blockquote', 'li', 'ol', @@ -91,7 +91,7 @@ ALLOWED_TAGS = ALLOWED_TAGS_SNIPPET + [ 'h6', 'pre', # Update doc/user/markdown.rst if you change this! -] +} ALLOWED_ATTRIBUTES = { 'a': ['href', 'title', 'class'], @@ -106,7 +106,7 @@ ALLOWED_ATTRIBUTES = { # Update doc/user/markdown.rst if you change this! } -ALLOWED_PROTOCOLS = ['http', 'https', 'mailto', 'tel'] +ALLOWED_PROTOCOLS = {'http', 'https', 'mailto', 'tel'} URL_RE = SimpleLazyObject(lambda: build_url_re(tlds=sorted(tld_set, key=len, reverse=True))) @@ -211,9 +211,9 @@ class CleanPostprocessor(Postprocessor): def run(self, text): return bleach.clean( text, - tags=self.tags, + tags=set(self.tags), attributes=self.attributes, - protocols=self.protocols, + protocols=set(self.protocols), strip=self.strip ) @@ -305,10 +305,11 @@ def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes source, extensions=[ 'markdown.extensions.sane_lists', + 'markdown.extensions.tables', EmailNl2BrExtension(), LinkifyAndCleanExtension( linker, - tags=allowed_tags, + tags=set(allowed_tags), attributes=allowed_attributes, protocols=ALLOWED_PROTOCOLS, strip=False, diff --git a/src/pretix/base/templatetags/textbubble.py b/src/pretix/base/templatetags/textbubble.py new file mode 100644 index 000000000..a0c3a3d30 --- /dev/null +++ b/src/pretix/base/templatetags/textbubble.py @@ -0,0 +1,42 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from django import template +from django.utils.html import format_html, mark_safe + +register = template.Library() + + +@register.simple_tag +def textbubble(type, *args, **kwargs): + return format_html( + '{}', + type or "info", + "" if "icon" not in kwargs else format_html( + ' ', + kwargs["icon"] + ) + ) + + +@register.simple_tag +def endtextbubble(): + return mark_safe('') diff --git a/src/pretix/base/views/js_helpers.py b/src/pretix/base/views/js_helpers.py index 458f55d44..be180e9cd 100644 --- a/src/pretix/base/views/js_helpers.py +++ b/src/pretix/base/views/js_helpers.py @@ -22,16 +22,30 @@ import pycountry from django.http import JsonResponse +from pretix.base.addressvalidation import ( + COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED, +) +from pretix.base.models.tax import VAT_ID_COUNTRIES from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS def states(request): cc = request.GET.get("country", "DE") + info = { + 'street': {'required': True}, + 'zipcode': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED}, + 'city': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED}, + 'state': {'visible': cc in COUNTRIES_WITH_STATE_IN_ADDRESS, 'required': cc in COUNTRIES_WITH_STATE_IN_ADDRESS}, + 'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False}, + } if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS: - return JsonResponse({'data': []}) + return JsonResponse({'data': [], **info, }) types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc] statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types] - return JsonResponse({'data': [ - {'name': s.name, 'code': s.code[3:]} - for s in sorted(statelist, key=lambda s: s.name) - ]}) + return JsonResponse({ + 'data': [ + {'name': s.name, 'code': s.code[3:]} + for s in sorted(statelist, key=lambda s: s.name) + ], + **info, + }) diff --git a/src/pretix/control/context.py b/src/pretix/control/context.py index 13eb1d205..3cdfc1b0b 100644 --- a/src/pretix/control/context.py +++ b/src/pretix/control/context.py @@ -36,6 +36,7 @@ import sys from importlib import import_module from django.conf import settings +from django.core.cache import cache from django.db.models import Q from django.urls import Resolver404, get_script_prefix, resolve from django.utils.translation import get_language @@ -152,6 +153,8 @@ def _default_context(request): ctx['warning_update_available'] = True if not gs.settings.update_check_ack and 'runserver' not in sys.argv: ctx['warning_update_check_active'] = True + if not cache.get('pretix_runperiodic_executed') and not settings.DEBUG: + ctx['warning_cronjob'] = True ctx['ie_deprecation_warning'] = 'MSIE' in request.headers.get('User-Agent', '') or 'Trident/' in request.headers.get('User-Agent', '') diff --git a/src/pretix/control/forms/checkin.py b/src/pretix/control/forms/checkin.py index 52a52217f..207a3d78c 100644 --- a/src/pretix/control/forms/checkin.py +++ b/src/pretix/control/forms/checkin.py @@ -33,9 +33,7 @@ from django_scopes.forms import ( from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.models import Gate from pretix.base.models.checkin import Checkin, CheckinList -from pretix.control.forms import ( - ItemMultipleChoiceField, SalesChannelCheckboxSelectMultiple, -) +from pretix.control.forms import ItemMultipleChoiceField from pretix.control.forms.widgets import Select2 @@ -67,10 +65,6 @@ class CheckinListForm(forms.ModelForm): kwargs.pop('locales', None) super().__init__(**kwargs) self.fields['limit_products'].queryset = self.event.items.all() - self.fields['auto_checkin_sales_channels'].queryset = self.event.organizer.sales_channels.all() - self.fields['auto_checkin_sales_channels'].widget = SalesChannelCheckboxSelectMultiple( - self.event, choices=self.fields['auto_checkin_sales_channels'].widget.choices - ) if not self.event.organizer.gates.exists(): del self.fields['gates'] @@ -102,7 +96,6 @@ class CheckinListForm(forms.ModelForm): 'limit_products', 'subevent', 'include_pending', - 'auto_checkin_sales_channels', 'allow_multiple_entries', 'allow_entry_after_exit', 'rules', @@ -125,7 +118,6 @@ class CheckinListForm(forms.ModelForm): 'limit_products': ItemMultipleChoiceField, 'gates': SafeModelMultipleChoiceField, 'subevent': SafeModelChoiceField, - 'auto_checkin_sales_channels': SafeModelMultipleChoiceField, 'exit_all_at': NextTimeField, } diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index a7f59a943..74723f502 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -35,7 +35,7 @@ # License for the specific language governing permissions and limitations under the License. from decimal import Decimal -from urllib.parse import urlencode, urlparse +from urllib.parse import urlencode from zoneinfo import ZoneInfo import pycountry @@ -76,8 +76,10 @@ from pretix.control.forms import ( ) from pretix.control.forms.widgets import Select2 from pretix.helpers.countries import CachedCountries -from pretix.multidomain.models import KnownDomain -from pretix.multidomain.urlreverse import build_absolute_uri +from pretix.multidomain.models import AlternativeDomainAssignment, KnownDomain +from pretix.multidomain.urlreverse import ( + build_absolute_uri, get_organizer_domain, +) from pretix.plugins.banktransfer.payment import BankTransfer from pretix.presale.style import get_fonts @@ -136,6 +138,11 @@ class EventWizardBasicsForm(I18nModelForm): choices=settings.LANGUAGES, label=_("Default language"), ) + no_taxes = forms.BooleanField( + label=_("I don't want to specify taxes now"), + help_text=_("You can always configure tax rates later."), + required=False, + ) tax_rate = forms.DecimalField( label=_("Sales tax rate"), help_text=_("Do you need to pay sales tax on your tickets? In this case, please enter the applicable tax rate " @@ -223,6 +230,11 @@ class EventWizardBasicsForm(I18nModelForm): raise ValidationError({ 'timezone': _('Your default locale must be specified.') }) + if not data.get("no_taxes") and not data.get("tax_rate"): + raise ValidationError({ + 'tax_rate': _('You have not specified a tax rate. If you do not want us to compute sales taxes, please ' + 'check "{field}" above.').format(field=self.fields["no_taxes"].label) + }) # change timezone zone = ZoneInfo(data.get('timezone')) @@ -353,14 +365,9 @@ class EventUpdateForm(I18nModelForm): def __init__(self, *args, **kwargs): self.change_slug = kwargs.pop('change_slug', False) - self.domain = kwargs.pop('domain', False) kwargs.setdefault('initial', {}) self.instance = kwargs['instance'] - if self.domain and self.instance: - initial_domain = self.instance.domains.first() - if initial_domain: - kwargs['initial'].setdefault('domain', initial_domain.domainname) super().__init__(*args, **kwargs) if not self.change_slug: @@ -369,48 +376,54 @@ class EventUpdateForm(I18nModelForm): self.fields['location'].widget.attrs['placeholder'] = _( 'Sample Conference Center\nHeidelberg, Germany' ) - if self.domain: + + try: self.fields['domain'] = forms.CharField( max_length=255, - label=_('Custom domain'), + label=_('Domain'), + initial=self.instance.domain.domainname, + required=False, + disabled=True, + help_text=_('You can configure this in your organizer settings.') + ) + except KnownDomain.DoesNotExist: + domain = get_organizer_domain(self.instance.organizer) + try: + current_domain_assignment = self.instance.alternative_domain_assignment + except AlternativeDomainAssignment.DoesNotExist: + current_domain_assignment = None + self.fields['domain'] = forms.ChoiceField( + label=_('Domain'), + help_text=_('You can add more domains in your organizer account.'), + choices=[('', _('Same as organizer account') + (f" ({domain})" if domain else ""))] + [ + (d.domainname, d.domainname) for d in self.instance.organizer.domains.filter(mode=KnownDomain.MODE_ORG_ALT_DOMAIN) + ], + initial=current_domain_assignment.domain_id if current_domain_assignment else "", required=False, - help_text=_('You need to configure the custom domain in the webserver beforehand.') ) self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all() self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={ 'data-inverse-dependency': '<[name$=all_sales_channels]', }, choices=self.fields['limit_sales_channels'].widget.choices) - def clean_domain(self): - d = self.cleaned_data['domain'] - if d: - if d == urlparse(settings.SITE_URL).hostname: - raise ValidationError( - _('You cannot choose the base domain of this installation.') - ) - if KnownDomain.objects.filter(domainname=d).exclude(event=self.instance.pk).exists(): - raise ValidationError( - _('This domain is already in use for a different event or organizer.') - ) - return d - def save(self, commit=True): instance = super().save(commit) - if self.domain: - current_domain = instance.domains.first() - if self.cleaned_data['domain']: - if current_domain and current_domain.domainname != self.cleaned_data['domain']: - current_domain.delete() - KnownDomain.objects.create( - organizer=instance.organizer, event=instance, domainname=self.cleaned_data['domain'] - ) - elif not current_domain: - KnownDomain.objects.create( - organizer=instance.organizer, event=instance, domainname=self.cleaned_data['domain'] - ) - elif current_domain: - current_domain.delete() + try: + current_domain_assignment = instance.alternative_domain_assignment + except AlternativeDomainAssignment.DoesNotExist: + current_domain_assignment = None + if self.cleaned_data['domain'] and not hasattr(instance, 'domain'): + domain = self.instance.organizer.domains.get(mode=KnownDomain.MODE_ORG_ALT_DOMAIN, domainname=self.cleaned_data["domain"]) + AlternativeDomainAssignment.objects.update_or_create( + event=instance, + defaults={ + "domain": domain, + } + ) + instance.cache.clear() + elif current_domain_assignment: + current_domain_assignment.delete() instance.cache.clear() return instance @@ -844,6 +857,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm): 'invoice_address_company_required', 'invoice_address_beneficiary', 'invoice_address_custom_field', + 'invoice_address_custom_field_helptext', 'invoice_name_required', 'invoice_address_not_asked_free', 'invoice_include_free', @@ -1371,7 +1385,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm): self.event.meta_values_cached = self.event.meta_values.select_related('property').all() for k, v in self.base_context.items(): - self._set_field_placeholders(k, v) + self._set_field_placeholders(k, v, rich=k.startswith('mail_text_')) for k, v in list(self.fields.items()): if k.endswith('_attendee') and not event.settings.attendee_emails_asked: diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index f3c5ce258..44b63a82d 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -549,7 +549,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm): ) email = forms.CharField( required=False, - label=_('E-mail address') + label=_('Email address') ) comment = forms.CharField( required=False, @@ -563,7 +563,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm): email_known_to_work = forms.NullBooleanField( required=False, widget=FilterNullBooleanSelect, - label=_('E-mail address verified'), + label=_('Email address verified'), ) total = forms.DecimalField( localize=True, @@ -648,7 +648,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm): ) self.fields['attendee_email'] = forms.CharField( required=False, - label=_('Attendee e-mail address') + label=_('Attendee email address') ) self.fields['attendee_address_company'] = forms.CharField( required=False, @@ -1967,7 +1967,7 @@ class CheckinListAttendeeFilterForm(FilterForm): if s == '1': qs = qs.filter(last_entry__isnull=False) elif s == '2': - qs = qs.filter(pk__in=self.list.positions_inside.values_list('pk')) + qs = self.list._filter_positions_inside(qs) elif s == '3': qs = qs.filter(last_entry__isnull=False).filter( Q(last_exit__isnull=False) & Q(last_exit__gte=F('last_entry')) diff --git a/src/pretix/control/forms/global_settings.py b/src/pretix/control/forms/global_settings.py index 6852d409e..41f4b5745 100644 --- a/src/pretix/control/forms/global_settings.py +++ b/src/pretix/control/forms/global_settings.py @@ -128,7 +128,7 @@ class UpdateSettingsForm(SettingsForm): ) update_check_email = forms.EmailField( required=False, - label=_("E-mail notifications"), + label=_("Email notifications"), help_text=_("We will notify you at this address if we detect that a new update is available. This " "address will not be transmitted to pretix.eu, the emails will be sent by this server " "locally.") diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index a2e07f4df..c11e6b3f5 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -40,7 +40,8 @@ from urllib.parse import urlencode from django import forms from django.conf import settings from django.core.exceptions import ValidationError -from django.db.models import Max +from django.db.models import Max, Q +from django.forms import ChoiceField, RadioSelect from django.forms.formsets import DELETION_FIELD_NAME from django.urls import reverse from django.utils.functional import cached_property @@ -79,11 +80,67 @@ class CategoryForm(I18nModelForm): 'name', 'internal_name', 'description', - 'is_addon' + 'cross_selling_condition', + 'cross_selling_match_products', ] widgets = { 'description': I18nMarkdownTextarea, + 'cross_selling_condition': RadioSelect, } + field_classes = { + 'cross_selling_match_products': SafeModelMultipleChoiceField, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + tpl = '{}   {}' + self.fields['category_type'] = ChoiceField(widget=RadioSelect, choices=( + ('normal', mark_safe(tpl.format( + _('Normal category'), + _('Products in this category are regular products displayed on the front page.') + )),), + ('addon', mark_safe(tpl.format( + _('Add-on product category'), + _('Products in this category are add-on products and can only be bought as add-ons.') + )),), + ('only', mark_safe(tpl.format( + _('Cross-selling category'), + _('Products in this category are regular products, but are only shown in the cross-selling step, ' + 'according to the configuration below.') + )),), + ('both', mark_safe(tpl.format( + _('Normal + cross-selling category'), + _('Products in this category are regular products displayed on the front page, but are additionally ' + 'shown in the cross-selling step, according to the configuration below.') + )),), + )) + self.fields['category_type'].initial = self.instance.category_type + + self.fields['cross_selling_condition'].widget.attrs['data-display-dependency'] = '#id_category_type_2,#id_category_type_3' + self.fields['cross_selling_condition'].widget.attrs['data-disable-dependent'] = 'true' + self.fields['cross_selling_condition'].widget.choices = self.fields['cross_selling_condition'].widget.choices[1:] + self.fields['cross_selling_condition'].required = False + + self.fields['cross_selling_match_products'].widget = forms.CheckboxSelectMultiple( + attrs={ + 'class': 'scrolling-multiple-choice', + 'data-display-dependency': '#id_cross_selling_condition_2' + } + ) + self.fields['cross_selling_match_products'].queryset = self.event.items.filter( + # don't show products which are only visible in addon/cross-sell step themselves + Q(category__isnull=True) | Q( + Q(category__is_addon=False) & Q(Q(category__cross_selling_mode='both') | Q(category__cross_selling_mode__isnull=True)) + ) + ) + + def clean(self): + d = super().clean() + if d.get('category_type') == 'only' or d.get('category_type') == 'both': + if not d.get('cross_selling_condition'): + raise ValidationError({'cross_selling_condition': [_('This field is required')]}) + self.instance.category_type = d.get('category_type') + return d class QuestionForm(I18nModelForm): diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index 70bab945e..e251d64ff 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -609,6 +609,49 @@ class OrderFeeChangeForm(forms.Form): change_decimal_field(self.fields['value'], instance.order.event.currency) +class OrderFeeAddForm(forms.Form): + fee_type = forms.ChoiceField(choices=OrderFee.FEE_TYPES) + value = forms.DecimalField( + max_digits=13, decimal_places=2, + localize=True, + label=_('Price'), + help_text=_("including all taxes"), + ) + tax_rule = forms.ModelChoiceField( + TaxRule.objects.none(), + required=False, + ) + description = forms.CharField(required=False) + + def __init__(self, *args, **kwargs): + order = kwargs.pop('order') + super().__init__(*args, **kwargs) + self.fields['tax_rule'].queryset = order.event.tax_rules.all() + change_decimal_field(self.fields['value'], order.event.currency) + + +class OrderFeeAddFormset(forms.BaseFormSet): + def __init__(self, *args, **kwargs): + self.order = kwargs.pop('order', None) + super().__init__(*args, **kwargs) + + def _construct_form(self, i, **kwargs): + kwargs['order'] = self.order + return super()._construct_form(i, **kwargs) + + @property + def empty_form(self): + form = self.form( + auto_id=self.auto_id, + prefix=self.add_prefix('__prefix__'), + empty_permitted=True, + use_required_attribute=False, + order=self.order, + ) + self.add_fields(form, None) + return form + + class OrderContactForm(forms.ModelForm): regenerate_secrets = forms.BooleanField(required=False, label=_('Invalidate secrets'), help_text=_('Regenerates the order and ticket secrets. You will ' diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index e2e695bd7..8a4082e86 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -54,6 +54,7 @@ from i18nfield.strings import LazyI18nString from phonenumber_field.formfields import PhoneNumberField from pytz import common_timezones +from pretix.api.auth.devicesecurity import get_all_security_profiles from pretix.api.models import WebHook from pretix.api.webhooks import get_all_webhook_events from pretix.base.customersso.oidc import oidc_validate_and_complete_config @@ -132,63 +133,108 @@ class OrganizerDeleteForm(forms.Form): class OrganizerUpdateForm(OrganizerForm): def __init__(self, *args, **kwargs): - self.domain = kwargs.pop('domain', False) self.change_slug = kwargs.pop('change_slug', False) kwargs.setdefault('initial', {}) self.instance = kwargs['instance'] - if self.domain and self.instance: - initial_domain = self.instance.domains.filter(event__isnull=True).first() - if initial_domain: - kwargs['initial'].setdefault('domain', initial_domain.domainname) super().__init__(*args, **kwargs) if not self.change_slug: self.fields['slug'].widget.attrs['readonly'] = 'readonly' - if self.domain: - self.fields['domain'] = forms.CharField( - max_length=255, - label=_('Custom domain'), - required=False, - help_text=_('You need to configure the custom domain in the webserver beforehand.') - ) - - def clean_domain(self): - d = self.cleaned_data['domain'] - if d: - if d == urlparse(settings.SITE_URL).hostname: - raise ValidationError( - _('You cannot choose the base domain of this installation.') - ) - if KnownDomain.objects.filter(domainname=d).exclude(organizer=self.instance.pk, - event__isnull=True).exists(): - raise ValidationError( - _('This domain is already in use for a different event or organizer.') - ) - return d def clean_slug(self): if self.change_slug: return self.cleaned_data['slug'] return self.instance.slug - def save(self, commit=True): - instance = super().save(commit) - if self.domain: - current_domain = instance.domains.filter(event__isnull=True).first() - if self.cleaned_data['domain']: - if current_domain and current_domain.domainname != self.cleaned_data['domain']: - current_domain.delete() - KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain']) - elif not current_domain: - KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain']) - elif current_domain: - current_domain.delete() - instance.cache.clear() - for ev in instance.events.all(): - ev.cache.clear() +class KnownDomainForm(forms.ModelForm): + class Meta: + model = KnownDomain + fields = ["domainname", "mode", "event"] + field_classes = { + "event": SafeModelChoiceField, + } - return instance + def __init__(self, *args, **kwargs): + self.organizer = kwargs.pop('organizer') + super().__init__(*args, **kwargs) + self.fields["event"].queryset = self.organizer.events.all() + if self.instance and self.instance.pk: + self.fields["domainname"].widget.attrs['readonly'] = 'readonly' + + def clean_domainname(self): + if self.instance and self.instance.pk: + return self.instance.domainname + d = self.cleaned_data['domainname'] + if d: + if d == urlparse(settings.SITE_URL).hostname: + raise ValidationError( + _('You cannot choose the base domain of this installation.') + ) + if KnownDomain.objects.filter(domainname=d).exclude(organizer=self.instance.organizer).exists(): + raise ValidationError( + _('This domain is already in use for a different event or organizer.') + ) + return d + + def clean(self): + d = super().clean() + + if d["mode"] == KnownDomain.MODE_ORG_DOMAIN and d["event"]: + raise ValidationError( + _("Do not choose an event for this mode.") + ) + + if d["mode"] == KnownDomain.MODE_ORG_ALT_DOMAIN and d["event"]: + raise ValidationError( + _("Do not choose an event for this mode. You can assign events to this domain in event settings.") + ) + + if d["mode"] == KnownDomain.MODE_EVENT_DOMAIN and not d["event"]: + raise ValidationError( + _("You need to choose an event.") + ) + + return d + + +class BaseKnownDomainFormSet(forms.BaseInlineFormSet): + def __init__(self, *args, **kwargs): + self.organizer = kwargs.pop('organizer') + super().__init__(*args, **kwargs) + + def _construct_form(self, i, **kwargs): + kwargs['organizer'] = self.organizer + return super()._construct_form(i, **kwargs) + + @property + def empty_form(self): + form = self.form( + auto_id=self.auto_id, + prefix=self.add_prefix('__prefix__'), + empty_permitted=True, + use_required_attribute=False, + organizer=self.organizer, + ) + self.add_fields(form, None) + return form + + def clean(self): + super().clean() + data = [f.cleaned_data for f in self.forms] + + if len([d for d in data if d.get("mode") == KnownDomain.MODE_ORG_DOMAIN and not d.get("DELETE")]) > 1: + raise ValidationError(_("You may set only one organizer domain.")) + + return data + + +KnownDomainFormset = inlineformset_factory( + Organizer, KnownDomain, + KnownDomainForm, + formset=BaseKnownDomainFormSet, + can_order=False, can_delete=True, extra=0 +) class SafeOrderPositionChoiceField(forms.ModelChoiceField): @@ -311,6 +357,11 @@ class DeviceForm(forms.ModelForm): '-has_subevents', '-date_from' ) self.fields['gate'].queryset = organizer.gates.all() + self.fields['security_profile'] = forms.ChoiceField( + label=self.fields['security_profile'].label, + help_text=self.fields['security_profile'].help_text, + choices=[(k, v.verbose_name) for k, v in get_all_security_profiles().items()], + ) def clean(self): d = super().clean() @@ -344,6 +395,11 @@ class DeviceBulkEditForm(forms.ModelForm): '-has_subevents', '-date_from' ) self.fields['gate'].queryset = organizer.gates.all() + self.fields['security_profile'] = forms.ChoiceField( + label=self.fields['security_profile'].label, + help_text=self.fields['security_profile'].help_text, + choices=[(k, v.verbose_name) for k, v in get_all_security_profiles().items()], + ) def clean(self): d = super().clean() diff --git a/src/pretix/control/forms/users.py b/src/pretix/control/forms/users.py index f1cf45e3b..08cf6dcef 100644 --- a/src/pretix/control/forms/users.py +++ b/src/pretix/control/forms/users.py @@ -40,7 +40,7 @@ class StaffSessionForm(forms.ModelForm): class UserEditForm(forms.ModelForm): error_messages = { - 'duplicate_identifier': _("There already is an account associated with this e-mail address. " + 'duplicate_identifier': _("There already is an account associated with this email address. " "Please choose a different one."), 'pw_mismatch': _("Please enter the same password twice"), } diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 379b879c2..305b54859 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -57,6 +57,7 @@ from pretix.base.models import ( Checkin, CheckinList, Event, ItemVariation, LogEntry, OrderPosition, TaxRule, ) +from pretix.base.models.orders import PrintLog from pretix.base.signals import ( app_cache, logentry_display, orderposition_blocked_display, ) @@ -348,7 +349,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): if logentry.action_type == 'pretix.event.order.consent': return _('The user confirmed the following message: "{}"').format( - bleach.clean(logentry.parsed_data.get('msg'), tags=[], strip=True) + bleach.clean(logentry.parsed_data.get('msg'), tags=set(), strip=True) ) if logentry.action_type == 'pretix.event.order.canceled': @@ -375,6 +376,16 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): if sender and logentry.action_type.startswith('pretix.event.checkin'): return _display_checkin(sender, logentry) + if logentry.action_type == 'pretix.event.order.print': + return _('Position #{posid} has been printed at {datetime} with type "{type}".').format( + posid=data.get('positionid'), + datetime=date_format( + dateutil.parser.parse(data["datetime"]).astimezone(sender.timezone), + "SHORT_DATETIME_FORMAT" + ), + type=dict(PrintLog.PRINT_TYPES)[data["type"]], + ) + @receiver(signal=orderposition_blocked_display, dispatch_uid="pretixcontrol_orderposition_blocked_display") def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, block_name, **kwargs): diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index 4fb90f823..c081961aa 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -78,7 +78,7 @@ def get_event_navigation(request: HttpRequest): 'active': url.url_name == 'event.settings.tickets', }, { - 'label': _('E-mail'), + 'label': _('Email'), 'url': reverse('control:event.settings.mail', kwargs={ 'event': request.event.slug, 'organizer': request.event.organizer.slug, @@ -132,16 +132,6 @@ def get_event_navigation(request: HttpRequest): 'icon': 'wrench', 'children': event_settings }) - if request.event.has_subevents: - nav.append({ - 'label': pgettext_lazy('subevent', 'Dates'), - 'url': reverse('control:event.subevents', kwargs={ - 'event': request.event.slug, - 'organizer': request.event.organizer.slug, - }), - 'active': ('event.subevent' in url.url_name), - 'icon': 'calendar', - }) if 'can_change_items' in request.eventpermset: nav.append({ @@ -197,6 +187,18 @@ def get_event_navigation(request: HttpRequest): ] }) + if 'can_change_event_settings' in request.eventpermset: + if request.event.has_subevents: + nav.append({ + 'label': pgettext_lazy('subevent', 'Dates'), + 'url': reverse('control:event.subevents', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug, + }), + 'active': ('event.subevent' in url.url_name), + 'icon': 'calendar', + }) + if 'can_view_orders' in request.eventpermset: children = [ { @@ -496,7 +498,7 @@ def get_organizer_navigation(request): 'active': url.url_name.startswith('organizer.propert'), }, { - 'label': _('E-mail'), + 'label': _('Email'), 'url': reverse('control:organizer.settings.mail', kwargs={ 'organizer': request.organizer.slug, }), diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index df9806fee..79d9c5f52 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -61,6 +61,7 @@ + {% endcompress %} {{ html_head|safe }} @@ -421,6 +422,14 @@ {% endif %} + {% if warning_cronjob %} +
+ {% blocktrans trimmed %} + The cronjob component of pretix was not executed in the last hours. Please check that + you have completed all installation steps and your cronjob is executed correctly. + {% endblocktrans %} +
+ {% endif %} {% if debug_warning %}
diff --git a/src/pretix/control/templates/pretixcontrol/checkin/checkins.html b/src/pretix/control/templates/pretixcontrol/checkin/checkins.html index 3d40d1526..87117e05b 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/checkins.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/checkins.html @@ -127,6 +127,7 @@ {{ c.position.order.code }}-{{ c.position.positionid }} + {% include "pretixcontrol/checkin/fragment_checkin_source_type.html" with source_type=c.raw_source_type %} {% if c.position.attendee_name %}
@@ -143,7 +144,7 @@ {% endif %} {% else %} - + {% include "pretixcontrol/checkin/fragment_checkin_source_type.html" with source_type=c.raw_source_type %} {{ c.raw_barcode|slice:":16" }}{% if c.raw_barcode|length > 16 %}…{% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/items/category.html b/src/pretix/control/templates/pretixcontrol/items/category.html index b2b9c16ec..1e87f687b 100644 --- a/src/pretix/control/templates/pretixcontrol/items/category.html +++ b/src/pretix/control/templates/pretixcontrol/items/category.html @@ -16,7 +16,19 @@ {% bootstrap_field form.internal_name layout="control" %}
{% bootstrap_field form.description layout="control" %} - {% bootstrap_field form.is_addon layout="control" %} + {% bootstrap_field form.category_type layout="control" horizontal_field_class="big-radio-wrapper col-md-9" %} +
+
+
+ {% blocktrans trimmed %} + Please note that cross-selling categories are intended as a marketing feature and are not + suitable for strictly ensuring that products are only available in certain combinations. + {% endblocktrans %} +
+
+
+ {% bootstrap_field form.cross_selling_condition layout="control" horizontal_field_class="col-md-9" %} + {% bootstrap_field form.cross_selling_match_products layout="control" %} {% if category %} diff --git a/src/pretix/control/templates/pretixcontrol/items/question.html b/src/pretix/control/templates/pretixcontrol/items/question.html index 25689d3bf..18aab8d09 100644 --- a/src/pretix/control/templates/pretixcontrol/items/question.html +++ b/src/pretix/control/templates/pretixcontrol/items/question.html @@ -13,31 +13,52 @@ {% trans "Edit question" %} - -

- - - -

- + +
+
+

{% trans "Filter" %}

+
+
+
+
+ +
+
+ +
+ {% if request.event.has_subevents %} +
+ {% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %} +
+ {% endif %} +
+
+ +
+
+
+
{% if not stats %}
@@ -75,7 +96,7 @@ {% for stat in stats %} - + {{ stat.answer }} diff --git a/src/pretix/control/templates/pretixcontrol/order/change.html b/src/pretix/control/templates/pretixcontrol/order/change.html index 92aa9bece..03d8b3398 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change.html +++ b/src/pretix/control/templates/pretixcontrol/order/change.html @@ -296,11 +296,11 @@ {% endfor %} -
- {{ add_formset.management_form }} - {% bootstrap_formset_errors add_formset %} +
+ {{ add_position_formset.management_form }} + {% bootstrap_formset_errors add_position_formset %}
- {% for add_form in add_formset %} + {% for add_form in add_position_formset %}

@@ -351,25 +351,25 @@ {% trans "Add product" %}
- {{ add_formset.empty_form.id }} - {% bootstrap_field add_formset.empty_form.DELETE form_group_class="" layout="inline" %} + {{ add_position_formset.empty_form.id }} + {% bootstrap_field add_position_formset.empty_form.DELETE form_group_class="" layout="inline" %}

- {% bootstrap_field add_formset.empty_form.itemvar layout="control" %} - {% bootstrap_field add_formset.empty_form.price addon_after=request.event.currency layout="control" %} - {% if add_formset.empty_form.addon_to %} - {% bootstrap_field add_formset.empty_form.addon_to layout="control" %} + {% bootstrap_field add_position_formset.empty_form.itemvar layout="control" %} + {% bootstrap_field add_position_formset.empty_form.price addon_after=request.event.currency layout="control" %} + {% if add_position_formset.empty_form.addon_to %} + {% bootstrap_field add_position_formset.empty_form.addon_to layout="control" %} {% endif %} - {% if add_formset.empty_form.subevent %} - {% bootstrap_field add_formset.empty_form.subevent layout="control" %} + {% if add_position_formset.empty_form.subevent %} + {% bootstrap_field add_position_formset.empty_form.subevent layout="control" %} {% endif %} - {% if add_formset.empty_form.used_membership %} - {% bootstrap_field add_formset.empty_form.used_membership layout="control" %} + {% if add_position_formset.empty_form.used_membership %} + {% bootstrap_field add_position_formset.empty_form.used_membership layout="control" %} {% endif %} - {% bootstrap_field add_formset.empty_form.seat layout="control" %} + {% bootstrap_field add_position_formset.empty_form.seat layout="control" %}
@@ -431,13 +431,77 @@ {% bootstrap_field fee.form.operation_cancel layout='inline' %} {% if fee.fee_type == "payment" %} - {% trans "Manually modifying payment fees is discouraged since they might automatically be on subsequent order changes or when choosing a different payment method." %} + {% trans "Manually modifying payment fees is discouraged since they might automatically be updated on subsequent order changes or when choosing a different payment method." %} {% endif %}
{% endfor %} + +
+ {{ add_fee_formset.management_form }} + {% bootstrap_formset_errors add_fee_formset %} +
+ {% for add_form in add_fee_formset %} +
+
+

+ + {% trans "Add fee" %} +
+ {{ add_form.id }} + {% bootstrap_field add_form.DELETE form_group_class="" layout="inline" %} +
+

+
+
+
+ {% bootstrap_field add_form.fee_type layout='control' %} + {% bootstrap_field add_form.value addon_after=request.event.currency layout='control' %} + {% bootstrap_field add_form.tax_rule layout='control' %} + {% bootstrap_field add_form.description layout='control' %} +
+
+
+ {% endfor %} +
+ +

+ +

+
+

diff --git a/src/pretix/control/templates/pretixcontrol/order/change_questions.html b/src/pretix/control/templates/pretixcontrol/order/change_questions.html index 02b4c7fc0..df298d912 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change_questions.html +++ b/src/pretix/control/templates/pretixcontrol/order/change_questions.html @@ -46,11 +46,13 @@
{% for form in forms %} - {% if form.pos.item != pos.item %} - {# Add-Ons #} - + {{ form.pos.item }} - {% endif %} - {% bootstrap_form form layout="control" %} +
+ {% if form.pos.item != pos.item %} + {# Add-Ons #} + + {{ form.pos.item }} + {% endif %} + {% bootstrap_form form layout="control" %} +
{% endfor %}
diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 889ffc8b0..af221e38e 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -198,7 +198,7 @@ {% endif %} - {% if order.status == "n" %} + {% if order.status == "n" and not order.require_approval %}
{% trans "Expiry date" %}
{{ order.expires|date:"SHORT_DATETIME_FORMAT" }} diff --git a/src/pretix/control/templates/pretixcontrol/order/sendmail.html b/src/pretix/control/templates/pretixcontrol/order/sendmail.html index 66a619c75..5681c1cd3 100644 --- a/src/pretix/control/templates/pretixcontrol/order/sendmail.html +++ b/src/pretix/control/templates/pretixcontrol/order/sendmail.html @@ -24,7 +24,7 @@ {% endif %} {% if request.method == "POST" %}
- {% trans "E-mail preview" %} + {% trans "Email preview" %}
{{ preview_output.subject }}

diff --git a/src/pretix/control/templates/pretixcontrol/organizers/customer.html b/src/pretix/control/templates/pretixcontrol/organizers/customer.html index 8d79ebdff..b5954d068 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/customer.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/customer.html @@ -46,7 +46,7 @@ {% trans "active" %} {% endif %}
-
{% trans "E-mail" %}
+
{% trans "Email" %}
{{ customer.email|default_if_none:"" }} {% if customer.email and not customer.provider %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html index ce39171d1..f9d000a1f 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html @@ -294,6 +294,71 @@ {% trans "Invoices" %} {% bootstrap_field sform.invoice_regenerate_allowed layout="control" %} + {% if domain_formset %} +
+ {% trans "Domains" %} +
+ {% trans "This dialog is intended for advanced users." %} + {% trans "The domain needs to be configured on your webserver before it can be used here." %} +
+
+ {{ domain_formset.management_form }} + {% bootstrap_formset_errors domain_formset %} +
+ {% for form in domain_formset %} +
+
+ {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} +
+
+ {% bootstrap_field form.domainname layout='' form_group_class="" %} + {% bootstrap_form_errors form %} +
+
+ {% bootstrap_field form.mode layout='' form_group_class="" %} +
+
+ {% bootstrap_field form.event layout='' form_group_class="" %} +
+
+
+ +
+
+ {% endfor %} +
+ +

+ +

+
+ {% endif %}

+ aria-label="{% trans "Quantity" %}"> -
+ {% endif %}
{% else %} @@ -250,7 +253,7 @@ {% include "pretixpresale/event/fragment_quota_left.html" with avail=item.cached_availability %} {% endif %} {% if item.min_per_order and item.min_per_order > 1 %} -

+

{% blocktrans trimmed with num=item.min_per_order %} minimum amount to order: {{ num }} @@ -341,12 +344,13 @@ name="cp_{{ form.pos.pk }}_item_{{ item.id }}" id="cp_{{ form.pos.pk }}_item_{{ item.id }}" aria-label="{% blocktrans with item=item.name %}Add {{ item }} to cart{% endblocktrans %}" - {% if item.description %} aria-describedby="cp-{{ form.pos.pk }}-item-{{ item.id }}-description"{% endif %}> + aria-describedby="c-{{ form.pos.pk }}-{{ category_idx }}-addon-count-desc"> {% trans "Select" context "checkbox" %} {% else %} -

+
+ {% blocktrans with item=item.name %}Add {{ item }} to cart{% endblocktrans %} + aria-label="{% trans "Quantity" %}"> -
+ {% endif %}
{% else %} @@ -370,6 +373,7 @@ {% endif %} {% endfor %} + {% endwith %} {% empty %} {% trans "There are no add-ons available for this product." %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart_box.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart_box.html index 183c23cb5..d1d94cdc1 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart_box.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart_box.html @@ -10,18 +10,41 @@ {% trans "Your cart" %} - + {% if cart.positions %} + + {% endif %}
- {% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event editable=True %} + {% if cart.positions %} + {% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event editable=True %} + {% endif %} + {% if cart.current_selected_payments %} +

{% trans "You already selected the following payment methods:" %}

+
+ {% for p in cart.current_selected_payments %} +
+
+
+ {{ p.provider_name }} +
+
+ {% if p.payment_amount %} + {{ p.payment_amount|money:request.event.currency }} + {% endif %} +
+
+
+ {% endfor %} +
+ {% endif %}

- -

+ {% endif %}
{% else %} @@ -234,7 +246,7 @@
{% else %} -
@@ -250,9 +262,9 @@ {% endif %}
-

{{ item.name }}

+ {{ item.name }} {% if item.description %} -
+
{{ item.description|localize|rich_text }}
{% endif %} @@ -293,10 +305,10 @@ + {% if item.description %} aria-describedby="{{ form_prefix }}item-{{ item.id }}-description"{% endif %}> {% trans "Select" context "checkbox" %} {% else %} -
- - -
+ {% endif %}
{% else %} @@ -386,4 +398,4 @@ {% endif %} {% endfor %} -{% endfor %} +{% endwith %}{% endfor %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html index 4f3e9fa6e..62c9c11a8 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order.html +++ b/src/pretix/presale/templates/pretixpresale/event/order.html @@ -296,7 +296,7 @@
{% if order.email %} -
{% trans "E-mail" %}
+
{% trans "Email" %}
{{ order.email }}
{% endif %} {% if order.phone %} diff --git a/src/pretix/presale/templates/pretixpresale/event/voucher.html b/src/pretix/presale/templates/pretixpresale/event/voucher.html index 706300f30..556222f22 100644 --- a/src/pretix/presale/templates/pretixpresale/event/voucher.html +++ b/src/pretix/presale/templates/pretixpresale/event/voucher.html @@ -231,16 +231,17 @@ {% trans "Select" context "checkbox" %} {% else %} -
+
+ {% blocktrans with item=item.name %}Add {{ item }}, {{ var }} to cart{% endblocktrans %} + aria-label="{% trans "Quantity" %}"> -
+ {% endif %} {% else %} {% else %} -
+
+ {% blocktrans with item=item.name %}Add {{ item }} to cart{% endblocktrans %} + aria-label="{% trans "Quantity" %}"> -
+ {% endif %} {% else %}