diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ca75c8f44..381e679df2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,7 +24,7 @@ jobs: name: Packaging strategy: matrix: - python-version: ["3.11"] + python-version: ["3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/.github/workflows/strings.yml b/.github/workflows/strings.yml index cfc580b9a7..cf75b54a15 100644 --- a/.github/workflows/strings.yml +++ b/.github/workflows/strings.yml @@ -24,10 +24,10 @@ jobs: name: Check gettext syntax steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 - uses: actions/cache@v4 with: path: ~/.cache/pip @@ -49,10 +49,10 @@ jobs: name: Spellcheck steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 - uses: actions/cache@v4 with: path: ~/.cache/pip diff --git a/.github/workflows/style-js.yml b/.github/workflows/style-js.yml new file mode 100644 index 0000000000..64e35fecce --- /dev/null +++ b/.github/workflows/style-js.yml @@ -0,0 +1,43 @@ +name: JS Code Style + +on: + push: + branches: [ master ] + paths: + - 'src/pretix/static/pretixpresale/widget/**' + - 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**' + - 'src/pretix/plugins/webcheckin/**' + - 'eslint.config.mjs' + - 'package.json' + - 'package-lock.json' + pull_request: + branches: [ master ] + paths: + - 'src/pretix/static/pretixpresale/widget/**' + - 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**' + - 'src/pretix/plugins/webcheckin/**' + - 'eslint.config.mjs' + - 'package.json' + - 'package-lock.json' + +permissions: + contents: read + +env: + FORCE_COLOR: 1 + +jobs: + eslint: + name: eslint + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Set up Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - name: Install Dependencies + run: npm ci + - name: Run ESLint + run: npm run lint:eslint diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 4ecec238fb..1f74dfe7db 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -24,10 +24,10 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 - uses: actions/cache@v4 with: path: ~/.cache/pip @@ -44,10 +44,10 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 - uses: actions/cache@v4 with: path: ~/.cache/pip @@ -64,10 +64,10 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 - name: Install Dependencies run: pip3 install licenseheaders - name: Run licenseheaders diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6ae43076ec..a1c1ba2977 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,13 +23,15 @@ jobs: name: Tests strategy: matrix: - python-version: ["3.10", "3.11", "3.13"] + python-version: ["3.11", "3.13", "3.14"] database: [sqlite, postgres] exclude: - database: sqlite python-version: "3.10" - database: sqlite python-version: "3.11" + - database: sqlite + python-version: "3.12" services: postgres: image: postgres:15 @@ -81,7 +83,7 @@ jobs: file: src/coverage.xml token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false - if: matrix.database == 'postgres' && matrix.python-version == '3.11' + if: matrix.database == 'postgres' && matrix.python-version == '3.13' e2e: runs-on: ubuntu-22.04 name: E2E Tests @@ -121,7 +123,7 @@ jobs: working-directory: ./src run: make all compress - name: Install Playwright browsers - run: npx playwright install + run: playwright install - name: Run E2E tests working-directory: ./src run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10 diff --git a/.gitignore b/.gitignore index 5a877a56fd..d5a3a0e522 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,6 @@ local/ .pydevproject .DS_Store node_modules/ +.vite/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b354c958f8..377bbfe970 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,9 +10,9 @@ tests: - cd src - python manage.py check - make all compress - - PRETIX_CONFIG_FILE=tests/ci_sqlite.cfg py.test -n 3 tests --maxfail=100 + - PRETIX_CONFIG_FILE=tests/ci_sqlite.cfg py.test -n 3 tests --ignore=tests/e2e --maxfail=100 except: - - pypi + - '/^v.*$/' pypi: stage: release image: @@ -35,7 +35,7 @@ pypi: - twine check dist/* - twine upload dist/* only: - - pypi + - '/^v.*$/' artifacts: paths: - src/dist/ diff --git a/.node-version b/.node-version index 98d9bcb75a..a45fd52cc5 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -17 +24 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ada91ce8a8..729172afa7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,11 +1,16 @@ Contributing to pretix ====================== -Hey there and welcome to pretix! +Welcome to pretix, we are happy that you would like to contribute. +Before you do so, please make sure to read the following documents: -* We've got a contributors guide in [our documentation](https://docs.pretix.eu/dev/development/contribution/) together with notes on the [development setup](https://docs.pretix.eu/dev/development/setup.html). +- [Contribution workflow](https://docs.pretix.eu/dev/development/contribution/general.html) +- [AI-assisted contribution policy](https://docs.pretix.eu/dev/development/contribution/ai.html) +- [Coding style and quality](https://docs.pretix.eu/dev/development/contribution/style.html) +- [Development setup](https://docs.pretix.eu/dev/development/setup.html) +- [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html) -* Please note that we have a [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html) in place that applies to all project contributions, including issues, pull requests, etc. - -* Before we can accept a PR from you we'll need you to sign [our CLA](https://pretix.eu/about/en/cla). You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/). +Before we can accept your first PR we'll need you to sign [our **Contributor License Agreement** (CLA)](https://pretix.eu/about/en/cla). +You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/). +**Before contributing new functionality, always open a discussion first.** \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c5fa61da57..871d04d9b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ -FROM python:3.11-bookworm +FROM python:3.13-trixie -RUN apt-get update && \ +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get update && \ apt-get install -y --no-install-recommends \ build-essential \ gettext \ @@ -20,17 +21,17 @@ RUN apt-get update && \ supervisor \ libmaxminddb0 \ libmaxminddb-dev \ - zlib1g-dev && \ + zlib1g-dev \ + nodejs && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash - && \ - apt-get install -y nodejs && \ dpkg-reconfigure locales && \ locale-gen C.UTF-8 && \ /usr/sbin/update-locale LANG=C.UTF-8 && \ mkdir /etc/pretix && \ mkdir /data && \ useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \ + chmod 0755 /pretix && \ echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \ mkdir /static && \ mkdir /etc/supervisord @@ -56,8 +57,7 @@ COPY vite.config.ts /pretix/vite.config.ts RUN pip3 install -U \ pip \ - setuptools \ - wheel && \ + setuptools && \ cd /pretix && \ PRETIX_DOCKER_BUILD=TRUE pip3 install \ -e ".[memcached]" \ diff --git a/doc/api/resources/carts.rst b/doc/api/resources/carts.rst index 74afdc4565..b725cc5a75 100644 --- a/doc/api/resources/carts.rst +++ b/doc/api/resources/carts.rst @@ -192,7 +192,7 @@ Cart position endpoints * ``attendee_email`` (optional) * ``subevent`` (optional) * ``expires`` (optional) - * ``includes_tax`` (optional, **deprecated**, do not use, will be removed) + * ``includes_tax`` (optional, **DEPRECATED**, do not use, will be removed) * ``sales_channel`` (optional) * ``voucher`` (optional, expect a voucher code) * ``addons`` (optional, expect a list of nested objects of cart positions) diff --git a/doc/api/resources/checkin.rst b/doc/api/resources/checkin.rst index 479fa28a37..c4571ade22 100644 --- a/doc/api/resources/checkin.rst +++ b/doc/api/resources/checkin.rst @@ -46,12 +46,14 @@ Checking a ticket in this request twice with the same nonce, the second request will also succeed but will always create only one check-in object even when the previous request was successful as well. This allows for a certain level of idempotency and enables you to re-try after a connection failure. + :json string status: ``"ok"``, ``"incomplete"``, or ``"error"`` + :>json string status: ``"ok"``, ``"incomplete"``, ``"exchange"``, or ``"error"`` :>json string reason: Reason code, only set on status ``"error"``, see below for possible values. :>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null. :>json object position: Copy of the matching order position (if any was found). The contents are the same as the @@ -67,6 +69,8 @@ Checking a ticket in :>json object list: Excerpt of information about the matching :ref:`check-in list ` (if any was found), including the attributes ``id``, ``name``, ``event``, ``subevent``, and ``include_pending``. :>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``. + :>json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``. + :>json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``. **Example request**: @@ -224,6 +228,9 @@ Checking a ticket in * ``ambiguous`` - Multiple tickets match scan, rejected. * ``revoked`` - Ticket code has been revoked. * ``unapproved`` - Order has not yet been approved. + * ``already_exchanged`` - Ticket already has been exchanged for a reusable medium that must now be used for check-in. + * ``medium_invalid`` - Reusable medium identifier given was not found or is not valid. + * ``medium_exists`` - Reusable medium identifier already exists, but expected to be new. * ``error`` - Internal error. In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation`` diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index 6090772b58..3b6f506f4e 100644 --- a/doc/api/resources/checkinlists.rst +++ b/doc/api/resources/checkinlists.rst @@ -602,7 +602,8 @@ Order position endpoints We no longer recommend using this API if you're building a ticket scanning application, as it has a few design flaws that can lead to `security issues`_ or compatibility issues due to barcode content characters that are not - URL-safe. We recommend to use our new :ref:`check-in API ` instead. + URL-safe. We recommend to use our new :ref:`check-in API ` instead. Advanced features like medium + exchange are only supported on the new API. :query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never as an ``id``. This should be always set if you are passing through untrusted, scanned @@ -741,6 +742,9 @@ Order position endpoints * ``ambiguous`` - Multiple tickets match scan, rejected. * ``revoked`` - Ticket code has been revoked. * ``unapproved`` - Order has not yet been approved. + * ``already_exchanged`` - Ticket already has been exchanged for a reusable medium that must now be used for check-in. + * ``medium_invalid`` - Reusable medium identifier given was not found and could not be automatically created. + * ``medium_exists`` - Reusable medium identifier already exists, but expected to be new. In case of reason ``rules`` or ``invalid_time``, there might be an additional response field ``reason_explanation`` with a human-readable description of the violated rules. However, that field can also be missing or be ``null``. diff --git a/doc/api/resources/exhibitors.rst b/doc/api/resources/exhibitors.rst index 8f6a89a08b..6fe2d73045 100644 --- a/doc/api/resources/exhibitors.rst +++ b/doc/api/resources/exhibitors.rst @@ -844,3 +844,187 @@ You can also fetch existing leads (if you are authorized to do so): :statuscode 200: No error :statuscode 401: Invalid authentication code :statuscode 403: Not permitted to access bulk data + +Retrieving Vouchers +""""""""""""""""""" + +Vouchers returned by the App API use a different format than described in :ref:`rest-vouchers`. + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the voucher +code string The voucher code that is required to redeem the voucher +max_usages integer The maximum number of times this voucher can be + redeemed (default: 1). +redeemed integer The number of times this voucher already has been + redeemed. +valid_until datetime The voucher expiration date (or ``null``). +subevent string Name of the date inside an event series this voucher belongs to (or ``null``). +tag string A string that is used for grouping vouchers +comment string An internal exhibitor comment on the voucher. +items list of strings A list of items this voucher is restricted to (or ``null``). +price_mode string Determines how this voucher affects product prices. + Possible values: + + * ``none`` – No effect on price + * ``set`` – The product price is set to the given ``value`` + * ``subtract`` – The product price is determined by the original price *minus* the given ``value`` + * ``percent`` – The product price is determined by the original price reduced by the percentage given in ``value`` +value decimal (string) The value (see ``price_mode``) +redemptions list of objects A list of objects, where each object represents an order position that has been purchased using the voucher. + Each entry will contains the fields ``attendee_fields``, ``redemption_date`` and ``subevent``. + + The attendee data in the ``attendee_fields`` that is shown is based on the event's configuration, and each entry + contains the fields ``id``, ``label``, ``value``, and ``details``. ``details`` is usually empty + except in a few cases where it contains an additional list of objects + with ``value`` and ``label`` keys (e.g. splitting of names). +===================================== ========================== ======================================================= + + +.. http:get:: /exhibitors/api/v1/vouchers/ + + Returns a list of all vouchers connected to the exhibitor. + + Note that the ``attendee_fields`` array can contain any number of dynamic keys! + Depending on the exhibitors permission and event configuration this might be empty, or contain lots of details. + The app should dynamically show these values (read-only) with the labels sent by the server. + + + **Example request**: + + .. sourcecode:: http + + GET /exhibitors/api/v1/vouchers/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "code": "43K6LKM37FBVR2YG", + "max_usages": 1, + "redeemed": 0, + "valid_until": null, + "subevent": null, + "tag": "testvoucher", + "comment": "", + "items": [ + "All" + ], + "price_mode": "set", + "value": "12.00", + "redemptions": [ + { + "attendee_fields": [ + { + "id": "attendee_name", + "label": "Name", + "value": "Jon Doe", + "details": [ + {"label": "Given name", "value": "John"}, + {"label": "Family name", "value": "Doe"}, + ] + }, + { + "id": "attendee_email", + "label": "Email", + "value": "test@example.com", + "details": [] + } + ], + "redemption_date": "2026-05-06", + "subevent": null + }, + ] + } + ] + } + + :statuscode 200: No error + :statuscode 401: Invalid authentication code + :statuscode 403: Not permitted to access bulk data + +.. http:get:: /exhibitors/api/v1/vouchers/(id)/ + + Returns the details of a single, specific voucher connected to the exhibitor. + + Note that the ``attendee_fields`` array can contain any number of dynamic keys! + Depending on the exhibitors permission and event configuration this might be empty, or contain lots of details. + The app should dynamically show these values (read-only) with the labels sent by the server. + + + **Example request**: + + .. sourcecode:: http + + GET /exhibitors/api/v1/vouchers/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "code": "43K6LKM37FBVR2YG", + "max_usages": 1, + "redeemed": 0, + "valid_until": null, + "subevent": null, + "tag": "testvoucher", + "comment": "", + "items": [ + "All" + ], + "price_mode": "set", + "value": "12.00", + "redemptions": [ + { + "attendee_fields": [ + { + "id": "attendee_name", + "label": "Name", + "value": "Jon Doe", + "details": [ + {"label": "Given name", "value": "John"}, + {"label": "Family name", "value": "Doe"}, + ] + }, + { + "id": "attendee_email", + "label": "Email", + "value": "test@example.com", + "details": [] + } + ], + "redemption_date": "2026-05-06", + "subevent": null + }, + ] + } + + :param id: The ``id`` field of the voucher to fetch + :statuscode 200: No error + :statuscode 401: Invalid authentication code + :statuscode 403: Not permitted to access bulk data + :statuscode 404: Voucher not found in system \ No newline at end of file diff --git a/doc/api/resources/item_program_times.rst b/doc/api/resources/item_program_times.rst index db8a6d3368..19a1885183 100644 --- a/doc/api/resources/item_program_times.rst +++ b/doc/api/resources/item_program_times.rst @@ -16,6 +16,7 @@ Field Type Description id integer Internal ID of the program time start datetime The start date time for this program time slot. end datetime The end date time for this program time slot. +location multi-lingual string The program time slot's location (or ``null``) ===================================== ========================== ======================================================= .. versionchanged:: TODO @@ -54,17 +55,20 @@ Endpoints { "id": 2, "start": "2025-08-14T22:00:00Z", - "end": "2025-08-15T00:00:00Z" + "end": "2025-08-15T00:00:00Z", + "location": null }, { "id": 3, "start": "2025-08-12T22:00:00Z", - "end": "2025-08-13T22:00:00Z" + "end": "2025-08-13T22:00:00Z", + "location": null }, { "id": 14, "start": "2025-08-15T22:00:00Z", - "end": "2025-08-17T22:00:00Z" + "end": "2025-08-17T22:00:00Z", + "location": null } ] } @@ -99,7 +103,8 @@ Endpoints { "id": 1, "start": "2025-08-15T22:00:00Z", - "end": "2025-10-27T23:00:00Z" + "end": "2025-10-27T23:00:00Z", + "location": null } :param organizer: The ``slug`` field of the organizer to fetch @@ -125,7 +130,8 @@ Endpoints { "start": "2025-08-15T10:00:00Z", - "end": "2025-08-15T22:00:00Z" + "end": "2025-08-15T22:00:00Z", + "location": null } **Example response**: @@ -139,7 +145,8 @@ Endpoints { "id": 17, "start": "2025-08-15T10:00:00Z", - "end": "2025-08-15T22:00:00Z" + "end": "2025-08-15T22:00:00Z", + "location": null } :param organizer: The ``slug`` field of the organizer of the event/item to create a program time for diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index a8a5a14822..ed81af0fed 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -131,7 +131,7 @@ allow_waitinglist boolean If ``false``, product when it is sold out. issue_giftcard boolean If ``true``, buying this product will yield a gift card. media_policy string Policy on how to handle reusable media (experimental feature). - Possible values are ``null``, ``"new"``, ``"reuse"``, and ``"reuse_or_new"``. + Possible values are ``null``, ``"new"``, ``"reuse"``, ``"reuse_or_new"``, ``"append"``, and ``"append_or_new"``. media_type string Type of reusable media to work on (experimental feature). See :ref:`rest-reusablemedia` for possible choices. show_quota_left boolean Publicly show how many tickets are still available. If this is ``null``, the event default is used. diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index fe98d93e98..8f0ff758a0 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -1069,7 +1069,7 @@ Creating orders * ``valid_from`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product) * ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product) * ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected) - * ``use_reusable_medium`` (optional, causes the new ticket to take over the given reusable medium, identified by its ID) + * ``use_reusable_medium`` (optional, causes the new ticket to be connected to the given reusable medium, identified by its ID) * ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run) * ``answers`` diff --git a/doc/api/resources/reusablemedia.rst b/doc/api/resources/reusablemedia.rst index e0618a5b41..666a4f2c40 100644 --- a/doc/api/resources/reusablemedia.rst +++ b/doc/api/resources/reusablemedia.rst @@ -21,12 +21,16 @@ id integer Internal ID of type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``. organizer string Organizer slug of the organizer who "owns" this medium. identifier string Unique identifier of the medium. The format depends on the ``type``. +claim_token string Secret token to claim ownership of the medium (or ``null``) +label string Label to identify the medium, usually something human readable (or ``null``) active boolean Whether this medium may be used. created datetime Date of creation updated datetime Date of last modification expires datetime Expiry date (or ``null``) customer string Identifier of a customer account this medium belongs to. -linked_orderposition integer Internal ID of a ticket this medium is linked to. +linked_orderpositions list of integers Internal IDs of tickets this medium is linked to. +linked_orderposition integer **DEPRECATED.** ID of the ticket the medium is linked to, if it is linked to + only one ticket. ``null``, if the medium is linked to none or multiple tickets. linked_giftcard integer Internal ID of a gift card this medium is linked to. info object Additional data, content depends on the ``type``. Consider this internal to the system and don't use it for your own data. @@ -39,6 +43,14 @@ Existing media types are: - ``nfc_uid`` - ``nfc_mf0aes`` + +.. versionchanged:: 2026.5 + + The ``claim_token``, ``label``, ``linked_orderpositions`` attributes have been added, the ``linked_orderposition`` attribute has been + deprecated. Note: To maintain backwards compatibility ``linked_orderposition`` contains the internal ID of the linked order position + if the medium has exactly one order position in ``linked_orderpositions``. + + Endpoints --------- @@ -77,6 +89,7 @@ Endpoints "active": True, "expires": None, "customer": None, + "linked_orderpositions": [], "linked_orderposition": None, "linked_giftcard": None, "notes": None, @@ -92,10 +105,13 @@ Endpoints :query string customer: Only show media linked to the given customer. :query string created_since: Only show media created since a given date. :query string updated_since: Only show media updated since a given date. + :query integer linked_orderpositions: Only show media linked to the given tickets. Note: you can pass multiple ticket IDs by passing + ``linked_orderpositions`` multiple times. Any medium matching any linked orderposition will be returned. :query integer linked_orderposition: Only show media linked to the given ticket. :query integer linked_giftcard: Only show media linked to the given gift card. - :query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``, - or ``"customer"``, the respective field will be shown as a nested value instead of just an ID. + :query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderpositions"``, + ``"linked_orderposition"`` (**DEPRECATED**), or ``"customer"``, the respective field will be shown + as a nested value instead of just an ID. The nested objects are identical to the respective resources, except that order positions will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter can be given multiple times. @@ -134,6 +150,7 @@ Endpoints "active": True, "expires": None, "customer": None, + "linked_orderpositions": [], "linked_orderposition": None, "linked_giftcard": None, "notes": None, @@ -191,6 +208,7 @@ Endpoints "active": True, "expires": None, "customer": None, + "linked_orderpositions": [], "linked_orderposition": None, "linked_giftcard": None, "notes": None, @@ -198,9 +216,9 @@ Endpoints } :param organizer: The ``slug`` field of the organizer to look up a medium for - :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective + :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective field will be shown as a nested value instead of just an ID. The nested objects are identical to - the respective resources, except that the ``linked_orderposition`` will have an attribute of the + the respective resources, except that the ``linked_orderpositions`` each will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter can be given multiple times. :statuscode 201: no error @@ -227,6 +245,7 @@ Endpoints "active": True, "expires": None, "customer": None, + "linked_orderpositions": [], "linked_orderposition": None, "linked_giftcard": None, "notes": None, @@ -251,6 +270,7 @@ Endpoints "active": True, "expires": None, "customer": None, + "linked_orderpositions": [], "linked_orderposition": None, "linked_giftcard": None, "notes": None, @@ -258,7 +278,7 @@ Endpoints } :param organizer: The ``slug`` field of the organizer to create a medium for - :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective + :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective field will be shown as a nested value instead of just an ID. The nested objects are identical to the respective resources, except that the ``linked_orderposition`` will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter @@ -287,7 +307,7 @@ Endpoints Content-Length: 94 { - "linked_orderposition": 13 + "linked_orderpositions": [13, 29] } **Example response**: @@ -308,7 +328,8 @@ Endpoints "active": True, "expires": None, "customer": None, - "linked_orderposition": 13, + "linked_orderpositions": [13, 29], + "linked_orderposition": None, "linked_giftcard": None, "notes": None, "info": {} @@ -316,7 +337,7 @@ Endpoints :param organizer: The ``slug`` field of the organizer to modify :param id: The ``id`` field of the medium to modify - :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective + :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective field will be shown as a nested value instead of just an ID. The nested objects are identical to the respective resources, except that the ``linked_orderposition`` will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter diff --git a/doc/api/resources/webhooks.rst b/doc/api/resources/webhooks.rst index 8568d50679..465582b046 100644 --- a/doc/api/resources/webhooks.rst +++ b/doc/api/resources/webhooks.rst @@ -70,6 +70,7 @@ The following values for ``action_types`` are valid with pretix core: * ``pretix.subevent.changed`` * ``pretix.subevent.deleted`` * ``pretix.event.item.*`` + * ``pretix.event.quota.*`` * ``pretix.event.live.activated`` * ``pretix.event.live.deactivated`` * ``pretix.event.testmode.activated`` diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index f565da5c1a..37d6ed2248 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -64,8 +64,8 @@ Backend .. automodule:: pretix.control.signals :members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings, - order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms, - item_formsets, order_search_filter_q, order_search_forms + order_info, order_approve_info, event_settings_widget, oauth_application_registered, + order_position_buttons, subevent_forms, item_formsets, order_search_filter_q, order_search_forms, subevent_detail_html .. automodule:: pretix.base.signals :no-index: diff --git a/doc/development/contribution/ai.rst b/doc/development/contribution/ai.rst new file mode 100644 index 0000000000..eb9f22d22f --- /dev/null +++ b/doc/development/contribution/ai.rst @@ -0,0 +1,24 @@ +.. _`aipolicy`: + +AI-assisted contribution policy +=============================== + +pretix is maintained by humans. +Every discussion, issue, and pull request is read and reviewed by humans (and sometimes machines, too). +We ask you to respect the time and effort put in by these humans by not sending low-effort, unqualified work, since it puts the burden of validation on the maintainer. + +Therefore, the pretix project has strict rules for AI usage: + +- **All AI usage in any form must be disclosed.** You must state the tool you used (e.g. Claude Code, Cursor, Amp) along with the extent that the work was AI-assisted. + +- **The human-in-the-loop must fully understand all code.** If you can't explain what your changes do and how they interact with the greater system without the aid of AI tools, do not contribute to this project. + +- **Issues and discussions can use AI assistance but must have a full human-in-the-loop.** This means that any content generated with AI must have been reviewed and edited by a human before submission. AI is very good at being overly verbose and including noise that distracts from the main point. Humans must do their research and trim this down. + +- **No AI-generated media is allowed (art, images, videos, audio, etc.).** Text and code are the only acceptable AI-generated content, per the other rules in this policy. + +- **Bad AI drivers will be excluded from the project.** People who produce bad contributions that are clearly AI (slop) will be blocked from our organization without warning. + +This policy was inspired by the `ghostty project`_. + +.. _ghostty project: https://github.com/ghostty-org/ghostty/blob/main/AI_POLICY.md \ No newline at end of file diff --git a/doc/development/contribution/general.rst b/doc/development/contribution/general.rst index 50024e2397..c6c535d3b6 100644 --- a/doc/development/contribution/general.rst +++ b/doc/development/contribution/general.rst @@ -1,23 +1,39 @@ -General remarks -=============== +Contribution workflow +===================== You are interested in contributing to pretix? That is awesome! If you’re new to contributing to open source software, don’t be afraid. We’ll happily review your code and give you -constructive and friendly feedback on your changes. +constructive and friendly feedback on your changes. Every contribution should go through the following steps. -First of all, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this. +Discussion & Design +------------------- + +pretix is a large and mature project with more of a decade of history and hopefully many more decades to come. +Keeping pretix in good shape over long timeframes is first and foremost a fight against complexity. +With every additional feature, complexity grows, and both features and complexity are hard to remove. + +Even if you are doing the initial work of the contribution, accepting the contribution is not free for us. +Not only will we need to maintain the feature, but every feature adds cost to the maintenance of every other feature it interacts with, and every feature adds effort for users to understand how pretix works. +Therefore, we must carefully select what features we add, based on how well they fit the system in general and of how much use they will be to our larger user base. + +We strongly ask you to **create a discussion on GitHub for every new feature idea** outlining the use case and the proposed implementation design. +Pull requests without prior discussion will likely just be closed. + +For bug fixes and very minor changes, you can skip this step and open a PR right away. + +Development +----------- + +To develop your contribution, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this. If you run into any problems on your way, please do not hesitate to ask us anytime! -Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be -assured that we will not tolerate any form of harassment. +While developing, please have a look at our :ref:`aipolicy` and our guidelines on :ref:`codestyle`. Sending a patch --------------- -If you improved pretix in any way, we'd be very happy if you contribute it -back to the main code base! The easiest way to do so is to `create a pull request`_ -on our `GitHub repository`_. +Once you have a first draft of your changes, please `create a pull request`_ on our `GitHub repository`_. We recommend that you create a feature branch for every issue you work on so the changes can be reviewed individually. @@ -25,14 +41,17 @@ Please use the test suite to check whether your changes break any existing featu the code style checks to confirm you are consistent with pretix's coding style. You'll find instructions on this in the :ref:`checksandtests` section of the development setup guide. -We automatically run the tests and the code style check on every pull request on Travis CI and we won’t +We automatically run the tests and the code style check on every pull request through GitHub Actions and we won’t accept any pull requests without all tests passing. However, if you don't find out *why* they are not passing, just send the pull request and tell us – we'll be glad to help. If you add a new feature, please include appropriate documentation into your patch. If you fix a bug, please include a regression test, i.e. a test that fails without your changes and passes after applying your changes. -Again: If you get stuck, do not hesitate to contact any of us, or Raphael personally at mail@raphaelmichel.de. +Again: If you get stuck, do not hesitate to contact us through GitHub discussions. + +Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be +assured that we will not tolerate any form of harassment. .. _create a pull request: https://help.github.com/articles/creating-a-pull-request/ .. _GitHub repository: https://github.com/pretix/pretix diff --git a/doc/development/contribution/index.rst b/doc/development/contribution/index.rst index 7f698eac6c..f8a1d65f37 100644 --- a/doc/development/contribution/index.rst +++ b/doc/development/contribution/index.rst @@ -6,4 +6,5 @@ Contributing to pretix general style + ai codeofconduct diff --git a/doc/development/contribution/style.rst b/doc/development/contribution/style.rst index e91bb48d06..d6065a019e 100644 --- a/doc/development/contribution/style.rst +++ b/doc/development/contribution/style.rst @@ -1,5 +1,7 @@ .. spelling:word-list:: Rebase rebasing +.. _`codestyle`: + Coding style and quality ======================== @@ -28,8 +30,6 @@ Code Commits and Pull Requests ------------------------- - - Most commits should start as pull requests, therefore this applies to the titles of pull requests as well since the pull request title will become the commit message on merge. We prefer merging with GitHub's "Squash and merge" feature if the PR contains multiple commits that do not carry value to keep. If there is value in keeping the @@ -86,7 +86,7 @@ individual commits, we use "Rebase and merge" instead. Merge commits should be a .. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/ .. _flake8: https://pypi.python.org/pypi/flake8 .. _Django Coding Style: https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/ -.. _translation: https://docs.djangoproject.com/en/1.11/topics/i18n/translation/ -.. _class-based views: https://docs.djangoproject.com/en/1.11/topics/class-based-views/ +.. _translation: https://docs.djangoproject.com/en/6.0/topics/i18n/translation/ +.. _class-based views: https://docs.djangoproject.com/en/6.0/topics/class-based-views/ .. _pytest-style: https://docs.pytest.org/en/latest/assert.html .. _fixtures: https://docs.pytest.org/en/latest/fixture.html diff --git a/doc/development/setup.rst b/doc/development/setup.rst index 9d34aa4d49..f9988d2585 100644 --- a/doc/development/setup.rst +++ b/doc/development/setup.rst @@ -110,6 +110,56 @@ process:: However, beware that code changes will not auto-reload within Celery. +Running the local development server will also automatically start a vite dev server for all control vue components. + +Run the widget development server +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To locally develop the presale widget you need to start a separate vite dev server using:: + + npm run dev:widget + +You can control the org, event and much more via query parameters like this:: + + http://localhost:5180/?org=testorg&event=testevent + +The following query parameters are supported: + +.. list-table:: + :header-rows: 1 + :widths: 20 20 60 + + * - Parameter + - Default + - Description + * - ``org`` + - ``testorg`` + - Organization slug + * - ``event`` + - ``testevent`` + - Event slug + * - ``host`` + - ``http://localhost:8000`` + - Backend host URL + * - ``type`` + - ``widget`` + - Element type: ``widget`` or ``button`` + * - ``mode`` + - ``dev`` + - ``dev`` loads the Vite dev source, ``prod`` loads the built ``v2.{lang}.js`` + * - ``lang`` + - ``de`` + - Language code for the prod script + * - ``button-text`` + - ``Buy tickets!`` + - Text content for the button (only used when ``type=button``) + +Any other query parameter is passed through as an attribute on the widget/button element. +For example, ``?skip-ssl-check&list-type=calendar&items=123`` adds those attributes directly. + + + + .. _`checksandtests`: Code checks and unit tests diff --git a/package-lock.json b/package-lock.json index 61431fe86d..2ffd60c6b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,7 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "vue": "^3.5.30", - "vue-slicksort": "^2.0.5" + "vue": "^3.5.30" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -27,6 +26,7 @@ "globals": "^17.4.0", "pug": "^3.0.3", "sass-embedded": "^1.98.0", + "smol-toml": "^1.6.1", "stylus": "^0.64.0", "typescript-eslint": "^8.57.0", "vite": "^8.0.0" @@ -93,21 +93,21 @@ "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -116,9 +116,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -370,20 +370,22 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@nodelib/fs.scandir": { @@ -424,20 +426,10 @@ "node": ">= 8" } }, - "node_modules/@oxc-project/runtime": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", - "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", + "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", "dev": true, "license": "MIT", "funding": { @@ -766,9 +758,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", + "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", "cpu": [ "arm64" ], @@ -783,9 +775,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", "cpu": [ "arm64" ], @@ -800,9 +792,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", + "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", "cpu": [ "x64" ], @@ -817,9 +809,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", + "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", "cpu": [ "x64" ], @@ -834,9 +826,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", + "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", "cpu": [ "arm" ], @@ -851,9 +843,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", + "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", "cpu": [ "arm64" ], @@ -868,9 +860,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", + "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", "cpu": [ "arm64" ], @@ -885,9 +877,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", + "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", "cpu": [ "ppc64" ], @@ -902,9 +894,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", + "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", "cpu": [ "s390x" ], @@ -919,9 +911,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", + "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", "cpu": [ "x64" ], @@ -936,9 +928,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", + "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", "cpu": [ "x64" ], @@ -953,9 +945,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", + "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", "cpu": [ "arm64" ], @@ -970,9 +962,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", + "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", "cpu": [ "wasm32" ], @@ -980,16 +972,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", + "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", "cpu": [ "arm64" ], @@ -1004,9 +998,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", + "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", "cpu": [ "x64" ], @@ -1049,9 +1043,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -2362,9 +2356,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -3111,9 +3105,9 @@ } }, "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -3327,9 +3321,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -3340,9 +3334,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", @@ -3616,14 +3610,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", + "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" + "@oxc-project/types": "=0.129.0", + "@rolldown/pluginutils": "1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3632,27 +3626,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + "@rolldown/binding-android-arm64": "1.0.0", + "@rolldown/binding-darwin-arm64": "1.0.0", + "@rolldown/binding-darwin-x64": "1.0.0", + "@rolldown/binding-freebsd-x64": "1.0.0", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", + "@rolldown/binding-linux-arm64-gnu": "1.0.0", + "@rolldown/binding-linux-arm64-musl": "1.0.0", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0", + "@rolldown/binding-linux-s390x-gnu": "1.0.0", + "@rolldown/binding-linux-x64-gnu": "1.0.0", + "@rolldown/binding-linux-x64-musl": "1.0.0", + "@rolldown/binding-openharmony-arm64": "1.0.0", + "@rolldown/binding-wasm32-wasi": "1.0.0", + "@rolldown/binding-win32-arm64-msvc": "1.0.0", + "@rolldown/binding-win32-x64-msvc": "1.0.0" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", + "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", "dev": true, "license": "MIT" }, @@ -4135,6 +4129,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -4318,14 +4325,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -4458,18 +4465,17 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", - "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "version": "8.0.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", + "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.9", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -4485,8 +4491,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.0.0-alpha.31", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -4611,15 +4617,6 @@ "vue-eslint-parser": "^10.0.0" } }, - "node_modules/vue-slicksort": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/vue-slicksort/-/vue-slicksort-2.0.5.tgz", - "integrity": "sha512-fXz1YrNjhUbJK7o0tMk27mIr4pMAZYLSYvtmLazCtfpvz+zafPCn34ILDL8B7hT7WLVZKreYs6JVe5VWymqmzA==", - "license": "MIT", - "peerDependencies": { - "vue": ">=3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 2335c571a4..c20749c4eb 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,7 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { - "vue": "^3.5.30", - "vue-slicksort": "^2.0.5" + "vue": "^3.5.30" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -45,6 +44,7 @@ "globals": "^17.4.0", "pug": "^3.0.3", "sass-embedded": "^1.98.0", + "smol-toml": "^1.6.1", "stylus": "^0.64.0", "typescript-eslint": "^8.57.0", "vite": "^8.0.0" diff --git a/pyproject.toml b/pyproject.toml index 8ddde880f8..716fd58027 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "pretix" dynamic = ["version"] description = "Reinventing presales, one ticket at a time" readme = "README.rst" -requires-python = ">=3.10" +requires-python = ">=3.11" license = {file = "LICENSE"} keywords = ["tickets", "web", "shop", "ecommerce"] authors = [ @@ -19,30 +19,31 @@ classifiers = [ "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Environment :: Web Environment", "License :: OSI Approved :: GNU Affero General Public License v3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Framework :: Django :: 4.2", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Framework :: Django :: 5.2", ] dependencies = [ - "arabic-reshaper==3.0.0", # Support for Arabic in reportlab + "arabic-reshaper==3.0.1", # Support for Arabic in reportlab "babel", - "BeautifulSoup4==4.14.*", - "bleach==6.3.*", + "BeautifulSoup4==4.15.*", + "bleach==6.4.*", "celery==5.6.*", "chardet==5.2.*", - "cryptography>=44.0.0", + "cryptography>=48.0.1", "css-inline==0.20.*", - "defusedcsv>=1.1.0", + "defusedcsv>=3.0.0", "dnspython==2.*", - "Django[argon2]==4.2.*,>=4.2.26", + "Django[argon2]==5.2.*", "django-bootstrap3==26.1", "django-compressor==4.6.0", "django-countries==8.2.*", "django-filter==25.1", "django-formset-js-improved==0.5.0.5", - "django-formtools==2.5.1", + "django-formtools==2.6.1", "django-hierarkey==2.0.*,>=2.0.1", "django-hijack==3.7.*", "django-i18nfield==1.11.*", @@ -55,11 +56,11 @@ dependencies = [ "django-redis==6.0.*", "django-scopes==2.0.*", "django-statici18n==2.7.*", - "djangorestframework==3.16.*", + "djangorestframework==3.17.*", "dnspython==2.8.*", "drf_ujson2==1.7.*", "geoip2==5.*", - "importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+ + "importlib_metadata==9.*", # Polyfill, we can probably drop this once we require Python 3.10+ "isoweek", "jsonschema", "kombu==5.6.*", @@ -73,11 +74,11 @@ dependencies = [ "packaging", "paypalrestsdk==1.13.*", "paypal-checkout-serversdk==1.0.*", - "PyJWT==2.12.*", + "PyJWT==2.13.*", "phonenumberslite==9.0.*", - "Pillow==12.1.*", + "Pillow==12.2.*", "pretix-plugin-build", - "protobuf==7.34.*", + "protobuf==7.35.*", "psycopg2-binary", "pycountry", "pycparser==3.0", @@ -89,14 +90,14 @@ dependencies = [ "pytz-deprecation-shim==0.1.*", "pyuca", "qrcode==8.2", - "redis==7.1.*", - "reportlab==4.4.*", + "redis==7.4.*", + "reportlab==4.5.*", "requests==2.32.*", - "sentry-sdk==2.54.*", + "sentry-sdk==2.62.*", "sepaxml==2.7.*", "stripe==7.9.*", "text-unidecode==1.*", - "tlds>=2020041600", + "tlds>=2026041800", "tqdm==4.*", "ua-parser==1.0.*", "vobject==0.9.*", @@ -107,16 +108,16 @@ dependencies = [ [project.optional-dependencies] memcached = ["pylibmc"] dev = [ - "aiohttp==3.13.*", + "aiohttp==3.14.*", "coverage", "coveralls", - "fakeredis==2.34.*", + "fakeredis==2.36.*", "flake8==7.3.*", "freezegun", "isort==8.0.*", "pep8-naming==0.15.*", "potypo", - "pytest-asyncio>=0.24", + "pytest-asyncio>=1.4.0", "pytest-cache", "pytest-cov", "pytest-django==4.*", @@ -125,6 +126,7 @@ dev = [ "pytest-xdist==3.8.*", "pytest-playwright", "pytest==9.0.*", + "playwright", "responses", ] @@ -137,8 +139,6 @@ build-backend = "backend" backend-path = ["_build"] requires = [ "setuptools", - "setuptools-rust", - "wheel", "importlib_metadata", "tomli", ] diff --git a/src/pretix/__init__.py b/src/pretix/__init__.py index e8dfc803dc..77eeac95ac 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__ = "2026.3.0.dev0" +__version__ = "2026.6.0.dev0" diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index 18a99336bd..7fc06e9522 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -110,6 +110,8 @@ class PretixScanSecurityProfile(AllowListSecurityProfile): ('POST', 'api-v1:checkinrpc.redeem'), ('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:reusablemedium-list'), + ('POST', 'api-v1:reusablemedium-lookup'), + ('PATCH', 'api-v1:reusablemedium-detail') ) diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index 63759c0160..db716a3154 100644 --- a/src/pretix/api/serializers/checkin.py +++ b/src/pretix/api/serializers/checkin.py @@ -88,11 +88,19 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer): nonce = serializers.CharField(required=False, allow_null=True) datetime = serializers.DateTimeField(required=False, allow_null=True) answers = serializers.JSONField(required=False, allow_null=True) + exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES) + exchange_medium_identifier = serializers.CharField(required=False) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event') + def validate(self, attrs): + exchange_fields = ["exchange_medium_type", "exchange_medium_identifier"] + if any(attrs.get(k) is None for k in exchange_fields) and not all(attrs.get(k) is None for k in exchange_fields): + raise ValidationError("If you set any of exchange_medium_type or exchange_medium_identifier, you need to set both of them.") + return attrs + class MiniCheckinListSerializer(I18nAwareModelSerializer): event = serializers.SlugRelatedField(slug_field='slug', read_only=True) diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 009f94e926..8a2b3a8a19 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -871,6 +871,7 @@ class EventSettingsSerializer(SettingsSerializer): 'og_image', 'name_scheme', 'reusable_media_active', + 'reusable_media_usage_enforced', 'reusable_media_type_barcode', 'reusable_media_type_barcode_identifier_length', 'reusable_media_type_nfc_uid', @@ -885,6 +886,7 @@ class EventSettingsSerializer(SettingsSerializer): readonly_fields = [ # These are read-only since they are currently only settable on organizers, not events 'reusable_media_active', + 'reusable_media_usage_enforced', 'reusable_media_type_barcode', 'reusable_media_type_barcode_identifier_length', 'reusable_media_type_nfc_uid', @@ -970,6 +972,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer): 'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_mf0aes', 'reusable_media_type_nfc_mf0aes_random_uid', + 'reusable_media_usage_enforced', 'system_question_order', 'tax_rule_payment', 'tax_rule_cancellation', diff --git a/src/pretix/api/serializers/exporters.py b/src/pretix/api/serializers/exporters.py index f541f758a1..ff1bb6464c 100644 --- a/src/pretix/api/serializers/exporters.py +++ b/src/pretix/api/serializers/exporters.py @@ -133,37 +133,43 @@ class JobRunSerializer(serializers.Serializer): return not bool(self._errors) +class ExportFormDataField(serializers.Field): + def get_attribute(self, instance): + return (instance.export_identifier, instance.export_form_data) + + def to_representation(self, value): + export_identifier, export_form_data = value + exporter = self.context['exporters'].get(export_identifier) + if exporter: + return JobRunSerializer(exporter=exporter).to_representation(export_form_data) + else: + return export_form_data + + def get_value(self, dictionary): + return dictionary + + def to_internal_value(self, data): + if "export_form_data" in data: + identifier = data.get('export_identifier', self.parent.instance.export_identifier if self.parent.instance else None) + exporter = self.context['exporters'].get(identifier) + if exporter: + return JobRunSerializer(exporter=exporter).to_internal_value(data["export_form_data"]) + else: + return data['export_form_data'] + + class ScheduledExportSerializer(serializers.ModelSerializer): schedule_next_run = serializers.DateTimeField(read_only=True) export_identifier = serializers.ChoiceField(choices=[]) locale = serializers.ChoiceField(choices=settings.LANGUAGES, default='en') owner = serializers.SlugRelatedField(slug_field='email', read_only=True) error_counter = serializers.IntegerField(read_only=True) + export_form_data = ExportFormDataField() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['export_identifier'].choices = [(e, e) for e in self.context['exporters']] - def validate(self, attrs): - if attrs.get("export_form_data"): - identifier = attrs.get('export_identifier', self.instance.export_identifier if self.instance else None) - exporter = self.context['exporters'].get(identifier) - if exporter: - try: - attrs["export_form_data"] = JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"]) - except ValidationError as e: - raise ValidationError({"export_form_data": e.detail}) - else: - raise ValidationError({"export_identifier": ["Unknown exporter."]}) - return attrs - - def to_representation(self, instance): - repr = super().to_representation(instance) - exporter = self.context['exporters'].get(instance.export_identifier) - if exporter: - repr["export_form_data"] = JobRunSerializer(exporter=exporter).to_representation(repr["export_form_data"]) - return repr - def validate_mail_additional_recipients(self, value): d = value.replace(' ', '') if len(d.split(',')) > 25: diff --git a/src/pretix/api/serializers/fields.py b/src/pretix/api/serializers/fields.py index d3cc5f1b8a..d877028ab7 100644 --- a/src/pretix/api/serializers/fields.py +++ b/src/pretix/api/serializers/fields.py @@ -115,10 +115,10 @@ class PluginsField(serializers.Field): def to_representation(self, obj): from pretix.base.plugins import get_all_plugins - + active_plugins = set(obj.get_plugins()) return sorted([ p.module for p in get_all_plugins() - if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins() + if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in active_plugins ]) def to_internal_value(self, data): diff --git a/src/pretix/api/serializers/forms.py b/src/pretix/api/serializers/forms.py index 00459016c6..08ec53a5b1 100644 --- a/src/pretix/api/serializers/forms.py +++ b/src/pretix/api/serializers/forms.py @@ -45,6 +45,12 @@ class PrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField): return value return super().to_representation(value) + def to_internal_value(self, data): + value = super().to_internal_value(data) + if value is not None: + return value.pk + return value + class FormFieldWrapperField(serializers.Field): def __init__(self, *args, **kwargs): diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 7bfee14929..051da8667b 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -192,7 +192,7 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer): class InlineItemProgramTimeSerializer(serializers.ModelSerializer): class Meta: model = ItemProgramTime - fields = ('start', 'end') + fields = ('start', 'end', 'location') class ItemBundleSerializer(serializers.ModelSerializer): @@ -223,7 +223,7 @@ class ItemBundleSerializer(serializers.ModelSerializer): class ItemProgramTimeSerializer(serializers.ModelSerializer): class Meta: model = ItemProgramTime - fields = ('id', 'start', 'end') + fields = ('id', 'start', 'end', 'location') def validate(self, data): data = super().validate(data) diff --git a/src/pretix/api/serializers/media.py b/src/pretix/api/serializers/media.py index 894c1bcf0b..cce6e86183 100644 --- a/src/pretix/api/serializers/media.py +++ b/src/pretix/api/serializers/media.py @@ -31,7 +31,9 @@ from pretix.api.serializers.order import OrderPositionSerializer from pretix.api.serializers.organizer import ( CustomerSerializer, GiftCardSerializer, ) -from pretix.base.models import Order, OrderPosition, ReusableMedium +from pretix.base.models import ( + Device, Order, OrderPosition, ReusableMedium, TeamAPIToken, +) logger = logging.getLogger(__name__) @@ -64,13 +66,14 @@ class ReusableMediaSerializer(I18nAwareModelSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + expand_nested = self.context['request'].query_params.getlist('expand') - if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'): + if 'linked_giftcard' in expand_nested: if not self.context["can_read_giftcards"]: raise PermissionDenied("No permission to access gift card details.") self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context) - if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'): + if 'linked_giftcard.owner_ticket' in expand_nested: self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context) else: self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField( @@ -79,18 +82,27 @@ class ReusableMediaSerializer(I18nAwareModelSerializer): queryset=self.context['organizer'].issued_gift_cards.all() ) - if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'): - # No additional permission check performed, documented limitation of the permission system - # Would get to complex/unusable otherwise since the permission depends on the event - self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True) + # keep linked_orderposition (singular) for backwards compatibility, will be overwritten in self.validate + self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField( + required=False, + allow_null=True, + queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']), + ) + + if 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested: + self.fields['linked_orderpositions'] = NestedOrderPositionSerializer( + many=True, + read_only=True + ) else: - self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField( + self.fields['linked_orderpositions'] = serializers.PrimaryKeyRelatedField( + many=True, required=False, allow_null=True, queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']), ) - if 'customer' in self.context['request'].query_params.getlist('expand'): + if 'customer' in expand_nested: if not self.context["can_read_customers"]: raise PermissionDenied("No permission to access customer details.") @@ -105,6 +117,21 @@ class ReusableMediaSerializer(I18nAwareModelSerializer): def validate(self, data): data = super().validate(data) + if 'linked_orderposition' in data: + linked_orderposition = data['linked_orderposition'] + # backwards-compatibility + if 'linked_orderpositions' in data: + raise ValidationError({ + 'linked_orderposition': 'You cannot use linked_orderposition and linked_orderpositions at the same time.' + }) + if self.instance and self.instance.linked_orderpositions.count() > 1: + raise ValidationError({ + 'linked_orderposition': 'There are more than one linked_orderposition. You need to use linked_orderpositions.' + }) + + data['linked_orderpositions'] = [linked_orderposition] if linked_orderposition else [] + del data['linked_orderposition'] + if 'type' in data and 'identifier' in data: qs = self.context['organizer'].reusable_media.filter( identifier=data['identifier'], type=data['type'] @@ -117,6 +144,41 @@ class ReusableMediaSerializer(I18nAwareModelSerializer): ) return data + def to_representation(self, instance): + r = super().to_representation(instance) + request = self.context.get('request') + + ops = r.get('linked_orderpositions', []) + # late permission evaluations for checks that depend on the actual linked events + expand_nested = self.context['request'].query_params.getlist('expand') + perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user + if ops and 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested: + ops_noperm = [] + for lop in instance.linked_orderpositions.all(): + event = lop.order.event + if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request): + ops_noperm.append(lop.id) + if ops_noperm: + ops = [ + {'id': op['id']} if op['id'] in ops_noperm + else op + for op in ops + ] + r['linked_orderpositions'] = ops + + # add linked_orderposition (singular) for backwards compatibility + if len(ops) < 2: + r['linked_orderposition'] = ops[0] if ops else None + + if 'linked_giftcard.owner_ticket' in expand_nested: + gc = instance.linked_giftcard + if gc is not None and gc.owner_ticket is not None: + event = gc.owner_ticket.order.event + if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request): + r['linked_giftcard']['owner_ticket'] = {'id': instance.linked_giftcard.owner_ticket.id} + + return r + class Meta: model = ReusableMedium fields = ( @@ -126,10 +188,12 @@ class ReusableMediaSerializer(I18nAwareModelSerializer): 'updated', 'type', 'identifier', + 'claim_token', + 'label', 'active', 'expires', 'customer', - 'linked_orderposition', + 'linked_orderpositions', 'linked_giftcard', 'info', 'notes', diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 6b83f832bc..7ce4c567d4 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -769,7 +769,11 @@ class PaymentDetailsField(serializers.Field): pp = value.payment_provider if not pp: return {} - return pp.api_payment_details(value) + try: + return pp.api_payment_details(value) + except Exception: + logger.exception("Failed to retrieve payment_details") + return {} class OrderPaymentSerializer(I18nAwareModelSerializer): @@ -1145,6 +1149,7 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): raise ValidationError( {'discount': ['You can only specify a discount if you do the price computation, but price is not set.']} ) + return data @@ -1412,6 +1417,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): qa = QuotaAvailability() qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0]) qa.compute() + v_avail = {} # These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental # use further down @@ -1441,11 +1447,13 @@ class OrderCreateSerializer(I18nAwareModelSerializer): voucher_usage[v] += 1 if voucher_usage[v] > 0: - redeemed_in_carts = CartPosition.objects.filter( - Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt) - ).exclude(pk__in=[cp.pk for cp in delete_cps]) - v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count() - if v_avail < voucher_usage[v]: + if v not in v_avail: + v.refresh_from_db(fields=['redeemed']) + redeemed_in_carts = CartPosition.objects.filter( + Q(voucher=v) & Q(event=self.context['event']) & Q(expires__gte=now_dt) + ).exclude(pk__in=[cp.pk for cp in delete_cps]) + v_avail[v] = v.max_usages - v.redeemed - redeemed_in_carts.count() + if v_avail[v] < voucher_usage[v]: errs[i]['voucher'] = [ 'The voucher has already been used the maximum number of times.' ] @@ -1581,7 +1589,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): pos_data['attendee_name_parts'] = { '_legacy': attendee_name } - pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'}) + pos = OrderPosition(**{k: v for k, v in pos_data.items() if k not in ('answers', '_quotas', 'use_reusable_medium')}) if simulate: pos.order = order._wrapped else: @@ -1696,15 +1704,25 @@ class OrderCreateSerializer(I18nAwareModelSerializer): answ.options.add(*options) if use_reusable_medium: - use_reusable_medium.linked_orderposition = pos - use_reusable_medium.save(update_fields=['linked_orderposition']) + if pos.item.media_policy not in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW): + for op_pk in use_reusable_medium.linked_orderpositions.values_list('pk', flat=True): + use_reusable_medium.log_action( + 'pretix.reusable_medium.linked_orderposition.removed', + data={ + 'linked_orderposition': op_pk, + } + ) + use_reusable_medium.linked_orderpositions.set([pos]) + else: + use_reusable_medium.linked_orderpositions.add(pos) use_reusable_medium.log_action( - 'pretix.reusable_medium.linked_orderposition.changed', + 'pretix.reusable_medium.linked_orderposition.added', data={ 'by_order': order.code, 'linked_orderposition': pos.pk, } ) + use_reusable_medium.touch() if not simulate: for cp in delete_cps: diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index 05f25976a8..9d595f7624 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -286,6 +286,19 @@ class GiftCardSerializer(I18nAwareModelSerializer): ) return data + def to_representation(self, instance): + r = super().to_representation(instance) + request = self.context.get('request') + # late permission evaluations for checks that depend on the actual linked events + if 'owner_ticket' in self.context['request'].query_params.getlist('expand'): + owner_ticket = instance.owner_ticket + if owner_ticket: + event = owner_ticket.order.event + perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user + if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request): + r['owner_ticket'] = {'id': instance.owner_ticket.id} + return r + class Meta: model = GiftCard fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket', @@ -592,6 +605,7 @@ class OrganizerSettingsSerializer(SettingsSerializer): 'cookie_consent_dialog_button_yes', 'cookie_consent_dialog_button_no', 'reusable_media_active', + 'reusable_media_usage_enforced', 'reusable_media_type_barcode', 'reusable_media_type_barcode_identifier_length', 'reusable_media_type_nfc_uid', diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 4130229193..5016d54f9a 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -69,8 +69,10 @@ from pretix.base.models import ( from pretix.base.models.orders import PrintLog from pretix.base.permissions import AnyPermissionOf from pretix.base.services.checkin import ( - CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, + CheckInError, RequiredMediaExchangeError, RequiredQuestionsError, SQLLogic, + perform_checkin, ) +from pretix.base.services.media import perform_media_exchange from pretix.base.signals import checkin_annulled from pretix.helpers import OF_SELF @@ -454,7 +456,8 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce, untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported, - source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False): + source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False, + exchange_medium_type=None, exchange_medium_identifier=None): if not checkinlists: raise ValidationError('No check-in list passed.') @@ -463,6 +466,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, device = auth if isinstance(auth, Device) else None gate = gate or (auth.gate if isinstance(auth, Device) else None) + medium = None context = { 'request': request, @@ -491,6 +495,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, ) raw_barcode_for_checkin = None from_revoked_secret = False + reusable_medium_used = None if simulate: common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True @@ -521,11 +526,12 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, # with respecting the force option), or it's a reusable medium (-> proceed with that) if not op_candidates: try: - media = ReusableMedium.objects.select_related('linked_orderposition').active().get( + medium = ReusableMedium.objects.active().filter( + Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk'))) + ).get( organizer_id=checkinlists[0].event.organizer_id, type=source_type, identifier=raw_barcode, - linked_orderposition__isnull=False, ) raw_barcode_for_checkin = raw_barcode except ReusableMedium.DoesNotExist: @@ -628,7 +634,9 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, 'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data, }, status=400) else: - if media.linked_orderposition.order.event_id not in list_by_event: + linked_ops = medium.linked_orderpositions.all().select_related("order").prefetch_related("addons") + linked_event_ids = {op.order.event_id for op in linked_ops} + if not any(event_id in list_by_event for event_id in linked_event_ids): # Medium exists but connected ticket is for the wrong event if not simulate: checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={ @@ -654,28 +662,91 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, 'checkin_texts': [], 'list': MiniCheckinListSerializer(checkinlists[0]).data, }, status=404) - op_candidates = [media.linked_orderposition] - if list_by_event[media.linked_orderposition.order.event_id].addon_match: - op_candidates += list(media.linked_orderposition.addons.all()) + op_candidates = [] + for op in linked_ops: + if op.order.event_id in list_by_event: + reusable_medium_used = medium + op_candidates.append(op) + if list_by_event[op.order.event_id].addon_match: + op_candidates += list(op.addons.all()) # 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary - # key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out - # which add-on has the right product. + # key on the same list, we're probably dealing with multiple linked_orderpositions or the ``addon_match`` case + # here and need to figure out which op has the right product. This basically is a valid-for-checkin-test on every op. if len(op_candidates) > 1: - op_candidates_matching_product = [ - op for op in op_candidates - if ( - (list_by_event[op.order.event_id].addon_match or op.secret == raw_barcode or legacy_url_support) and - (list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()}) - ) - ] - if len(op_candidates_matching_product) == 0: - # None of the found add-ons has the correct product, too bad! We could just error out here, but + if not reusable_medium_used: + # 3a. First, we clean up that we made an imprecise query above. If a scan is made for multiple check-in lists, + # we have queried ``addon_to__secret=raw_barcode``, even if some of the lists in question do not allow addon + # matching. So we accept all candidates that match one of these cases: + # - Exactly the ticket secret we scanned (because that's always a possible result) + # - Exactly the ticket pk we scanned (on legacy endpoints) + # - An add-on on a list that allows add-on matching + # This is not necessary when a reusable media was used, since in that case we already obeyed list.addon_match + # correctly above. + op_candidates_filtered = [ + op for op in op_candidates + if ( + op.secret == raw_barcode or + list_by_event[op.order.event_id].addon_match or + (str(op.pk) == raw_barcode and legacy_url_support and not untrusted_input) + ) + ] + else: + op_candidates_filtered = op_candidates + + if len(op_candidates_filtered) > 1: + # 3b. If we still have multiple candidates, we filter by product based on the check-in list configuration. + # This is relevant for the addon_match scenario where the scanned ticket has multiple add-ons, but only + # one is contained in the check-in list used to scan. It makes sense to filter this first, since it is a + # "static" check, i.e. scanning the same QR code on the same check-in list will always do the same, no matter + # when I scan it, and it is "intentional" filtering in the sense that the admin configured this behaviour + # into the check-in list. + op_candidates_filtered = [ + op for op in op_candidates_filtered + if list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()} + ] + + if len(op_candidates_filtered) > 1: + # 3c. If we still have multiple candidates, we filter by validity date. This was introduced for the case where + # a reusable media refers to two tickets, one currently valid and one expired or in the future. Howeer, + # it could in theory also happen with two add-ons being on the same check-in list but without overlapping + # validity. It makes sense to filter this "after" the previous checks since it is not "intentional" filtering + # configured by the admin but "accidental" filtering that depends on the time of execution. + op_candidates_filtered = [ + op for op in op_candidates_filtered + if ( + (not op.valid_from or op.valid_from <= datetime) and + (not op.valid_until or op.valid_until > datetime) + ) + ] + + if len(op_candidates_filtered) == 0: + # None of the ops is valid today or has the correct product, too bad! We could just error out here, but # instead we just continue with *any* product and have it rejected by the check in perform_checkin. - # This has the advantage of a better error message. - op_candidates = [op_candidates[0]] - elif len(op_candidates_matching_product) > 1: + # To improve the error message, we select the op that will "work next" or - if none matches - "worked last". + op_candidate = None + for op in op_candidates: + if ( + op.valid_from and op.valid_from > datetime and + (not op_candidate or op.valid_from < op_candidate.valid_from) + ): + op_candidate = op + + if not op_candidate: + # no candidate in the future, get closest in the past + for op in op_candidates: + if ( + op.valid_until and op.valid_until < datetime and + (not op_candidate or op.valid_until > op_candidate.valid_until) + ): + op_candidate = op + + if not op_candidate: + op_candidate = op_candidates[0] + + op_candidates = [op_candidate] + elif len(op_candidates_filtered) > 1: # It's still ambiguous, we'll error out. # We choose the first match (regardless of product) for the logging since it's most likely to be the # base product according to our order_by above. @@ -709,7 +780,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, }, status=400) else: - op_candidates = op_candidates_matching_product + op_candidates = op_candidates_filtered op = op_candidates[0] common_checkin_args['list'] = list_by_event[op.order.event_id] @@ -721,7 +792,10 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, if str(q.pk) in answers_data: try: if q.type == Question.TYPE_FILE: - given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth) + if answers_data[str(q.pk)]: + given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth) + else: + given_answers[q] = None else: given_answers[q] = q.clean_answer(answers_data[str(q.pk)]) except (ValidationError, BaseValidationError): @@ -734,7 +808,14 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, locale = op.order.event.settings.locale with language(locale): try: - perform_checkin( + if exchange_medium_identifier and medium: + # Cannot scan a medium and then request to exchange it + raise CheckInError( + gettext('You cannot exchange a medium for a medium.'), + 'error' + ) + + checkin_args = dict( op=op, clist=list_by_event[op.order.event_id], given_answers=given_answers, @@ -752,7 +833,25 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, from_revoked_secret=from_revoked_secret, simulate=simulate, gate=gate, + reusable_medium=medium, ) + + if exchange_medium_identifier: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate + with transaction.atomic(): + # Do exchange and check-in atomically, i.e. both succeed or both fail + medium = perform_media_exchange( + organizer=request.organizer, + media_type=exchange_medium_type, + identifier=exchange_medium_identifier, + link_orderposition=op, + user=user, + auth=auth, + ) + source_type = medium.media_type.identifier + checkin_args['reusable_medium'] = medium + perform_checkin(**checkin_args) + else: + perform_checkin(**checkin_args) except RequiredQuestionsError as e: return Response({ 'status': 'incomplete', @@ -764,6 +863,17 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, ], 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, }, status=400) + except RequiredMediaExchangeError as e: + return Response({ + 'status': 'exchange', + 'require_attention': op.require_checkin_attention, + 'checkin_texts': op.checkin_texts, + 'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data, + 'media_policy': e.media_policy, + 'media_type': e.media_type, + 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, + 'reason_explanation': e.msg, + }, status=400) except CheckInError as e: if not simulate: op.order.log_action('pretix.event.checkin.denied', data={ @@ -951,6 +1061,8 @@ class CheckinRPCRedeemView(views.APIView): canceled_supported=True, request=self.request, # this is not clean, but we need it in the serializers for URL generation legacy_url_support=False, + exchange_medium_type=s.validated_data.get('exchange_medium_type'), + exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'), ) @@ -1122,7 +1234,7 @@ class CheckinViewSet(viewsets.ReadOnlyModelViewSet): permission = 'event.orders:read' def get_queryset(self): - qs = Checkin.all.filter().select_related( + qs = Checkin.all.filter(list__event=self.request.event).select_related( "position", "device", ) diff --git a/src/pretix/api/views/media.py b/src/pretix/api/views/media.py index 7b3be781f6..5c20079303 100644 --- a/src/pretix/api/views/media.py +++ b/src/pretix/api/views/media.py @@ -53,10 +53,12 @@ with scopes_disabled(): customer = django_filters.CharFilter(field_name='customer__identifier') updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte') created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte') + # backwards-compatible + linked_orderposition = django_filters.NumberFilter(field_name='linked_orderpositions__id') class Meta: model = ReusableMedium - fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard'] + fields = ['identifier', 'type', 'active', 'customer', 'linked_orderpositions', 'linked_giftcard'] class ReusableMediaViewSet(viewsets.ModelViewSet): @@ -75,7 +77,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet): ).order_by().values('card').annotate(s=Sum('value')).values('s') return self.request.organizer.reusable_media.prefetch_related( Prefetch( - 'linked_orderposition', + 'linked_orderpositions', queryset=OrderPosition.objects.select_related( 'order', 'order__event', 'order__event__organizer', 'seat', ).prefetch_related( @@ -117,14 +119,38 @@ class ReusableMediaViewSet(viewsets.ModelViewSet): @transaction.atomic() def perform_update(self, serializer): - ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk) + rm = ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk) + prev_linked_ops_pks = list(rm.linked_orderpositions.values_list("pk", flat=True)) inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type) - inst.log_action( - 'pretix.reusable_medium.changed', - user=self.request.user, - auth=self.request.auth, - data=self.request.data, - ) + linked_ops_pks = inst.linked_orderpositions.values_list("pk", flat=True) + for op_pk in prev_linked_ops_pks: + if op_pk not in linked_ops_pks: + inst.log_action( + 'pretix.reusable_medium.linked_orderposition.removed', + user=self.request.user, + auth=self.request.auth, + data={ + 'linked_orderposition': op_pk, + } + ) + for op_pk in linked_ops_pks: + if op_pk not in prev_linked_ops_pks: + inst.log_action( + 'pretix.reusable_medium.linked_orderposition.added', + user=self.request.user, + auth=self.request.auth, + data={ + 'linked_orderposition': op_pk, + } + ) + data = {k: v for k, v in self.request.data.items() if k not in ('linked_orderposition', 'linked_orderpositions')} + if data: + inst.log_action( + 'pretix.reusable_medium.changed', + user=self.request.user, + auth=self.request.auth, + data=data, + ) return inst def perform_destroy(self, instance): @@ -157,7 +183,6 @@ class ReusableMediaViewSet(viewsets.ModelViewSet): type=s.validated_data["type"], identifier=s.validated_data["identifier"], ) - m.linked_orderposition = None # not relevant for cross-organizer m.customer = None # not relevant for cross-organizer s = self.get_serializer(m) return Response({"result": s.data}) @@ -171,7 +196,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet): return Response({"result": None}) - @scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce + @scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some performance def list(self, request, **kwargs): date = serializers.DateTimeField().to_representation(now()) queryset = self.filter_queryset(self.get_queryset()) diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 6d5184db81..31a81ed0bb 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -194,7 +194,7 @@ with scopes_disabled(): ) ).values('id') - matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderposition__order_id', flat=True) + matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderpositions__order_id', flat=True) mainq = ( code @@ -381,12 +381,15 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list') return resp else: - resp = FileResponse(ct.file.file, content_type=ct.type) - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format( - self.request.event.slug.upper(), order.code, - provider.identifier, ct.extension + return FileResponse( + ct.file.file, + filename='{}-{}-{}{}'.format( + self.request.event.slug.upper(), order.code, + provider.identifier, ct.extension + ), + as_attachment=True, + content_type=ct.type ) - return resp @action(detail=True, methods=['POST']) def mark_paid(self, request, **kwargs): @@ -1031,7 +1034,7 @@ with scopes_disabled(): search = django_filters.CharFilter(method='search_qs') def search_qs(self, queryset, name, value): - matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderposition', flat=True) + matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderpositions', flat=True) return queryset.filter( Q(secret__istartswith=value) | Q(attendee_name_cached__icontains=value) @@ -1303,14 +1306,17 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet raise NotFound() ftype, ignored = mimetypes.guess_type(answer.file.name) - resp = FileResponse(answer.file, content_type=ftype or 'application/binary') - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format( - self.request.event.slug.upper(), - pos.order.code, - pos.positionid, - os.path.basename(answer.file.name).split('.', 1)[1] + return FileResponse( + answer.file, + filename='{}-{}-{}-{}'.format( + self.request.event.slug.upper(), + pos.order.code, + pos.positionid, + os.path.basename(answer.file.name).split('.', 1)[1] + ), + as_attachment=True, + content_type=ftype or 'application/binary' ) - return resp @action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"]) def printlog(self, request, **kwargs): @@ -1365,15 +1371,18 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet if hasattr(image_file, 'seek'): image_file.seek(0) - resp = FileResponse(image_file, content_type=ftype or 'application/binary') - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format( - self.request.event.slug.upper(), - pos.order.code, - pos.positionid, - key, - extension, + return FileResponse( + image_file, + filename='{}-{}-{}-{}.{}'.format( + self.request.event.slug.upper(), + pos.order.code, + pos.positionid, + key, + extension, + ), + as_attachment=True, + content_type=ftype or 'application/binary' ) - return resp @action(detail=True, url_name='download', url_path='download/(?P[^/]+)') def download(self, request, output, **kwargs): @@ -1399,12 +1408,15 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list') return resp else: - resp = FileResponse(ct.file.file, content_type=ct.type) - resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format( - self.request.event.slug.upper(), pos.order.code, pos.positionid, - provider.identifier, ct.extension + return FileResponse( + ct.file.file, + filename='{}-{}-{}-{}{}'.format( + self.request.event.slug.upper(), pos.order.code, pos.positionid, + provider.identifier, ct.extension + ), + as_attachment=True, + content_type=ct.type ) - return resp @action(detail=True, methods=['POST']) def regenerate_secrets(self, request, **kwargs): @@ -1986,9 +1998,12 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): if not invoice.file: raise RetryException() - resp = FileResponse(invoice.file.file, content_type='application/pdf') - resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number) - return resp + return FileResponse( + invoice.file.file, + filename='{}.pdf'.format(invoice.number), + as_attachment=True, + content_type='application/pdf' + ) @action(detail=True, methods=['POST']) def transmit(self, request, **kwargs): diff --git a/src/pretix/api/webhooks.py b/src/pretix/api/webhooks.py index 39a5a613bd..680807294c 100644 --- a/src/pretix/api/webhooks.py +++ b/src/pretix/api/webhooks.py @@ -408,6 +408,12 @@ def register_default_webhook_events(sender, **kwargs): _('This includes product added or deleted and changes to nested objects like ' 'variations or bundles.'), ), + ParametrizedItemWebhookEvent( + 'pretix.event.quota.*', + _('Quota changed'), + _('This includes related events like creation, deletion, opening or closing of quotas. ' + 'No webhook is sent for changes to the resulting availability.'), + ), ParametrizedEventWebhookEvent( 'pretix.event.live.activated', _('Shop taken live'), diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index 155b296573..b6a61e8a97 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -19,7 +19,10 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import ipaddress import logging +import smtplib +import socket from itertools import groupby from smtplib import SMTPResponseException from typing import TypeVar @@ -237,3 +240,80 @@ def base_renderers(sender, **kwargs): def get_email_context(**kwargs): return PlaceholderContext(**kwargs).render_all() + + +def create_connection(address, timeout=socket.getdefaulttimeout(), + source_address=None, *, all_errors=False): + # Taken from the python stdlib, extended with a check for local ips + + host, port = address + exceptions = [] + for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + + if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False): + ip_addr = ipaddress.ip_address(sa[0]) + if ip_addr.is_multicast: + raise socket.error(f"Request to multicast address {sa[0]} blocked") + if ip_addr.is_loopback or ip_addr.is_link_local: + raise socket.error(f"Request to local address {sa[0]} blocked") + if ip_addr.is_private: + raise socket.error(f"Request to private address {sa[0]} blocked") + + sock = None + try: + sock = socket.socket(af, socktype, proto) + if timeout is not socket.getdefaulttimeout(): + sock.settimeout(timeout) + if source_address: + sock.bind(source_address) + sock.connect(sa) + # Break explicitly a reference cycle + exceptions.clear() + return sock + + except socket.error as exc: + if not all_errors: + exceptions.clear() # raise only the last error + exceptions.append(exc) + if sock is not None: + sock.close() + + if len(exceptions): + try: + if not all_errors: + raise exceptions[0] + raise ExceptionGroup("create_connection failed", exceptions) + finally: + # Break explicitly a reference cycle + exceptions.clear() + else: + raise socket.error("getaddrinfo returns an empty list") + + +class CheckPrivateNetworkMixin: + # _get_socket taken 1:1 from smtplib, just with a call to our own create_connection + def _get_socket(self, host, port, timeout): + # This makes it simpler for SMTP_SSL to use the SMTP connect code + # and just alter the socket connection bit. + if timeout is not None and not timeout: + raise ValueError('Non-blocking socket (timeout=0) is not supported') + if self.debuglevel > 0: + self._print_debug('connect: to', (host, port), self.source_address) + return create_connection((host, port), timeout, self.source_address) + + +class SMTP(CheckPrivateNetworkMixin, smtplib.SMTP): + pass + + +# SMTP used here instead of mixin, because smtp.SMTP_SSL._get_socket calls super()._get_socket and then wraps this socket +# super()._get_socket needs to be our version from the mixin +class SMTP_SSL(smtplib.SMTP_SSL, SMTP): # noqa: N801 + pass + + +class CheckPrivateNetworkSmtpBackend(EmailBackend): + @property + def connection_class(self): + return SMTP_SSL if self.use_ssl else SMTP diff --git a/src/pretix/base/exporter.py b/src/pretix/base/exporter.py index 5182627a60..a89140e0b1 100644 --- a/src/pretix/base/exporter.py +++ b/src/pretix/base/exporter.py @@ -47,6 +47,7 @@ from django.utils.formats import localize from django.utils.translation import gettext, gettext_lazy as _ from pretix.base.models import Event +from pretix.base.models.auth import PermissionHolder from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe SafeWorkbook, remove_invalid_excel_chars as excel_safe, ) @@ -59,11 +60,20 @@ class BaseExporter: This is the base class for all data exporters """ - def __init__(self, event, organizer, progress_callback=lambda v: None): + def __init__(self, event, organizer, permission_holder: PermissionHolder=None, progress_callback=lambda v: None): + """ + :param event: Event context, can also be a queryset of events for multi-event exports + :param organizer: Organizer context + :param user: The user who triggered the export (or None). + :param token: The API token that triggered the export (or None). + :param device: The device that triggered the export (or None) + :param progress_callback: Callback function with progress + """ self.event = event self.organizer = organizer self.progress_callback = progress_callback self.is_multievent = isinstance(event, QuerySet) + self.permission_holder = permission_holder if isinstance(event, QuerySet): self.events = event self.event = None @@ -180,7 +190,7 @@ class BaseExporter: return True @classmethod - def get_required_event_permission(cls) -> str: + def get_required_event_permission(cls) -> Optional[str]: """ The permission level required to use this exporter for events. For multi-event-exports, this will be used to limit the selection of events. Will be ignored if the ``OrganizerLevelExportMixin`` mixin is used. @@ -195,7 +205,7 @@ class OrganizerLevelExportMixin: raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin") @classmethod - def get_required_organizer_permission(cls) -> str: + def get_required_organizer_permission(cls) -> Optional[str]: """ The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to allow everyone with any access to the organizer. diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index e05935898f..7e401e17c8 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -160,7 +160,7 @@ class OrderListExporter(MultiSheetListExporter): def _get_all_payment_methods(self, qs): pps = dict(get_all_payment_providers()) - return sorted([(pp, pps[pp]) for pp in set( + return sorted([(pp, pps.get(pp, pp)) for pp in set( OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list( 'provider', flat=True ).distinct() @@ -330,6 +330,7 @@ class OrderListExporter(MultiSheetListExporter): taxsum=Sum('tax_value'), grosssum=Sum('value') ) } + payment_methods = None if form_data.get('include_payment_amounts'): payment_sum_cache = { (o['order__id'], o['provider']): o['grosssum'] for o in @@ -347,6 +348,7 @@ class OrderListExporter(MultiSheetListExporter): grosssum=Sum('amount') ) } + payment_methods = self._get_all_payment_methods(qs) sum_cache = { (o['order__id'], o['tax_rate']): o for o in OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate( @@ -434,7 +436,6 @@ class OrderListExporter(MultiSheetListExporter): ) if form_data.get('include_payment_amounts'): - payment_methods = self._get_all_payment_methods(qs) for id, vn in payment_methods: row.append( payment_sum_cache.get((order.id, id), Decimal('0.00')) - @@ -1103,13 +1104,25 @@ class PaymentListExporter(ListExporter): def iterate_list(self, form_data): provider_names = dict(get_all_payment_providers()) + i_numbers = Invoice.objects.filter( + order=OuterRef('order_id'), + ).values('order').annotate( + m=GroupConcat('full_invoice_no', delimiter=', ') + ).values( + 'm' + ).order_by() + payments = OrderPayment.objects.filter( order__event__in=self.events, state__in=form_data.get('payment_states', []) + ).annotate( + order_invoice_numbers=Subquery(i_numbers, output_field=CharField()), ).select_related('order').prefetch_related('order__event').order_by('created') refunds = OrderRefund.objects.filter( order__event__in=self.events, state__in=form_data.get('refund_states', []) + ).annotate( + order_invoice_numbers=Subquery(i_numbers, output_field=CharField()), ).select_related('order').prefetch_related('order__event').order_by('created') if form_data.get('end_date_range'): @@ -1135,6 +1148,7 @@ class PaymentListExporter(ListExporter): headers = [ _('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'), _('Status code'), _('Amount'), _('Payment method'), _('Comment'), _('Matching ID'), _('Payment details'), + _('Invoice numbers'), ] yield headers @@ -1172,6 +1186,7 @@ class PaymentListExporter(ListExporter): obj.comment if isinstance(obj, OrderRefund) else "", matching_id, payment_details, + obj.order_invoice_numbers, ] yield row diff --git a/src/pretix/base/exporters/reusablemedia.py b/src/pretix/base/exporters/reusablemedia.py index fbc6005908..207d62306e 100644 --- a/src/pretix/base/exporters/reusablemedia.py +++ b/src/pretix/base/exporters/reusablemedia.py @@ -20,12 +20,13 @@ # . # +from django.db.models import Prefetch from django.dispatch import receiver from django.utils.formats import date_format from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy from ..exporter import ListExporter, OrganizerLevelExportMixin -from ..models import ReusableMedium +from ..models import OrderPosition, ReusableMedium from ..signals import register_multievent_data_exporters @@ -44,7 +45,9 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter): media = ReusableMedium.objects.filter( organizer=self.organizer, ).select_related( - 'customer', 'linked_orderposition', 'linked_giftcard', + 'customer', 'linked_giftcard', + ).prefetch_related( + Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order")) ).order_by('created') headers = [ @@ -61,18 +64,23 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter): yield headers yield self.ProgressSetTotal(total=media.count()) + can_read_giftcards = self.permission_holder.has_organizer_permission(self.organizer, 'organizer.giftcards:read') + for medium in media.iterator(chunk_size=1000): - row = [ + giftcard_secret = medium.linked_giftcard.secret if medium.linked_giftcard_id else '' + if giftcard_secret and not can_read_giftcards: + giftcard_secret = giftcard_secret[:3] + "…" + + yield [ medium.type, medium.identifier, _('Yes') if medium.active else _('No'), date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '', medium.customer.identifier if medium.customer_id else '', - f"{medium.linked_orderposition.order.code}-{medium.linked_orderposition.positionid}" if medium.linked_orderposition_id else '', - medium.linked_giftcard.secret if medium.linked_giftcard_id else '', + ', '.join([f"{op.order.code}-{op.positionid}" for op in medium.linked_orderpositions.all()]), + giftcard_secret, medium.notes, ] - yield row def get_filename(self): return f'{self.organizer.slug}_media' diff --git a/src/pretix/base/forms/auth.py b/src/pretix/base/forms/auth.py index 79e5a0e7c1..a47c231a7f 100644 --- a/src/pretix/base/forms/auth.py +++ b/src/pretix/base/forms/auth.py @@ -196,8 +196,7 @@ class RegistrationForm(forms.Form): def clean_password(self): password1 = self.cleaned_data.get('password', '') user = User(email=self.cleaned_data.get('email')) - if validate_password(password1, user=user) is not None: - raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid') + validate_password(password1, user=user) return password1 def clean_email(self): diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index ca29fd10a5..a4184fa522 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -35,6 +35,7 @@ import copy import json import logging +import re from datetime import timedelta from decimal import Decimal from io import BytesIO @@ -45,12 +46,9 @@ import pycountry from django import forms from django.conf import settings from django.contrib import messages -from django.contrib.gis.geoip2 import GeoIP2 from django.core.exceptions import ValidationError from django.core.files.uploadedfile import SimpleUploadedFile -from django.core.validators import ( - MaxValueValidator, MinValueValidator, RegexValidator, -) +from django.core.validators import MaxValueValidator, MinValueValidator from django.db.models import QuerySet from django.forms import Select, widgets from django.forms.widgets import FILE_INPUT_CONTRADICTION @@ -91,7 +89,7 @@ from pretix.base.settings import ( COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, ) -from pretix.base.templatetags.rich_text import rich_text +from pretix.base.templatetags.rich_text import URL_RE, rich_text from pretix.base.timemachine import time_machine_now from pretix.control.forms import ( ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField, @@ -102,6 +100,7 @@ from pretix.helpers.countries import ( from pretix.helpers.escapejson import escapejson_attr from pretix.helpers.http import get_client_ip from pretix.helpers.i18n import get_format_without_seconds +from pretix.helpers.security import get_geoip from pretix.presale.signals import question_form_fields logger = logging.getLogger(__name__) @@ -220,16 +219,8 @@ class NamePartsFormField(forms.MultiValueField): defaults = { 'widget': self.widget, 'max_length': kwargs.pop('max_length', None), - 'validators': [ - RegexValidator( - # The following characters should never appear in a name anywhere of - # the world. However, they commonly appear in inputs generated by spam - # bots. - r'^[^$€/%§{}<>~]*$', - message=_('Please do not use special characters in names.') - ) - ] } + self.max_length = defaults['max_length'] self.scheme_name = kwargs.pop('scheme') self.titles = kwargs.pop('titles') self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name) @@ -249,7 +240,6 @@ class NamePartsFormField(forms.MultiValueField): if fname == 'title' and self.scheme_titles: d = dict(defaults) d.pop('max_length', None) - d.pop('validators', None) field = forms.ChoiceField( **d, choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]] @@ -258,7 +248,6 @@ class NamePartsFormField(forms.MultiValueField): elif fname == 'salutation': d = dict(defaults) d.pop('max_length', None) - d.pop('validators', None) field = forms.ChoiceField( **d, choices=[ @@ -287,9 +276,40 @@ class NamePartsFormField(forms.MultiValueField): if self.require_all_fields and not all(v for v in value): raise forms.ValidationError(self.error_messages['incomplete'], code='required') - if sum(len(v) for v in value.values() if v) > 250: + if sum(len(v) for v in value.values() if v) > (self.max_length or 250): raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length') + for fname, label, size in self.scheme['fields']: + if fname == 'salutation' or (fname == 'title' and self.scheme_titles): + continue + v = value.get(fname) + if not v: + continue + special_chars = re.findall('[$€/%§{}<>~]', v) + if special_chars: + raise forms.ValidationError( + _('The field "%(label)s" may not contain special characters such as "%(chars)s".'), + code='name_special_chars', + params={ + "label": label, + "chars": "".join(special_chars), + }, + ) + # URL_RE checks for valid domain names, including one special TLD med, which can be part of a title + if ".med" in v: + v = v.replace(".med", ". med") + value[fname] = v + url_matched = URL_RE.search(v) + if url_matched: + raise forms.ValidationError( + _('The field "%(label)s" may not contain an URL (%(url)s).'), + code='url_in_title', + params={ + "label": label, + "url": url_matched.group(0), + } + ) + if value.get("salutation") == "empty": value["salutation"] = "" @@ -393,7 +413,7 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget): def guess_country_from_request(request, event): if settings.HAS_GEOIP: - g = GeoIP2() + g = get_geoip() try: res = g.country(get_client_ip(request)) if res['country_code'] and len(res['country_code']) == 2: diff --git a/src/pretix/base/invoicing/pdf.py b/src/pretix/base/invoicing/pdf.py index 21765a629e..b47b376bda 100644 --- a/src/pretix/base/invoicing/pdf.py +++ b/src/pretix/base/invoicing/pdf.py @@ -1160,7 +1160,7 @@ class Modern1Renderer(ClassicInvoiceRenderer): return stylesheet def _draw_invoice_from(self, canvas): - if not self.invoice.invoice_from: + if not self.invoice.address_invoice_from: return c = [ self._clean_text(l) diff --git a/src/pretix/base/management/commands/makemigrations.py b/src/pretix/base/management/commands/makemigrations.py index 7b88011fbd..b5e7c781b9 100644 --- a/src/pretix/base/management/commands/makemigrations.py +++ b/src/pretix/base/management/commands/makemigrations.py @@ -36,8 +36,9 @@ from django.core.management.commands.makemigrations import Command as Parent from ._migrations import monkeypatch_migrations -monkeypatch_migrations() - class Command(Parent): - pass + + def handle(self, *args, **kwargs): + monkeypatch_migrations() + return super().handle(*args, **kwargs) diff --git a/src/pretix/base/management/commands/runperiodic.py b/src/pretix/base/management/commands/runperiodic.py index b3e99ab9b6..ff9b31fc67 100644 --- a/src/pretix/base/management/commands/runperiodic.py +++ b/src/pretix/base/management/commands/runperiodic.py @@ -64,7 +64,7 @@ class Command(BaseCommand): if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS: return - for receiver in periodic_task._live_receivers(self): + for receiver in periodic_task._live_receivers(self)[0]: name = f'{receiver.__module__}.{receiver.__name__}' if options['list_tasks']: print(name) diff --git a/src/pretix/base/management/commands/runserver.py b/src/pretix/base/management/commands/runserver.py index b29f24fa10..09e8442f70 100644 --- a/src/pretix/base/management/commands/runserver.py +++ b/src/pretix/base/management/commands/runserver.py @@ -44,7 +44,8 @@ class Command(Parent): # Start the vite server in the background vite_server = subprocess.Popen( ["npm", "run", "dev:control"], - cwd=Path(__file__).parent.parent.parent.parent.parent + cwd=Path(__file__).parent.parent.parent.parent.parent, + stdin=subprocess.DEVNULL ) def cleanup(): diff --git a/src/pretix/base/media.py b/src/pretix/base/media.py index 47a1be987e..f6a4b24fdf 100644 --- a/src/pretix/base/media.py +++ b/src/pretix/base/media.py @@ -26,6 +26,7 @@ from django.utils.translation import gettext_lazy as _ class BaseMediaType: medium_created_by_server = False + medium_created_from_unknown_supported = False supports_orderposition = False supports_giftcard = False @@ -56,7 +57,7 @@ class BaseMediaType: def is_active(self, organizer): return organizer.settings.get(f'reusable_media_type_{self.identifier}', as_type=bool, default=False) - def handle_unknown(self, organizer, identifier, user, auth): + def handle_unknown(self, organizer, identifier, user, auth, force_create=False): pass def handle_new(self, organizer, medium, user, auth): @@ -88,23 +89,32 @@ class NfcUidMediaType(BaseMediaType): verbose_name = _('NFC UID-based') icon = 'pretixbase/img/media/nfc_uid.svg' medium_created_by_server = False + medium_created_from_unknown_supported = True supports_giftcard = True - supports_orderposition = False + supports_orderposition = True - def handle_unknown(self, organizer, identifier, user, auth): + def handle_unknown(self, organizer, identifier, user, auth, force_create=False): from pretix.base.models import GiftCard, ReusableMedium - if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool): + create_giftcard = organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool) + if create_giftcard or force_create: if identifier.startswith("08"): # Don't create gift cards for NFC UIDs that start with 08, which represents NFC cards that issue random # UIDs on every read, so they won't be useful. return with transaction.atomic(): - gc = GiftCard.objects.create( - issuer=organizer, - expires=organizer.default_gift_card_expiry, - currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'), - ) + if create_giftcard: + gc = GiftCard.objects.create( + issuer=organizer, + expires=organizer.default_gift_card_expiry, + currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'), + ) + gc.log_action( + 'pretix.giftcards.created', + user=user, auth=auth, + ) + else: + gc = None m = ReusableMedium.objects.create( type=self.identifier, identifier=identifier, @@ -116,10 +126,6 @@ class NfcUidMediaType(BaseMediaType): 'pretix.reusable_medium.created.auto', user=user, auth=auth, ) - gc.log_action( - 'pretix.giftcards.created', - user=user, auth=auth, - ) return m @@ -129,7 +135,7 @@ class NfcMf0aesMediaType(BaseMediaType): icon = 'pretixbase/img/media/nfc_secure.svg' medium_created_by_server = False supports_giftcard = True - supports_orderposition = False + supports_orderposition = True def handle_new(self, organizer, medium, user, auth): from pretix.base.models import GiftCard diff --git a/src/pretix/base/middleware.py b/src/pretix/base/middleware.py index 8ef7dce2ad..84c39fed36 100644 --- a/src/pretix/base/middleware.py +++ b/src/pretix/base/middleware.py @@ -24,6 +24,7 @@ from urllib.parse import urlparse, urlsplit from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from django.conf import settings +from django.core.exceptions import BadRequest from django.http import Http404, HttpRequest, HttpResponse from django.middleware.common import CommonMiddleware from django.urls import get_script_prefix, resolve @@ -73,6 +74,7 @@ class LocaleMiddleware(MiddlewareMixin): def process_request(self, request: HttpRequest): language = get_language_from_request(request) + region = None # Normally, this middleware runs *before* the event is set. However, on event frontend pages it # might be run a second time by pretix.presale.EventMiddleware and in this case the event is already # set and can be taken into account for the decision. @@ -93,15 +95,16 @@ class LocaleMiddleware(MiddlewareMixin): if '-' not in language and settings_holder.settings.region: language += '-' + settings_holder.settings.region if settings_holder.settings.region: - set_region(settings_holder.settings.region) + region = settings_holder.settings.region else: gs = global_settings_object(request) if '-' not in language and gs.settings.region: language += '-' + gs.settings.region if gs.settings.region: - set_region(gs.settings.region) + region = gs.settings.region translation.activate(language) + set_region(region) request.LANGUAGE_CODE = get_language_without_region() tzname = None @@ -280,7 +283,7 @@ class SecurityMiddleware(MiddlewareMixin): h = { 'default-src': ["{static}"], - 'script-src': ["{static}"] + (["http://localhost:5173", "ws://localhost:5173"] if settings.VITE_DEV_MODE else []), + 'script-src': ["{static}"], 'object-src': ["'none'"], 'frame-src': ['{static}'], 'style-src': ["{static}", "{media}"] + (["'unsafe-inline'"] if settings.VITE_DEV_MODE else []), @@ -294,6 +297,18 @@ class SecurityMiddleware(MiddlewareMixin): # this. However, we'll restrict it to HTTPS. 'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []), } + + if settings.VITE_DEV_MODE: + h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"] + h['style-src'] += ["'unsafe-inline'"] + h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"] + + if hasattr(request, 'csp_nonce'): + nonce = f"'nonce-{request.csp_nonce}'" + h['script-src'].append(nonce) + if not settings.VITE_DEV_MODE: + # can't have 'unsafe-inline' and nonce at the same time + h['style-src'].append(nonce) # Only include pay.google.com for wallet detection purposes on the Payment selection page if ( url.url_name == "event.order.pay.change" or @@ -347,6 +362,18 @@ class SecurityMiddleware(MiddlewareMixin): return resp +class RejectInvalidInputMiddleware(MiddlewareMixin): + + def process_request(self, request): + # Nullbytes in GET/POST parameters are mostly harmless, as they will later fail on database insertion, but it + # keeps spamming our error logs whenever someone tries to run a vulnerability scanner. + if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']: + raise BadRequest("Invalid characters in input.") + if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded": + if any("\x00" in value for key, value_list in request.POST.lists() for value in value_list): + raise BadRequest("Invalid characters in input.") + + class CustomCommonMiddleware(CommonMiddleware): def get_full_path_with_slash(self, request): diff --git a/src/pretix/base/migrations/0234_total_ordering.py b/src/pretix/base/migrations/0234_total_ordering.py index dfbd53d53d..9cd0898137 100644 --- a/src/pretix/base/migrations/0234_total_ordering.py +++ b/src/pretix/base/migrations/0234_total_ordering.py @@ -41,16 +41,20 @@ class Migration(migrations.Migration): name='datetime', field=models.DateTimeField(), ), - migrations.AlterIndexTogether( - name='logentry', - index_together={('datetime', 'id')}, + migrations.AddIndex( + 'logentry', + models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b1fe5a_idx"), ), - migrations.AlterIndexTogether( - name='order', - index_together={('datetime', 'id'), ('last_modified', 'id')}, + migrations.AddIndex( + 'order', + models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"), ), - migrations.AlterIndexTogether( - name='transaction', - index_together={('datetime', 'id')}, + migrations.AddIndex( + 'order', + models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"), + ), + migrations.AddIndex( + 'transaction', + models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b20405_idx"), ), ] diff --git a/src/pretix/base/migrations/0236_reusable_media.py b/src/pretix/base/migrations/0236_reusable_media.py index 95eb93890c..11c9a9df30 100644 --- a/src/pretix/base/migrations/0236_reusable_media.py +++ b/src/pretix/base/migrations/0236_reusable_media.py @@ -61,7 +61,10 @@ class Migration(migrations.Migration): options={ 'ordering': ('identifier', 'type', 'organizer'), 'unique_together': {('identifier', 'type', 'organizer')}, - 'index_together': {('identifier', 'type', 'organizer'), ('updated', 'id')}, + 'indexes': [ + models.Index(fields=('identifier', 'type', 'organizer'), name='reusable_medium_organizer_index'), + models.Index(fields=('updated', 'id'), name="pretixbase__updated_093277_idx") + ], }, bases=(models.Model, pretix.base.models.base.LoggingMixin), ), diff --git a/src/pretix/base/migrations/0246_bigint.py b/src/pretix/base/migrations/0246_bigint.py index ebfedcbad6..625cf3c641 100644 --- a/src/pretix/base/migrations/0246_bigint.py +++ b/src/pretix/base/migrations/0246_bigint.py @@ -9,31 +9,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RenameIndex( - model_name="logentry", - new_name="pretixbase__datetim_b1fe5a_idx", - old_fields=("datetime", "id"), - ), - migrations.RenameIndex( - model_name="order", - new_name="pretixbase__datetim_66aff0_idx", - old_fields=("datetime", "id"), - ), - migrations.RenameIndex( - model_name="order", - new_name="pretixbase__last_mo_4ebf8b_idx", - old_fields=("last_modified", "id"), - ), - migrations.RenameIndex( - model_name="reusablemedium", - new_name="pretixbase__updated_093277_idx", - old_fields=("updated", "id"), - ), - migrations.RenameIndex( - model_name="transaction", - new_name="pretixbase__datetim_b20405_idx", - old_fields=("datetime", "id"), - ), migrations.AlterField( model_name="attendeeprofile", name="id", diff --git a/src/pretix/base/migrations/0260_alter_reusablemedium_index_together.py b/src/pretix/base/migrations/0260_alter_reusablemedium_index_together.py index 123fc37f70..9dbbd057cc 100644 --- a/src/pretix/base/migrations/0260_alter_reusablemedium_index_together.py +++ b/src/pretix/base/migrations/0260_alter_reusablemedium_index_together.py @@ -1,6 +1,6 @@ # Generated by Django 4.2.10 on 2024-04-02 15:16 -from django.db import migrations +from django.db import migrations, models class Migration(migrations.Migration): @@ -10,8 +10,8 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AlterIndexTogether( - name="reusablemedium", - index_together=set(), + migrations.RemoveIndex( + "reusablemedium", + 'reusable_medium_organizer_index', ), ] diff --git a/src/pretix/base/migrations/0299_itemprogramtime_location.py b/src/pretix/base/migrations/0299_itemprogramtime_location.py new file mode 100644 index 0000000000..390b33d72d --- /dev/null +++ b/src/pretix/base/migrations/0299_itemprogramtime_location.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2026-01-21 12:06 + +import i18nfield.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0298_pluggable_permissions"), + ] + + operations = [ + migrations.AddField( + model_name="itemprogramtime", + name="location", + field=i18nfield.fields.I18nTextField(max_length=200, null=True), + ) + ] diff --git a/src/pretix/base/migrations/0300_add_reusablemedium_label.py b/src/pretix/base/migrations/0300_add_reusablemedium_label.py new file mode 100644 index 0000000000..0352c50b81 --- /dev/null +++ b/src/pretix/base/migrations/0300_add_reusablemedium_label.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.26 on 2025-11-24 11:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0299_itemprogramtime_location"), + ] + + operations = [ + migrations.AddField( + model_name="reusablemedium", + name="claim_token", + field=models.CharField(max_length=200, null=True), + ), + migrations.AddField( + model_name="reusablemedium", + name="label", + field=models.CharField(max_length=200, null=True), + ), + # use temporary related_name "linked_mediums" for ManyToManyField, so we can migrate existing data + migrations.AddField( + model_name="reusablemedium", + name="linked_orderpositions", + field=models.ManyToManyField( + related_name="linked_mediums", to="pretixbase.orderposition" + ), + ), + migrations.RunSQL( + sql="INSERT INTO pretixbase_reusablemedium_linked_orderpositions (reusablemedium_id, orderposition_id) SELECT id, linked_orderposition_id FROM pretixbase_reusablemedium WHERE linked_orderposition_id IS NOT NULL;", + reverse_sql="DELETE FROM pretixbase_reusablemedium_linked_orderpositions;", + ), + ] diff --git a/src/pretix/base/migrations/0301_reusablemedium_remove_orderposition.py b/src/pretix/base/migrations/0301_reusablemedium_remove_orderposition.py new file mode 100644 index 0000000000..8ba0a64b8a --- /dev/null +++ b/src/pretix/base/migrations/0301_reusablemedium_remove_orderposition.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.26 on 2025-11-24 11:32 + +from django.db import migrations, models + + +def reverse(apps, schema_editor): + ReusableMedium = apps.get_model('pretixbase', 'ReusableMedium') + + qs = ReusableMedium.linked_orderpositions.through.objects + objs = [] + # get last added orderposition from linked_orderpositions + for rm_id, op_id in qs.filter(id__in=qs.values("reusablemedium_id").annotate(max_id=models.Max('id')).values('max_id')).values_list("reusablemedium_id", "orderposition_id"): + obj = ReusableMedium( + id=rm_id, + linked_orderposition_id=op_id, + ) + objs.append(obj) + + ReusableMedium.objects.bulk_update(objs, ['linked_orderposition_id']) + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0300_add_reusablemedium_label"), + ] + + operations = [ + # according to the docs, UPDATE FROM should run similarly on sqlite and postgres, but I could not get it to work + # so roll back the data migration with code before deleting data from through-table in 0297 + migrations.RunPython(migrations.RunPython.noop, reverse), + migrations.RemoveField( + model_name="reusablemedium", + name="linked_orderposition", + ), + # change related_name for new ManyToManyField to previously used linked_media + migrations.AlterField( + model_name="reusablemedium", + name="linked_orderpositions", + field=models.ManyToManyField( + related_name="linked_media", to="pretixbase.orderposition" + ), + ), + ] diff --git a/src/pretix/base/modelimport.py b/src/pretix/base/modelimport.py index e274dbe85a..348012bad4 100644 --- a/src/pretix/base/modelimport.py +++ b/src/pretix/base/modelimport.py @@ -70,6 +70,10 @@ def parse_csv(file, length=None, mode="strict", charset=None): except ImportError: charset = file.charset data = data.decode(charset or "utf-8", mode) + + # remove stray linebreaks from the end of the file + data = data.rstrip("\n") + # If the file was modified on a Mac, it only contains \r as line breaks if '\r' in data and '\n' not in data: data = data.replace('\r', '\n') diff --git a/src/pretix/base/modelimport_orders.py b/src/pretix/base/modelimport_orders.py index 144cdf613d..11ddc18d43 100644 --- a/src/pretix/base/modelimport_orders.py +++ b/src/pretix/base/modelimport_orders.py @@ -442,7 +442,7 @@ class AttendeeState(ImportColumn): @property def verbose_name(self): - return _('Attendee address') + ': ' + _('State') + return _('Attendee address') + ': ' + pgettext('address', 'State') def clean(self, value, previous_values): if value: diff --git a/src/pretix/base/models/_transactions.py b/src/pretix/base/models/_transactions.py index 1aaf2919bf..8ee37e036b 100644 --- a/src/pretix/base/models/_transactions.py +++ b/src/pretix/base/models/_transactions.py @@ -29,7 +29,9 @@ import inspect import logging import os import threading +from pathlib import Path +import django from django.conf import settings from django.db import transaction @@ -74,10 +76,14 @@ def _transactions_mark_order_dirty(order_id, using=None): if "PYTEST_CURRENT_TEST" in os.environ: # We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code # or not. - for frame in inspect.stack(): - if 'pretix/base/models/orders' in frame.filename: + for frame in inspect.stack()[1:]: + if ( + 'pretix/base/models/orders' in frame.filename + or Path(frame.filename).is_relative_to(Path(django.__file__).parent) + ): + # Ignore model- and django-internal code continue - elif 'test_' in frame.filename or 'conftest.py in frame.filename': + elif 'test_' in frame.filename or 'conftest.py' in frame.filename: return elif 'pretix/' in frame.filename or 'pretix_' in frame.filename: # This went through non-test code, let's consider it non-test diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index a14f2e4b74..d783f7290c 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -38,6 +38,7 @@ import operator import secrets from datetime import timedelta from functools import reduce +from typing import Protocol from django.conf import settings from django.contrib.auth.models import ( @@ -67,6 +68,14 @@ class EmailAddressTakenError(IntegrityError): pass +class PermissionHolder(Protocol): + def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool: + ... + + def has_organizer_permission(self, organizer, perm_name=None, request=None): + ... + + class UserManager(BaseUserManager): """ This is the user manager for our custom user model. See the User @@ -696,6 +705,18 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): return self.teams.exists() +class UserWithStaffSession: + # Wrapper around a User object with a staff session, implementing the PermissionHolder Protocol + def __init__(self, user): + self.user = user + + def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool: + return True + + def has_organizer_permission(self, organizer, perm_name=None, request=None): + return True + + class UserKnownLoginSource(models.Model): user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources") agent_type = models.CharField(max_length=255, null=True, blank=True) diff --git a/src/pretix/base/models/base.py b/src/pretix/base/models/base.py index dd95ad03b0..64e37088f7 100644 --- a/src/pretix/base/models/base.py +++ b/src/pretix/base/models/base.py @@ -125,7 +125,7 @@ class LoggingMixin: elif isinstance(self, Event): event = self organizer_id = self.organizer_id - elif hasattr(self, 'event'): + elif hasattr(self, 'event') and self.event: event = self.event organizer_id = self.event.organizer_id elif hasattr(self, 'organizer_id'): diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 0926cedee4..bd06bb11e0 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -346,11 +346,14 @@ class Checkin(models.Model): REASON_INCOMPLETE = 'incomplete' REASON_ALREADY_REDEEMED = 'already_redeemed' REASON_AMBIGUOUS = 'ambiguous' + REASON_MEDIUM_INVALID = 'medium_invalid' + REASON_MEDIUM_EXISTS = 'medium_exists' REASON_ERROR = 'error' REASON_BLOCKED = 'blocked' REASON_UNAPPROVED = 'unapproved' REASON_INVALID_TIME = 'invalid_time' REASON_ANNULLED = 'annulled' + REASON_ALREADY_EXCHANGED = 'already_exchanged' REASONS = ( (REASON_CANCELED, _('Order canceled')), (REASON_INVALID, _('Unknown ticket')), @@ -366,6 +369,9 @@ class Checkin(models.Model): (REASON_UNAPPROVED, _('Order not approved')), (REASON_INVALID_TIME, _('Ticket not valid at this time')), (REASON_ANNULLED, _('Check-in annulled')), + (REASON_ALREADY_EXCHANGED, _('Ticket already exchanged')), + (REASON_MEDIUM_INVALID, _('Reusable medium invalid')), + (REASON_MEDIUM_EXISTS, _('Reusable medium already exists')), ) successful = models.BooleanField( diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py index a19f8e8402..726586e8ee 100644 --- a/src/pretix/base/models/devices.py +++ b/src/pretix/base/models/devices.py @@ -229,7 +229,7 @@ class Device(LoggedModel): """ return self._organizer_permission_set() if self.organizer == organizer else set() - def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool: + def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool: """ Checks if this token is part of a team that grants access of type ``perm_name`` to the event ``event``. @@ -238,6 +238,7 @@ class Device(LoggedModel): :param event: The event to check :param perm_name: The permission, e.g. ``event.orders:read`` :param request: This parameter is ignored and only defined for compatibility reasons. + :param session_key: This parameter is ignored and only defined for compatibility reasons. :return: bool """ has_event_access = (self.all_events and organizer == self.organizer) or ( diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index aee0df1a7b..1feed0e367 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -715,6 +715,12 @@ class Event(EventMixin, LoggedModel): self.settings.name_scheme = 'given_family' self.settings.payment_banktransfer_invoice_immediately = True self.settings.low_availability_percentage = 10 + self.settings.mail_send_order_free_attendee = True + self.settings.mail_send_order_placed_attendee = True + self.settings.mail_send_order_paid_attendee = True + self.settings.mail_send_order_approved_attendee = True + self.settings.mail_send_order_approved_free_attendee = True + self.settings.mail_text_download_reminder_attendee = True @property def social_image(self): diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 4a61bede56..1d44e79aae 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -452,11 +452,16 @@ class Item(LoggedModel): MEDIA_POLICY_REUSE = 'reuse' MEDIA_POLICY_NEW = 'new' MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new' + MEDIA_POLICY_APPEND = 'append' + MEDIA_POLICY_APPEND_OR_NEW = 'append_or_new' MEDIA_POLICIES = ( - (None, _("Don't use re-usable media, use regular one-off tickets")), - (MEDIA_POLICY_REUSE, _('Require an existing medium to be re-used')), + (None, _("Don't use reusable media, use regular one-off tickets")), (MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')), - (MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used')), + (MEDIA_POLICY_REUSE, _('Require an existing medium to be reused, replacing any previous tickets')), + (MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used, replacing any previous tickets')), + (MEDIA_POLICY_APPEND, _('Require an existing medium to be reused, adding to any previous tickets')), + (MEDIA_POLICY_APPEND_OR_NEW, + _('Require either an existing or a new medium to be used, adding to any previous tickets')), ) objects = ItemQuerySetManager() @@ -769,7 +774,7 @@ class Item(LoggedModel): null=True, blank=True, max_length=16, verbose_name=_('Reusable media policy'), help_text=_( - 'If this product should be stored on a re-usable physical medium, you can attach a physical media policy. ' + 'If this product should be stored on a reusable physical medium, you can attach a physical media policy. ' 'This is not required for regular tickets, which just use a one-time barcode, but only for products like ' 'renewable season tickets or re-chargeable gift card wristbands. ' 'This is an advanced feature that also requires specific configuration of ticketing and printing settings.' @@ -778,7 +783,7 @@ class Item(LoggedModel): media_type = models.CharField( max_length=100, null=True, blank=True, - choices=[(None, _("Don't use re-usable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()], + choices=[(None, _("Don't use reusable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()], verbose_name=_('Reusable media type'), help_text=_( 'Select the type of physical medium that should be used for this product. Note that not all media types ' @@ -995,6 +1000,11 @@ class Item(LoggedModel): raise ValidationError(_('The selected media type does not support usage for tickets currently.')) if not mt.supports_giftcard and issue_giftcard: raise ValidationError(_('The selected media type does not support usage for gift cards currently.')) + if media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): + if not mt.medium_created_by_server and not mt.medium_created_from_unknown_supported: + raise ValidationError(_('The selected media type requires all media to be registered in the system ' + 'prior to their usage. Therefore, the selected media policy does not make ' + 'sense for this media type.')) if issue_giftcard: raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, ' 'gift cards for some reusable media types can be created or re-charged directly ' @@ -2321,7 +2331,7 @@ class Quota(LoggedModel): class ItemMetaProperty(LoggedModel): """ An event can have ItemMetaProperty objects attached to define meta information fields - for its items. This information can be re-used for example in ticket layouts. + for its items. This information can be reused for example in ticket layouts. :param event: The event this property is defined for. :type event: Event @@ -2407,10 +2417,17 @@ class ItemProgramTime(models.Model): :type start: datetime :param end: The date and time this program time ends :type end: datetime + :param location: venue + :type location: str """ item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE) start = models.DateTimeField(verbose_name=_("Start")) end = models.DateTimeField(verbose_name=_("End")) + location = I18nTextField( + null=True, blank=True, + max_length=200, + verbose_name=_("Location"), + ) def clean(self): if hasattr(self, 'item') and self.item and self.item.event.has_subevents: diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index 43b5439ef9..480564f2b2 100644 --- a/src/pretix/base/models/log.py +++ b/src/pretix/base/models/log.py @@ -88,7 +88,7 @@ class LogEntry(models.Model): class Meta: ordering = ('-datetime', '-id') - indexes = [models.Index(fields=["datetime", "id"])] + indexes = [models.Index(fields=["datetime", "id"], name="pretixbase__datetim_b1fe5a_idx")] def display(self): from pretix.base.logentrytype_registry import log_entry_types diff --git a/src/pretix/base/models/media.py b/src/pretix/base/models/media.py index be8a0af9d2..097c7803f4 100644 --- a/src/pretix/base/models/media.py +++ b/src/pretix/base/models/media.py @@ -72,6 +72,16 @@ class ReusableMedium(LoggedModel): max_length=200, verbose_name=pgettext_lazy('reusable_medium', 'Identifier'), ) + claim_token = models.CharField( + max_length=200, + verbose_name=pgettext_lazy('reusable_medium', 'Claim token'), + null=True, blank=True + ) + label = models.CharField( + max_length=200, + verbose_name=pgettext_lazy('reusable_medium', 'Label'), + null=True, blank=True + ) active = models.BooleanField( verbose_name=_('Active'), @@ -89,12 +99,14 @@ class ReusableMedium(LoggedModel): on_delete=models.SET_NULL, verbose_name=_('Customer account'), ) - linked_orderposition = models.ForeignKey( + linked_orderpositions = models.ManyToManyField( OrderPosition, - null=True, blank=True, related_name='linked_media', - on_delete=models.SET_NULL, - verbose_name=_('Linked ticket'), + verbose_name=_('Linked tickets'), + help_text=_( + 'If you link to more than one ticket, make sure there is no overlap in validity. ' + 'If multiple tickets are valid at once, this will lead to failed check-ins.' + ) ) linked_giftcard = models.ForeignKey( GiftCard, @@ -117,12 +129,15 @@ class ReusableMedium(LoggedModel): @property def is_expired(self): - return self.expires and self.expires > now() + return self.expires and self.expires < now() + + def touch(self): + self.save(update_fields=['updated']) class Meta: unique_together = (("identifier", "type", "organizer"),) indexes = [ - models.Index(fields=("updated", "id")), + models.Index(fields=("updated", "id"), name="pretixbase__updated_093277_idx"), ] ordering = "identifier", "type", "organizer" diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 466af8b0d3..e74e450e8c 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -336,8 +336,8 @@ class Order(LockModel, LoggedModel): verbose_name_plural = _("Orders") ordering = ("-datetime", "-pk") indexes = [ - models.Index(fields=["datetime", "id"]), - models.Index(fields=["last_modified", "id"]), + models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"), + models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"), ] constraints = [ models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"), @@ -590,7 +590,7 @@ class Order(LockModel, LoggedModel): not kwargs.get('force_save_with_deferred_fields', None) and (not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields)) ): - _fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed " + _fail("It is unsafe to call save() on an Order with deferred fields since we can't check if you missed " "creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do " "this.") @@ -2841,7 +2841,7 @@ class OrderPosition(AbstractPosition): if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk: _transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None)) elif not kwargs.get('force_save_with_deferred_fields', None): - _fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed " + _fail("It is unsafe to call save() on an OrderPosition with deferred fields since we can't check if you missed " "creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do " "this.") @@ -3080,7 +3080,7 @@ class Transaction(models.Model): class Meta: ordering = 'datetime', 'pk' indexes = [ - models.Index(fields=['datetime', 'id']) + models.Index(fields=['datetime', 'id'], name="pretixbase__datetim_b20405_idx") ] def save(self, *args, **kwargs): diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index 5a9ef8c51d..65cff5edf3 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -319,6 +319,9 @@ class TeamQuerySet(models.QuerySet): def event_permission_q(cls, perm_name): from ..permissions import assert_valid_event_permission + if perm_name is None: + return Q() + if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_EVENT_COMPAT: # legacy return reduce(operator.and_, [cls.event_permission_q(p) for p in OLD_TO_NEW_EVENT_COMPAT[perm_name]]) assert_valid_event_permission(perm_name, allow_legacy=False) @@ -331,6 +334,9 @@ class TeamQuerySet(models.QuerySet): def organizer_permission_q(cls, perm_name): from ..permissions import assert_valid_organizer_permission + if perm_name is None: + return Q() + if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_ORGANIZER_COMPAT: # legacy return reduce(operator.and_, [cls.organizer_permission_q(p) for p in OLD_TO_NEW_ORGANIZER_COMPAT[perm_name]]) assert_valid_organizer_permission(perm_name, allow_legacy=False) @@ -550,7 +556,7 @@ class TeamAPIToken(models.Model): """ return self.team.organizer_permission_set() if self.team.organizer == organizer else set() - def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool: + def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool: """ Checks if this token is part of a team that grants access of type ``perm_name`` to the event ``event``. @@ -559,6 +565,7 @@ class TeamAPIToken(models.Model): :param event: The event to check :param perm_name: The permission, e.g. ``event.orders:read`` :param request: This parameter is ignored and only defined for compatibility reasons. + :param session_key: This parameter is ignored and only defined for compatibility reasons. :return: bool """ has_event_access = (self.team.all_events and organizer == self.team.organizer) or ( diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index 5d11c35045..a8cb2fc2c3 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -54,7 +54,7 @@ from bidi import get_display from django.conf import settings from django.contrib.staticfiles import finders from django.core.exceptions import ValidationError -from django.db.models import Max, Min +from django.db.models import Exists, Max, Min, OuterRef from django.db.models.fields.files import FieldFile from django.dispatch import receiver from django.utils.deconstruct import deconstructible @@ -76,7 +76,7 @@ from reportlab.pdfgen.canvas import Canvas from reportlab.platypus import Paragraph from pretix.base.i18n import language -from pretix.base.models import Event, Order, OrderPosition, Question +from pretix.base.models import Checkin, Event, Order, OrderPosition, Question from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.signals import layout_image_variables, layout_text_variables from pretix.base.templatetags.money import money_filter @@ -379,6 +379,13 @@ DEFAULT_VARIABLES = OrderedDict(( str(p) for p in generate_compressed_addon_list(op, order, ev) ]) }), + ("checked_in_addons", { + "label": _("List of Checked-In Add-Ons"), + "editor_sample": _("Add-on 1\n2x Add-on 2"), + "evaluate": lambda op, order, ev: "\n".join([ + str(p) for p in generate_compressed_addon_list(op, order, ev, only_checked_in=True) + ]) + }), ("organizer", { "label": _("Organizer name"), "editor_sample": _("Event organizer company"), @@ -491,9 +498,9 @@ DEFAULT_VARIABLES = OrderedDict(( ) if op.valid_until else "" }), ("program_times", { - "label": _("Program times: date and time"), + "label": _("Program times"), "editor_sample": _( - "2017-05-31 10:00 – 12:00\n2017-05-31 14:00 – 16:00\n2017-05-31 14:00 – 2017-06-01 14:00"), + "2017-05-31 10:00 – 12:00, Room 1\n2017-05-31 14:00 – 16:00, Room 2\n2017-05-31 14:00 – 2017-06-01 14:00, Building A"), "evaluate": lambda op, order, ev: get_program_times(op, ev) }), ("medium_identifier", { @@ -741,21 +748,31 @@ def get_seat(op: OrderPosition): def get_program_times(op: OrderPosition, ev: Event): - return '\n'.join([ - datetimerange( - pt.start.astimezone(ev.timezone), - pt.end.astimezone(ev.timezone), - as_html=False - ) for pt in op.item.program_times.all() - ]) + ptstr = [] + for pt in op.item.program_times.all(): + ptstr.append([ + datetimerange( + pt.start.astimezone(ev.timezone), + pt.end.astimezone(ev.timezone), + as_html=False + ), + (', ' + ', '.join( + l.strip() for l in str(pt.location).splitlines() if l.strip()) + ) if str(pt.location).strip() else '' + ]) + return '\n'.join(''.join(l) for l in ptstr) -def generate_compressed_addon_list(op, order, event): +def generate_compressed_addon_list(op, order, event, only_checked_in=False): itemcount = defaultdict(int) - addons = [p for p in ( + addon_qs = ( op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {}) else op.addons.select_related('item', 'variation') - ) if not p.canceled] + ) + if only_checked_in: + addon_qs = addon_qs.filter(Exists(Checkin.objects.filter(position=OuterRef('pk'))), canceled=False) + addons = [p for p in addon_qs if not p.canceled] + for pos in addons: itemcount[pos.item, pos.variation] += 1 @@ -912,7 +929,7 @@ class Renderer: # We do not use str.format like in emails so we (a) can evaluate lazily and (b) can re-implement this # 1:1 on other platforms that render PDFs through our API (libpretixprint) - return re.sub(r'\{([a-zA-Z0-9:_]+)\}', replace, text) + return re.sub(r'\{([-a-zA-Z0-9:_]+)\}', replace, text) elif o['content'].startswith('itemmeta:'): if op.variation_id: diff --git a/src/pretix/base/plugins.py b/src/pretix/base/plugins.py index 35647a1ba6..a1e1eec71d 100644 --- a/src/pretix/base/plugins.py +++ b/src/pretix/base/plugins.py @@ -49,14 +49,39 @@ class PluginType(Enum): EXPORT = 4 +def plugin_is_available(meta, event=None, organizer=None): + if not hasattr(meta.app, 'is_available'): + return True + + level = getattr(meta, "level", PLUGIN_LEVEL_EVENT) + if level == PLUGIN_LEVEL_EVENT: + if event: + return meta.app.is_available(event) + elif organizer: + if not hasattr(organizer, '_plugin_availability_fallback_event'): + with scope(organizer=organizer): + setattr(organizer, '_plugin_availability_fallback_event', organizer.events.first()) + return ( + organizer._plugin_availability_fallback_event + and meta.app.is_available(organizer._plugin_availability_fallback_event) + ) + elif level == PLUGIN_LEVEL_ORGANIZER: + if organizer: + return meta.app.is_available(organizer) + elif event: + return meta.app.is_available(event.organizer) + elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer): + return meta.app.is_available(event or organizer) + + return True + + def get_all_plugins(*, event=None, organizer=None) -> List[type]: """ Returns the PretixPluginMeta classes of all plugins found in the installed Django apps. """ assert not event or not organizer plugins = [] - event_fallback = None - event_fallback_used = False for app in apps.get_app_configs(): if hasattr(app, 'PretixPluginMeta'): meta = app.PretixPluginMeta @@ -65,28 +90,8 @@ def get_all_plugins(*, event=None, organizer=None) -> List[type]: if app.name in settings.PRETIX_PLUGINS_EXCLUDE: continue - level = getattr(meta, "level", PLUGIN_LEVEL_EVENT) - if level == PLUGIN_LEVEL_EVENT: - if event and hasattr(app, 'is_available'): - if not app.is_available(event): - continue - elif organizer and hasattr(app, 'is_available'): - if not event_fallback_used: - with scope(organizer=organizer): - event_fallback = organizer.events.first() - event_fallback_used = True - if not event_fallback or not app.is_available(event_fallback): - continue - elif level == PLUGIN_LEVEL_ORGANIZER: - if organizer and hasattr(app, 'is_available'): - if not app.is_available(organizer): - continue - elif event and hasattr(app, 'is_available'): - if not app.is_available(event.organizer): - continue - elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer) and hasattr(app, 'is_available'): - if not app.is_available(event or organizer): - continue + if not plugin_is_available(meta, event, organizer): + continue plugins.append(meta) return sorted( diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 59abf6c878..c9ddf51a9a 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -162,12 +162,12 @@ error_messages = { 'price_too_high': gettext_lazy('The entered price is to high.'), 'voucher_invalid': gettext_lazy('This voucher code is not known in our database.'), 'voucher_min_usages': ngettext_lazy( - 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.', + 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching product.', 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.', 'number' ), 'voucher_min_usages_removed': ngettext_lazy( - 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. ' + 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching product. ' 'We have therefore removed some positions from your cart that can no longer be purchased like this.', 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. ' 'We have therefore removed some positions from your cart that can no longer be purchased like this.', @@ -287,11 +287,11 @@ def _check_position_constraints( raise CartPositionError(error_messages['unavailable']) # Invalid media policy for online sale - if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): + if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): mt = MEDIA_TYPES[item.media_type] if not mt.medium_created_by_server: raise CartPositionError(error_messages['media_usage_not_implemented']) - elif item.media_policy == Item.MEDIA_POLICY_REUSE: + elif item.media_policy in (Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_APPEND): raise CartPositionError(error_messages['media_usage_not_implemented']) # Item removed from sales channel diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index ecf4cf7a9f..3ac7b6792b 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -867,6 +867,15 @@ class RequiredQuestionsError(Exception): super().__init__(msg) +class RequiredMediaExchangeError(Exception): + def __init__(self, msg, code, media_policy, media_type): + self.msg = msg + self.code = code + self.media_policy = media_policy + self.media_type = media_type + super().__init__(msg) + + def _save_answers(op, answers, given_answers): def _create_answer(question, answer): try: @@ -939,7 +948,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY, raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False, - gate=None): + gate=None, reusable_medium=None): """ Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is not valid at this time. @@ -955,6 +964,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, :param datetime: The datetime of the checkin, defaults to now. :param simulate: If true, the check-in is not saved. :param gate: The gate the check-in was performed at. + :param reusable_medium: The medium that is available for an exchange """ # !!!!!!!!! @@ -1035,7 +1045,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, with transaction.atomic(): # Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic - opqs = OrderPosition.all + opqs = OrderPosition.all.select_related("order", "item") if type != Checkin.TYPE_EXIT: opqs = opqs.select_for_update(of=OF_SELF) op = opqs.get(pk=op.pk) @@ -1101,6 +1111,24 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, require_answers ) + required_media_policy = op.item.media_policy + required_media_type = op.item.media_type + require_a_medium = required_media_policy and required_media_type + linked_media = op.linked_media + if require_a_medium and not reusable_medium and not force: + if not linked_media.exists(): + raise RequiredMediaExchangeError( + _('Ticket needs to be exchanged to a suitable medium.'), + 'exchange', + required_media_policy, + required_media_type + ) + elif op.organizer.settings.reusable_media_usage_enforced: + raise CheckInError( + _('This ticket has already been exchanged for a reusable medium that now needs to be used instead.'), + 'already_exchanged', + ) + device = None if isinstance(auth, Device): device = auth diff --git a/src/pretix/base/services/currencies.py b/src/pretix/base/services/currencies.py index 620f88b121..c8de3c4891 100644 --- a/src/pretix/base/services/currencies.py +++ b/src/pretix/base/services/currencies.py @@ -38,6 +38,7 @@ SOURCE_NAMES = { None: _('European Central Bank'), # backwards-compatibility 'eu:ecb:eurofxref-daily': _('European Central Bank'), 'cz:cnb:rate-fixing-daily': _('Czech National Bank'), + 'pl:nbp:table-a': _('National Bank of Poland'), } @@ -49,6 +50,7 @@ def fetch_rates(sender, **kwargs): source_tasks = { 'eu:ecb:eurofxref-daily': fetch_ecb_rates, 'cz:cnb:rate-fixing-daily': fetch_cnb_cz_rates, + 'pl:nbp:table-a': fetch_nbp_pl_rates, } for source_name, task in source_tasks.items(): @@ -144,3 +146,29 @@ def fetch_cnb_cz_rates(): rate=rate, ) ) + + +@app.task() +def fetch_nbp_pl_rates(): + """ + Fetches currency rates from the Polish National Bank. + """ + r = requests.get("https://api.nbp.pl/api/exchangerates/tables/A/", headers={ + "Accept": "application/json", + }) + r.raise_for_status() + data = r.json()[0] + + source_date = datetime.strptime(data["effectiveDate"], "%Y-%m-%d").date() + + for r in data["rates"]: + rate = Decimal(r["mid"]).quantize(Decimal('0.000001')) + ExchangeRate.objects.update_or_create( + source='pl:nbp:table-a', + source_currency=r["code"], + other_currency='PLN', + defaults=dict( + source_date=source_date, + rate=rate, + ) + ) diff --git a/src/pretix/base/services/export.py b/src/pretix/base/services/export.py index fc49ec2623..c04119cf67 100644 --- a/src/pretix/base/services/export.py +++ b/src/pretix/base/services/export.py @@ -40,6 +40,7 @@ from pretix.base.models import ( CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken, User, cachedfile_name, ) +from pretix.base.models.auth import UserWithStaffSession from pretix.base.models.exports import ScheduledOrganizerExport from pretix.base.services.mail import mail from pretix.base.services.tasks import ( @@ -211,7 +212,12 @@ def init_event_exporters(event, user=None, token=None, device=None, request=None if not perm_holder.has_event_permission(event.organizer, event, permission_name, request) and not staff_session: continue - exporter: BaseExporter = response(event=event, organizer=event.organizer, **kwargs) + exporter: BaseExporter = response( + event=event, + organizer=event.organizer, + permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user), + **kwargs + ) if not exporter.available_for_user(user if user and user.is_authenticated else None): continue @@ -243,7 +249,12 @@ def init_organizer_exporters( continue if issubclass(response, OrganizerLevelExportMixin): - exporter: BaseExporter = response(event=Event.objects.none(), organizer=organizer, **kwargs) + exporter: BaseExporter = response( + event=Event.objects.none(), + organizer=organizer, + permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user), + **kwargs, + ) try: if not perm_holder.has_organizer_permission(organizer, response.get_required_organizer_permission(), request) and not staff_session: @@ -295,7 +306,12 @@ def init_organizer_exporters( if not _has_permission_on_any_team_cache[permission_name] and not staff_session: continue - exporter: BaseExporter = response(event=_event_list_cache[permission_name], organizer=organizer, **kwargs) + exporter: BaseExporter = response( + event=_event_list_cache[permission_name], + organizer=organizer, + permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user), + **kwargs, + ) if not exporter.available_for_user(user if user and user.is_authenticated else None): continue diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index deadc4d2ff..75653e9081 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -58,6 +58,7 @@ from pretix.base.invoicing.transmission import ( from pretix.base.models import ( ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee, ) +from pretix.base.models.orders import OrderPayment from pretix.base.models.tax import EU_CURRENCIES from pretix.base.services.tasks import ( TransactionAwareProfiledEventTask, TransactionAwareTask, @@ -102,7 +103,7 @@ def build_invoice(invoice: Invoice) -> Invoice: introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString) additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString) footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString) - if lp and lp.payment_provider: + if lp and lp.payment_provider and lp.state not in (OrderPayment.PAYMENT_STATE_FAILED, OrderPayment.PAYMENT_STATE_CANCELED): if 'payment' in inspect.signature(lp.payment_provider.render_invoice_text).parameters: payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp)) else: @@ -204,6 +205,19 @@ def build_invoice(invoice: Invoice) -> Invoice: invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP) invoice.foreign_currency_rate_date = rate.source_date invoice.foreign_currency_source = 'cz:cnb:rate-fixing-daily' + elif invoice.event.settings.invoice_eu_currencies == 'PLN' and invoice.event.currency != 'PLN': + invoice.foreign_currency_display = 'PLN' + if settings.FETCH_ECB_RATES: + rate = ExchangeRate.objects.filter( + source='pl:nbp:table-a', + source_currency=invoice.event.currency, + other_currency=invoice.foreign_currency_display, + source_date__gt=now().date() - timedelta(days=7) + ).first() + if rate: + invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP) + invoice.foreign_currency_rate_date = rate.source_date + invoice.foreign_currency_source = 'pl:nbp:table-a' except InvoiceAddress.DoesNotExist: ia = None diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index a815179ad3..b5fae6ba75 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -411,7 +411,7 @@ def mail_send_task(self, **kwargs) -> bool: try: outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail) except OutgoingMail.DoesNotExist: - logger.info(f"Ignoring job for non existing email {outgoing_mail.guid}") + logger.info(f"Ignoring job for non existing email {outgoing_mail}") return False if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT: logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}") diff --git a/src/pretix/base/services/media.py b/src/pretix/base/services/media.py index c431e79ae3..6327242194 100644 --- a/src/pretix/base/services/media.py +++ b/src/pretix/base/services/media.py @@ -23,10 +23,13 @@ import secrets from django.db import IntegrityError from django.db.models import Q +from django.utils.translation import gettext as _ from django_scopes import scopes_disabled -from pretix.base.models import GiftCardAcceptance -from pretix.base.models.media import MediumKeySet +from pretix.base.media import MEDIA_TYPES +from pretix.base.models import Checkin, GiftCardAcceptance, Item +from pretix.base.models.media import MediumKeySet, ReusableMedium +from pretix.base.services.checkin import CheckInError def create_nfc_mf0aes_keyset(organizer): @@ -70,3 +73,174 @@ def get_keysets_for_organizer(organizer): if new_set: sets.append(new_set) return sets + + +def perform_media_exchange(organizer, media_type, identifier, link_orderposition, user, auth): + """ + Create or retrieve a medium, then link the order position to it. Expected to be called in a transaction. + + :param organizer: Organizer to operate in + :param media_type: Type of medium to operate with + :param identifier: Identifier of the medium + :param link_orderposition: Position to link to the medium + :return: ReusableMedium + """ + medium = None + media_policy = link_orderposition.item.media_policy + + if media_type not in MEDIA_TYPES: # should be caught by serializer already + raise CheckInError( + _('Invalid medium type.'), + Checkin.REASON_ERROR, + reason=_('Invalid medium type.'), + ) + + if not MEDIA_TYPES[media_type].is_active(organizer): + raise CheckInError( + _('Medium type is not enabled for organizer.'), + Checkin.REASON_ERROR, + reason=_('Medium type is not enabled for organizer.'), + ) + + if link_orderposition.item.media_type != media_type: + raise CheckInError( + _('Incorrect medium type for product.'), + Checkin.REASON_PRODUCT, + reason=_('Incorrect medium type for product.'), + ) + + if link_orderposition.linked_media.exists(): + raise CheckInError( + _('Ticket is already exchanged for reusable medium.'), + Checkin.REASON_ALREADY_EXCHANGED, + reason=_('Ticket is already exchanged for reusable medium.'), + ) + + if media_policy in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_NEW): + link_action = "append" + else: + link_action = "replace" + + if media_policy in (Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_APPEND): + try: + medium = ReusableMedium.objects.get( + type=media_type, + identifier=identifier, + organizer=organizer, + ) + except ReusableMedium.DoesNotExist: + raise CheckInError( + _('Reusable medium not found.'), + Checkin.REASON_MEDIUM_INVALID, + reason=_('Reusable medium not found.'), + ) + else: + if medium.is_expired or not medium.active: + raise CheckInError( + _('Reusable medium is inactive or expired.'), + Checkin.REASON_MEDIUM_INVALID, + reason=_('Reusable medium is inactive or expired.'), + ) + + elif media_policy in (Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW): + try: + medium = ReusableMedium.objects.get( + type=media_type, + identifier=identifier, + organizer=organizer, + ) + except ReusableMedium.DoesNotExist: + if not MEDIA_TYPES[media_type].medium_created_from_unknown_supported: + raise CheckInError( + _('Reusable medium not found and could not be created.'), + Checkin.REASON_MEDIUM_INVALID, + ) + + medium = MEDIA_TYPES[media_type].handle_unknown(organizer, identifier, user, auth, force_create=True) + if not medium: + raise CheckInError( + _('Reusable medium not found and could not be created.'), + Checkin.REASON_MEDIUM_INVALID, + ) + + if medium.is_expired or not medium.active: + raise CheckInError( + _('Reusable medium is inactive or expired.'), + Checkin.REASON_MEDIUM_INVALID, + reason=_('Reusable medium is inactive or expired.'), + ) + + elif media_policy == Item.MEDIA_POLICY_NEW: + if not MEDIA_TYPES[media_type].medium_created_from_unknown_supported: + raise CheckInError( + _('Reusable medium not found and could not be created.'), + Checkin.REASON_MEDIUM_INVALID, + ) + try: + medium = MEDIA_TYPES[media_type].handle_unknown(organizer, identifier, user, auth, force_create=True) + except IntegrityError: + raise CheckInError( + _('Reusable medium already exists.'), + Checkin.REASON_MEDIUM_EXISTS, + ) + else: + if not medium: + raise CheckInError( + _('Reusable medium could not be created.'), + Checkin.REASON_MEDIUM_INVALID, + ) + + else: + raise CheckInError( + _('Product does not support medium exchange.'), + Checkin.REASON_PRODUCT, + reason=_('Product does not support medium exchange.'), + ) + + if link_action == 'append': + medium.linked_orderpositions.add(link_orderposition) + medium.log_action( + 'pretix.reusable_medium.linked_orderposition.added', + user=user, + auth=auth, + data={ + 'linked_orderposition': link_orderposition, + } + ) + elif link_action == 'replace': + already_found = False + for op_pk in medium.linked_orderpositions.values_list('pk', flat=True): + if op_pk == link_orderposition.pk: + already_found = True + continue + else: + medium.log_action( + 'pretix.reusable_medium.linked_orderposition.removed', + data={ + 'linked_orderposition': op_pk, + } + ) + if not already_found: + medium.linked_orderpositions.set([link_orderposition]) + medium.log_action( + 'pretix.reusable_medium.linked_orderposition.added', + user=user, + auth=auth, + data={ + 'linked_orderposition': link_orderposition, + } + ) + + link_orderposition.order.log_action( + 'pretix.reusable_medium.exchanged', + data={ + 'position': link_orderposition.pk, + 'positionid': link_orderposition.positionid, + 'medium': medium.pk, + 'medium_identifier': medium.identifier, + 'medium_type': medium.media_type.identifier, + } + ) + medium.touch() + + return medium diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index c80d735f2a..dc73363301 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -67,9 +67,9 @@ from pretix.base.email import get_email_context from pretix.base.i18n import get_language_without_region, language from pretix.base.media import MEDIA_TYPES from pretix.base.models import ( - CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership, - Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, - Voucher, + CartPosition, Device, Event, GiftCard, Item, ItemVariation, LogEntry, + Membership, Order, OrderPayment, OrderPosition, Quota, Seat, + SeatCategoryMapping, User, Voucher, ) from pretix.base.models.event import SubEvent from pretix.base.models.orders import ( @@ -727,8 +727,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti _check_date(event, time_machine_now_dt) products_seen = Counter() - q_avail = Counter() - v_avail = Counter() v_usages = Counter() v_budget = {} deleted_positions = set() @@ -793,6 +791,9 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti shared_lock_objects=[event] ) + q_avail = Counter() + v_avail = Counter() + # Check maximum order size limit = min(int(event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE) if sum(1 for cp in sorted_positions if not cp.addon_to) > limit: @@ -1618,7 +1619,7 @@ class OrderChangeManager: MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership')) CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff')) AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership', - 'valid_from', 'valid_until', 'is_bundled', 'result')) + 'valid_from', 'valid_until', 'is_bundled', 'result', 'count')) SplitOperation = namedtuple('SplitOperation', ('position',)) FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff')) AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff')) @@ -1632,16 +1633,24 @@ class OrderChangeManager: ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple()) class AddPositionResult: - _position: Optional[OrderPosition] + _positions: Optional[List[OrderPosition]] def __init__(self): - self._position = None + self._positions = None @property def position(self) -> OrderPosition: - if self._position is None: + if self._positions is None: raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.") - return self._position + if len(self._positions) != 1: + raise RuntimeError("More than one position created.") + return self._positions[0] + + @property + def positions(self) -> List[OrderPosition]: + if self._positions is None: + raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.") + return self._positions def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False): self.order = order @@ -1848,8 +1857,12 @@ class OrderChangeManager: def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None, subevent: SubEvent = None, seat: Seat = None, membership: Membership = None, - valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult': + valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult': + if count < 1: + raise ValueError("Count must be positive") if isinstance(seat, str): + if count > 1: + raise ValueError("Cannot combine count > 1 with seat") if not seat: seat = None else: @@ -1903,14 +1916,14 @@ class OrderChangeManager: if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'): self._invoice_dirty = True - self._totaldiff_guesstimate += price.gross - self._quotadiff.update(new_quotas) + self._totaldiff_guesstimate += price.gross * count + self._quotadiff.update({q: count for q in new_quotas}) if seat: self._seatdiff.update([seat]) result = self.AddPositionResult() self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership, - valid_from, valid_until, is_bundled, result)) + valid_from, valid_until, is_bundled, result, count)) return result def split(self, position: OrderPosition): @@ -2530,29 +2543,35 @@ class OrderChangeManager: secret_dirty.remove(position) position.save(update_fields=['canceled', 'secret']) elif isinstance(op, self.AddOperation): - pos = OrderPosition.objects.create( - item=op.item, variation=op.variation, addon_to=op.addon_to, - price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, - tax_value=op.price.tax, tax_rule=op.item.tax_rule, - positionid=nextposid, subevent=op.subevent, seat=op.seat, - used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until, - is_bundled=op.is_bundled, - ) - nextposid += 1 - self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ - 'position': pos.pk, - 'item': op.item.pk, - 'variation': op.variation.pk if op.variation else None, - 'addon_to': op.addon_to.pk if op.addon_to else None, - 'price': op.price.gross, - 'positionid': pos.positionid, - 'membership': pos.used_membership_id, - 'subevent': op.subevent.pk if op.subevent else None, - 'seat': op.seat.pk if op.seat else None, - 'valid_from': op.valid_from.isoformat() if op.valid_from else None, - 'valid_until': op.valid_until.isoformat() if op.valid_until else None, - }) - op.result._position = pos + new_pos = [] + new_logs = [] + for i in range(op.count): + pos = OrderPosition.objects.create( + item=op.item, variation=op.variation, addon_to=op.addon_to, + price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, + tax_value=op.price.tax, tax_rule=op.item.tax_rule, + positionid=nextposid, subevent=op.subevent, seat=op.seat, + used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until, + is_bundled=op.is_bundled, + ) + nextposid += 1 + new_pos.append(pos) + new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ + 'position': pos.pk, + 'item': op.item.pk, + 'variation': op.variation.pk if op.variation else None, + 'addon_to': op.addon_to.pk if op.addon_to else None, + 'price': op.price.gross, + 'positionid': pos.positionid, + 'membership': pos.used_membership_id, + 'subevent': op.subevent.pk if op.subevent else None, + 'seat': op.seat.pk if op.seat else None, + 'valid_from': op.valid_from.isoformat() if op.valid_from else None, + 'valid_until': op.valid_until.isoformat() if op.valid_until else None, + }, save=False)) + + op.result._positions = new_pos + LogEntry.bulk_create_and_postprocess(new_logs) elif isinstance(op, self.SplitOperation): position = position_cache.setdefault(op.position.pk, op.position) split_positions.append(position) @@ -2877,7 +2896,7 @@ class OrderChangeManager: return total def _check_order_size(self): - if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: + if (len(self.order.positions.all()) + sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: raise OrderError( self.error_messages['max_order_size'] % { 'max': settings.PRETIX_MAX_ORDER_SIZE, @@ -2938,7 +2957,7 @@ class OrderChangeManager: ]) + len([ o for o in self._operations if isinstance(o, self.SplitOperation) ]) - adds = len([o for o in self._operations if isinstance(o, self.AddOperation)]) + adds = sum([o.count for o in self._operations if isinstance(o, self.AddOperation)]) if current > 0 and current - cancels + adds < 1: raise OrderError(self.error_messages['complete_cancel']) @@ -2985,17 +3004,18 @@ class OrderChangeManager: elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart: fake_cart.remove(positions_to_fake_cart[op.position]) elif isinstance(op, self.AddOperation): - cp = CartPosition( - event=self.event, - item=op.item, - variation=op.variation, - used_membership=op.membership, - subevent=op.subevent, - seat=op.seat, - ) - cp.override_valid_from = op.valid_from - cp.override_valid_until = op.valid_until - fake_cart.append(cp) + for i in range(op.count): + cp = CartPosition( + event=self.event, + item=op.item, + variation=op.variation, + used_membership=op.membership, + subevent=op.subevent, + seat=op.seat, + ) + cp.override_valid_from = op.valid_from + cp.override_valid_until = op.valid_until + fake_cart.append(cp) try: validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode) except ValidationError as e: @@ -3486,7 +3506,7 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs): from pretix.base.models import ReusableMedium for p in order.positions.all(): - if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): + if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW): mt = MEDIA_TYPES[p.item.media_type] if mt.medium_created_by_server and not p.linked_media.exists(): rm = ReusableMedium.objects.create( @@ -3495,8 +3515,8 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs): identifier=mt.generate_identifier(sender.organizer), active=True, customer=order.customer, - linked_orderposition=p, ) + rm.linked_orderpositions.add(p) rm.log_action( 'pretix.reusable_medium.created', data={ diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index d1b4b91cc5..ec6224e768 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -100,7 +100,7 @@ def primary_font_kwargs(): choices = [('Open Sans', 'Open Sans')] choices += sorted([ - (a, {"title": a, "data": v}) for a, v in get_fonts(pdf_support_required=False).items() + (a, FontSelect.FontOption(title=a, data=v)) for a, v in get_fonts(pdf_support_required=False).items() ], key=lambda a: a[0]) return { 'choices': choices, @@ -211,12 +211,25 @@ DEFAULTS = { 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, 'form_kwargs': dict( - label=_("Activate re-usable media"), - help_text=_("The re-usable media feature allows you to connect tickets and gift cards with physical media " - "such as wristbands or chip cards that may be re-used for different tickets or gift cards " + label=_("Activate reusable media"), + help_text=_("The reusable media feature allows you to connect tickets and gift cards with physical media " + "such as wristbands or chip cards that may be reused for different tickets or gift cards " "later.") ) }, + 'reusable_media_usage_enforced': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Enforce the usage of issued reusable media for check-in"), + help_text=_("If enabled, a ticket barcode will not be accepted anymore, if a reusable medium has been " + "created and linked to a ticket. Keeping this option turned off will treat the reusable " + "medium and ticket as equals."), + widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-reusable_media_active'}), + ) + }, 'reusable_media_type_barcode': { 'default': 'False', 'type': bool, @@ -574,6 +587,7 @@ DEFAULTS = { ('True', _('Based on European Central Bank daily rates, whenever the invoice recipient is in an EU ' 'country that uses a different currency.')), ('CZK', _('Based on Czech National Bank daily rates, whenever the invoice amount is not in CZK.')), + ('PLN', _('Based on National Bank of Poland daily rates, whenever the invoice amount is not in PLN.')), ), ), 'serializer_kwargs': dict( @@ -582,6 +596,7 @@ DEFAULTS = { ('True', _('Based on European Central Bank daily rates, whenever the invoice recipient is in an EU ' 'country that uses a different currency.')), ('CZK', _('Based on Czech National Bank daily rates, whenever the invoice amount is not in CZK.')), + ('PLN', _('Based on National Bank of Poland daily rates, whenever the invoice amount is not in PLN.')), ), ), }, @@ -4152,6 +4167,14 @@ def validate_event_settings(event, settings_dict): ) ]} ) + if ( + settings_dict.get('invoice_address_from_vat_id') and + settings_dict.get('invoice_address_from_country') and + settings_dict.get('invoice_address_from_country') not in VAT_ID_COUNTRIES + ): + raise ValidationError({ + 'invoice_address_from_vat_id': _('VAT-ID is not supported for "{}".').format(settings_dict.get('invoice_address_from_country')) + }) payment_term_last = settings_dict.get('payment_term_last') if payment_term_last and event.presale_end: diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index a5682576e4..108b24b0f6 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -32,6 +32,7 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. +import logging import warnings from typing import Any, Callable, Generic, List, Tuple, TypeVar @@ -48,6 +49,8 @@ from .plugins import ( PLUGIN_LEVEL_ORGANIZER, ) +logger = logging.getLogger(__name__) + app_cache = {} T = TypeVar('T') @@ -60,23 +63,25 @@ def _populate_app_cache(): def get_defining_app(o): # If sentry packed this in a wrapper, unpack that - if "sentry" in o.__module__: + module = getattr(o, "__module__", None) + if module and "sentry" in module: o = o.__wrapped__ if hasattr(o, "__mocked_app"): return o.__mocked_app # Find the Django application this belongs to - searchpath = o.__module__ + searchpath = module or getattr(o.__class__, "__module__", None) or "" # Core modules are always active - if any(searchpath.startswith(cm) for cm in settings.CORE_MODULES): + if searchpath and any(searchpath.startswith(cm) for cm in settings.CORE_MODULES): return 'CORE' if not app_cache: _populate_app_cache() - while True: + app = None + while searchpath: app = app_cache.get(searchpath) if "." not in searchpath or app: break @@ -157,7 +162,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal): if not app_cache: _populate_app_cache() - for receiver in self._sorted_receivers(sender): + for receiver in self._live_receivers(sender)[0]: if self._is_receiver_active(sender, receiver): response = receiver(signal=self, sender=sender, **named) responses.append((receiver, response)) @@ -179,7 +184,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal): if not app_cache: _populate_app_cache() - for receiver in self._sorted_receivers(sender): + for receiver in self._live_receivers(sender)[0]: if self._is_receiver_active(sender, receiver): named[chain_kwarg_name] = response response = receiver(signal=self, sender=sender, **named) @@ -204,7 +209,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal): if not app_cache: _populate_app_cache() - for receiver in self._sorted_receivers(sender): + for receiver in self._live_receivers(sender)[0]: if self._is_receiver_active(sender, receiver): try: response = receiver(signal=self, sender=sender, **named) @@ -214,17 +219,35 @@ class PluginSignal(Generic[T], django.dispatch.Signal): responses.append((receiver, response)) return responses - def _sorted_receivers(self, sender): - orig_list = self._live_receivers(sender) + def asend(self, sender: T, **named): + raise NotImplementedError() # NOQA + + def asend_robust(self, sender: T, **named): + raise NotImplementedError() # NOQA + + def _live_receivers(self, sender): + orig_list, orig_async_list = super()._live_receivers(sender) + + if orig_async_list: + logger.error('Async receivers are not supported.') + raise NotImplementedError + + def _getattr_fallback_to_class(obj, key): + return getattr(obj, key, getattr(obj.__class__, key)) + + def _is_core_module(receiver): + m = _getattr_fallback_to_class(receiver, "__module__") + return any(m.startswith(c) for c in settings.CORE_MODULES) + sorted_list = sorted( orig_list, key=lambda receiver: ( - 0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1, - receiver.__module__, - receiver.__name__, + 0 if _is_core_module(receiver) else 1, + _getattr_fallback_to_class(receiver, "__module__"), + _getattr_fallback_to_class(receiver, "__name__"), ) ) - return sorted_list + return sorted_list, [] class EventPluginSignal(PluginSignal[Event]): @@ -300,23 +323,41 @@ class GlobalSignal(django.dispatch.Signal): if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS: return response - for receiver in self._live_receivers(sender): + for receiver in self._live_receivers(sender)[0]: named[chain_kwarg_name] = response response = receiver(signal=self, sender=sender, **named) return response + def asend(self, sender: T, **named): + raise NotImplementedError() # NOQA + + def asend_robust(self, sender: T, **named): + raise NotImplementedError() # NOQA + def _live_receivers(self, sender): # Ensure consistent sorting of receivers - orig_list = super()._live_receivers(sender) + orig_list, orig_async_list = super()._live_receivers(sender) + + if orig_async_list: + logger.error('Async receivers are not supported.') + raise NotImplementedError + + def _getattr_fallback_to_class(obj, key): + return getattr(obj, key, getattr(obj.__class__, key)) + + def _is_core_module(receiver): + m = _getattr_fallback_to_class(receiver, "__module__") + return any(m.startswith(c) for c in settings.CORE_MODULES) + sorted_list = sorted( orig_list, key=lambda receiver: ( - 0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1, - receiver.__module__, - receiver.__name__, + 0 if _is_core_module(receiver) else 1, + _getattr_fallback_to_class(receiver, "__module__"), + _getattr_fallback_to_class(receiver, "__name__"), ) ) - return sorted_list + return sorted_list, [] class DeprecatedSignal(GlobalSignal): diff --git a/src/pretix/base/templates/error.html b/src/pretix/base/templates/error.html index 2dcc3e9fa4..333c8267b0 100644 --- a/src/pretix/base/templates/error.html +++ b/src/pretix/base/templates/error.html @@ -11,6 +11,7 @@ + {% block custom_header %}{% endblock %} {% if css_theme %} @@ -21,5 +22,4 @@ {% block content %}{% endblock %} - diff --git a/src/pretix/base/templates/pretixbase/email/notification.html b/src/pretix/base/templates/pretixbase/email/notification.html index cb7d9e7e96..76b70c2ddb 100644 --- a/src/pretix/base/templates/pretixbase/email/notification.html +++ b/src/pretix/base/templates/pretixbase/email/notification.html @@ -55,10 +55,12 @@ {% trans "You receive these emails based on your notification settings." %}
{% trans "Click here to view and change your notification settings" %} -
- - {% trans "Click here disable all notifications immediately." %} + {% if disable_url %}
+ + {% trans "Click here disable all notifications immediately." %} + + {% endif %} +
+ + const baseUrl = `${host}/${org}/${event}` + + document.getElementById('widget-css').href = `${baseUrl}/widget/v2.css` + + const el = document.createElement(type === 'button' ? 'pretix-button' : 'pretix-widget') + el.setAttribute('event', `${baseUrl}/`) + if (type === 'button') { + el.textContent = params.get('button-text') || 'Buy tickets!' + } + for (const [key, value] of params) { + if (knownParams.has(key)) continue + el.setAttribute(key, value) + } + document.getElementById('widget-container').appendChild(el) + + const script = document.createElement('script') + if (mode === 'prod') { + Object.assign(script, { type: 'text/javascript', src: `${host}/widget/v2.${lang}.js`, async: true, crossOrigin: 'anonymous' }) + } else { + Object.assign(script, { type: 'module', src: '/src/main.ts' }) + } + document.body.appendChild(script) + } + diff --git a/src/pretix/static/pretixpresale/widget/src/api.ts b/src/pretix/static/pretixpresale/widget/src/api.ts index be419ed6dd..23650186c9 100644 --- a/src/pretix/static/pretixpresale/widget/src/api.ts +++ b/src/pretix/static/pretixpresale/widget/src/api.ts @@ -83,3 +83,11 @@ export async function checkAsyncTask (url: string) { } return await response.json() as CartResponse } + +export async function createCart (url: string) { + const response = await fetch(url) + if (!response.ok) { + throw new ApiError(response.status, response.url) + } + return await response.json() as CartResponse +} diff --git a/src/pretix/static/pretixpresale/widget/src/button.ts b/src/pretix/static/pretixpresale/widget/src/button.ts index a04d687710..2905bb7603 100644 --- a/src/pretix/static/pretixpresale/widget/src/button.ts +++ b/src/pretix/static/pretixpresale/widget/src/button.ts @@ -39,7 +39,8 @@ export function createButtonInstance (element: Element, htmlId?: string): App { htmlId: htmlId || element.id || makeid(16), isButton: true, buttonItems, - buttonText: element.innerHTML + buttonText: element.innerHTML, + keepCart: 'keep-cart' in element.attributes || buttonItems.length > 0, }) const observer = new MutationObserver((mutationList) => { @@ -54,13 +55,6 @@ export function createButtonInstance (element: Element, htmlId?: string): App { } }) - // TODO I don't think we need this anymore in vue3 - // if (element.tagName !== 'pretix-button') { - // element.innerHTML = '' + element.innerHTML + '' - // // Vue does not replace the container, so watch container as well - // observer.observe(element, observerOptions) - // } - const app = createApp(ButtonComponent) app.provide(StoreKey, store) app.config.errorHandler = (error, _vm, info) => { diff --git a/src/pretix/static/pretixpresale/widget/src/components/PriceBox.vue b/src/pretix/static/pretixpresale/widget/src/components/PriceBox.vue index a89750f85a..3ce01162cb 100644 --- a/src/pretix/static/pretixpresale/widget/src/components/PriceBox.vue +++ b/src/pretix/static/pretixpresale/widget/src/components/PriceBox.vue @@ -93,11 +93,11 @@ const showTaxline = computed(() => props.price.rate !== '0.00' && props.price.gr span(v-if="!freePrice && !originalPrice", v-html="priceline") span(v-if="!freePrice && originalPrice") del.pretix-widget-pricebox-original-price(:aria-label="originalPriceAriaLabel", v-html="originalLine") - | + |!{' '} ins.pretix-widget-pricebox-new-price(:aria-label="newPriceAriaLabel", v-html="priceline") div(v-if="freePrice") span.pretix-widget-pricebox-currency(:id="priceBoxId") {{ store.currency }} - | + |!{' '} input.pretix-widget-pricebox-price-input( type="number", placeholder="0", diff --git a/src/pretix/static/pretixpresale/widget/src/main.ts b/src/pretix/static/pretixpresale/widget/src/main.ts index 3626d9f20b..66b6ed1521 100644 --- a/src/pretix/static/pretixpresale/widget/src/main.ts +++ b/src/pretix/static/pretixpresale/widget/src/main.ts @@ -48,10 +48,6 @@ window.PretixWidget = { } async function buildWidgets () { - // TODO what does this do? - document.createElement('pretix-widget') - document.createElement('pretix-button') - await docReady() const widgetElements = document.querySelectorAll('pretix-widget, div.pretix-widget-compat') for (const [i, el] of Array.from(widgetElements).entries()) { @@ -96,6 +92,7 @@ function openWidget ( isButton: true, buttonItems: items ?? [], buttonText: '', + keepCart: true }) const app = createApp(ButtonComponent) diff --git a/src/pretix/static/pretixpresale/widget/src/sharedStore.ts b/src/pretix/static/pretixpresale/widget/src/sharedStore.ts index 8eb69466d5..f43398316b 100644 --- a/src/pretix/static/pretixpresale/widget/src/sharedStore.ts +++ b/src/pretix/static/pretixpresale/widget/src/sharedStore.ts @@ -1,9 +1,9 @@ import { nextTick, type InjectionKey } from 'vue' import { createStore } from '~/lib/store' -import { fetchProductList, submitCart, checkAsyncTask, ApiError } from '~/api' +import { fetchProductList, submitCart, checkAsyncTask, ApiError, createCart } from '~/api' import type { CartResponse } from '~/api' import { STRINGS } from '~/i18n' -import { setCookie, getCookie, makeid } from '~/utils' +import { setCookie, getCookie, makeid, siteIsSecure } from '~/utils' import type { Category, DayEntry, EventEntry, LightboxState, MetaFilterField, WidgetData } from '~/types' export const globalWidgetId = makeid(16) @@ -28,6 +28,7 @@ export function createWidgetStore (config: { variations?: string | null widgetData: WidgetData htmlId: string + keepCart: boolean // Button-specific buttonItems?: { item: string; count: string }[] buttonText?: string @@ -54,6 +55,7 @@ export function createWidgetStore (config: { widgetData: config.widgetData, widgetId: `pretix-widget-${globalWidgetId}`, htmlId: config.htmlId, + keepCart: config.keepCart, // View state view: null as 'event' | 'events' | 'weeks' | 'days' | null, @@ -74,7 +76,7 @@ export function createWidgetStore (config: { displayAddToCart: false, waitingListEnabled: false, showVariationsExpanded: !!config.variations, - cartId: null as string | null, + _cartId: null as string | null, cartExists: false, vouchersExist: false, hasSeatingPlan: false, @@ -123,13 +125,18 @@ export function createWidgetStore (config: { getters: { useIframe (): boolean { if ((window as any).crossOriginIsolated === true) return false - return !this.disableIframe && (this.skipSsl || /https.*/.test(document.location.protocol)) + return !this.disableIframe && (this.skipSsl || siteIsSecure()) }, cookieName (): string { return `pretix_widget_${this.targetUrl.replace(/[^a-zA-Z0-9]+/g, '_')}` }, - cartIdFromCookie (): string | null { - return getCookie(this.cookieName) ?? null + cartId (): string | null { + if (this._cartId) { + return this._cartId + } + if (this.keepCart) { + return getCookie(this.cookieName) ?? null + } }, widgetDataJson (): string { const cloned = { ...this.widgetData } @@ -155,7 +162,15 @@ export function createWidgetStore (config: { return params.toString() }, newTabTarget (): string { - return this.subevent ? `${this.targetUrl}${this.subevent}/` : this.targetUrl + let url = this.subevent ? `${this.targetUrl}${this.subevent}/` : this.targetUrl + let parameters = this.consentParameter + if (this.additionalURLParams) { + parameters += `&${this.additionalURLParams}` + } + if (parameters) { + url += '?' + parameters.replace(/^&/, '') + } + return url }, formTarget (): string { const isFirefox = navigator.userAgent.toLowerCase().includes('firefox') @@ -187,12 +202,12 @@ export function createWidgetStore (config: { } let formTarget = `${this.targetUrl}w/${globalWidgetId}/cart/add?iframe=1&next=${encodeURIComponent(checkoutUrl)}` - if (this.cartIdFromCookie) { - formTarget += `&take_cart_id=${this.cartIdFromCookie}` + if (this.cartId) { + formTarget += `&take_cart_id=${this.cartId}` } formTarget += this.consentParameter return formTarget - }, + } }, actions: { triggerLoadCallback () { @@ -219,8 +234,7 @@ export function createWidgetStore (config: { if (this.variationFilter) url += `&variations=${encodeURIComponent(this.variationFilter)}` if (this.voucherCode) url += `&voucher=${encodeURIComponent(this.voucherCode)}` - const cartIdCookie = this.cartIdFromCookie - if (cartIdCookie) url += `&cart_id=${encodeURIComponent(cartIdCookie)}` + if (this.cartId) url += `&cart_id=${encodeURIComponent(this.cartId)}` if (this.date !== null) { url += `&date=${this.date.substring(0, 7)}` } else if (this.week !== null) { @@ -291,7 +305,6 @@ export function createWidgetStore (config: { this.displayAddToCart = data.display_add_to_cart ?? false this.waitingListEnabled = data.waiting_list_enabled ?? false this.showVariationsExpanded = data.show_variations_expanded || !!this.variationFilter - this.cartId = cartIdCookie this.cartExists = data.cart_exists ?? false this.vouchersExist = data.vouchers_exist ?? false this.hasSeatingPlan = data.has_seating_plan ?? false @@ -335,12 +348,13 @@ export function createWidgetStore (config: { this.loading-- this.triggerLoadCallback() } + throw e } }, getVoucherFormTarget (): string { let formTarget = `${this.targetUrl}w/${globalWidgetId}/redeem?iframe=1&locale=${LANG}` - if (this.cartIdFromCookie) { - formTarget += `&take_cart_id=${this.cartIdFromCookie}` + if (this.cartId) { + formTarget += `&take_cart_id=${this.cartId}` } if (this.subevent) { formTarget += `&subevent=${this.subevent}` @@ -357,8 +371,7 @@ export function createWidgetStore (config: { handleCartResponse (data: CartResponse) { if (data.redirect) { if (data.cart_id) { - this.cartId = data.cart_id - setCookie(this.cookieName, data.cart_id, 30) + this.setCartId(data.cart_id) } let url = data.redirect @@ -436,6 +449,7 @@ export function createWidgetStore (config: { this.overlay.frameLoading = false this.overlay.errorUrlAfter = this.newTabTarget this.overlay.errorUrlAfterNewTab = true + return } else if (e.status === 405) { // Likely a redirect! this.targetUrl = e.responseUrl.substring(0, e.responseUrl.indexOf('/cart/add') - 18) @@ -448,6 +462,27 @@ export function createWidgetStore (config: { this.overlay.frameLoading = false } }, + async createCart () { + const url = `${this.targetUrl}w/${globalWidgetId}/cart/create?ajax=1` + + try { + this.overlay.frameLoading = true + const data = await createCart(url) + this.setCartId(data.cart_id) + return true + } catch (e) { + if (e instanceof ApiError && e.status === 429) { + this.overlay.errorMessage = STRINGS.cart_error_429 + this.overlay.frameLoading = false + this.overlay.errorUrlAfter = this.newTabTarget + this.overlay.errorUrlAfterNewTab = true + } else if (e instanceof ApiError && (e.status === 200 || (e.status >= 400 && e.status < 500))) { + this.overlay.errorMessage = STRINGS.cart_error + this.overlay.frameLoading = false + } + return false + } + }, redeem (voucherCode: string, event?: Event) { if (!this.useIframe) return if (event) event.preventDefault() @@ -462,15 +497,24 @@ export function createWidgetStore (config: { window.open(redirectUrl) } }, - resume () { + async resume () { + if (!this.cartId && this.keepCart) { + // create an empty cart whose id we can persist + if (!await this.createCart()) return + } let redirectUrl = `${this.targetUrl}w/${globalWidgetId}/` + if (this.subevent && this.isButton && this.items.length === 0) { + // button with subevent but no items + redirectUrl += `${this.subevent}/` + } if (this.subevent && !this.cartId) { // button with subevent but no items redirectUrl += `${this.subevent}/` } redirectUrl += `?iframe=1&locale=${LANG}` if (this.cartId) { - redirectUrl += `&take_cart_id=${this.cartId}` + // ajax to make sure the cart-id is used, even if the cart is currently empty + redirectUrl += `&take_cart_id=${this.cartId}&ajax=1` } if (this.widgetData) { redirectUrl += `&widget_data=${encodeURIComponent(this.widgetDataJson)}` @@ -523,6 +567,10 @@ export function createWidgetStore (config: { } else { window.open(redirectUrl) } + }, + setCartId (cartId: string) { + this._cartId = cartId + setCookie(this.cookieName, cartId, 30) } } }) diff --git a/src/pretix/static/pretixpresale/widget/src/utils.ts b/src/pretix/static/pretixpresale/widget/src/utils.ts index e4968ab2b0..107130d004 100644 --- a/src/pretix/static/pretixpresale/widget/src/utils.ts +++ b/src/pretix/static/pretixpresale/widget/src/utils.ts @@ -7,7 +7,7 @@ export function setCookie (cname: string, cvalue: string, exdays: number): void const d = new Date() d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000) const expires = `expires=${d.toUTCString()}` - document.cookie = `${cname}=${cvalue};${expires};path=/` + document.cookie = `${cname}=${cvalue};${expires};${siteIsSecure() ? 'SameSite=None;Secure;' : ''}path=/` } export function getCookie (name: string): string | null { diff --git a/src/pretix/static/pretixpresale/widget/src/widget.ts b/src/pretix/static/pretixpresale/widget/src/widget.ts index c4094ab96d..68f01a0bb8 100644 --- a/src/pretix/static/pretixpresale/widget/src/widget.ts +++ b/src/pretix/static/pretixpresale/widget/src/widget.ts @@ -38,6 +38,7 @@ export function createWidgetInstance (element: Element, htmlId?: string): App { variations: element.attributes.variations?.value || null, widgetData, htmlId: htmlId || element.id || makeid(16), + keepCart: true }) const observer = new MutationObserver((mutationList) => { @@ -50,13 +51,6 @@ export function createWidgetInstance (element: Element, htmlId?: string): App { } }) - // TODO I don't think we need this anymore in vue3 - // if (element.tagName !== 'pretix-widget') { - // element.innerHTML = '' - // // we need to watch the container as well as the replaced root-node (see mounted()) - // observer.observe(element, observerOptions) - // } - const app = createApp(WidgetComponent) app.provide(StoreKey, store) app.config.errorHandler = (error, _vm, info) => { diff --git a/src/pretix/testutils/settings.py b/src/pretix/testutils/settings.py index c73440dea7..6cf5f121f4 100644 --- a/src/pretix/testutils/settings.py +++ b/src/pretix/testutils/settings.py @@ -94,6 +94,9 @@ class DisableMigrations(object): def __getitem__(self, item): return None + def setdefault(self, key, default=None): + return + if not os.environ.get("GITHUB_WORKFLOW", ""): MIGRATION_MODULES = DisableMigrations() diff --git a/src/setup.cfg b/src/setup.cfg index d02ab3b0da..2a7b2e13f1 100644 --- a/src/setup.cfg +++ b/src/setup.cfg @@ -29,9 +29,7 @@ filterwarnings = error ignore:.*invalid escape sequence.*: ignore:The 'warn' method is deprecated:DeprecationWarning - ignore::django.utils.deprecation.RemovedInDjango51Warning:django.core.files.storage - ignore:.*index_together.*:django.utils.deprecation.RemovedInDjango51Warning: - ignore:.*get_storage_class.*:django.utils.deprecation.RemovedInDjango51Warning:compressor + ignore::django.utils.deprecation.RemovedInDjango60Warning: ignore:.*This signal will soon be only available for plugins that declare to be organizer-level.*:DeprecationWarning: ignore::DeprecationWarning:mt940 ignore::DeprecationWarning:cbor2 diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 031b6a7c52..a4d617b22a 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -212,4 +212,17 @@ def membership_type(organizer): return organizer.membership_types.create(name='foo') +@pytest.fixture +def clist(event, item): + c = event.checkin_lists.create(name="Default", all_products=False) + c.limit_products.add(item) + return c + + +@pytest.fixture +def clist_all(event, item): + c = event.checkin_lists.create(name="Default", all_products=True) + return c + + utils.setup_databases = scopes_disabled()(utils.setup_databases) diff --git a/src/tests/api/test_checkin.py b/src/tests/api/test_checkin.py index f829eb3422..88115b40e1 100644 --- a/src/tests/api/test_checkin.py +++ b/src/tests/api/test_checkin.py @@ -252,19 +252,6 @@ TEST_HISTORY_RES = { } -@pytest.fixture -def clist(event, item): - c = event.checkin_lists.create(name="Default", all_products=False) - c.limit_products.add(item) - return c - - -@pytest.fixture -def clist_all(event, item): - c = event.checkin_lists.create(name="Default", all_products=True) - return c - - @pytest.mark.django_db def test_list_list(token_client, organizer, event, clist, item, subevent, django_assert_num_queries): res = dict(TEST_LIST_RES) @@ -1111,6 +1098,27 @@ def test_question_upload(token_client, organizer, clist, event, order, question) assert order.positions.first().answers.get(question=question[0]).file +@pytest.mark.django_db +def test_question_upload_optional(token_client, organizer, clist, event, order, question): + with scopes_disabled(): + p = order.positions.first() + question[0].type = 'F' + question[0].required = False + question[0].save() + + resp = _redeem(token_client, organizer, clist, p.pk, {}) + assert resp.status_code == 400 + assert resp.data['status'] == 'incomplete' + with scopes_disabled(): + assert resp.data['questions'] == [QuestionSerializer(question[0]).data] + + resp = _redeem(token_client, organizer, clist, p.pk, {'answers': {question[0].pk: ""}}) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + with scopes_disabled(): + assert not order.positions.first().answers.filter(question=question[0]).exists() + + @pytest.mark.django_db def test_store_failed(token_client, organizer, clist, event, order): with scopes_disabled(): diff --git a/src/tests/api/test_checkinrpc.py b/src/tests/api/test_checkinrpc.py index 44e278a93d..0c5c387a94 100644 --- a/src/tests/api/test_checkinrpc.py +++ b/src/tests/api/test_checkinrpc.py @@ -34,7 +34,7 @@ from tests.const import SAMPLE_PNG from pretix.api.serializers.item import QuestionSerializer from pretix.base.models import ( - Checkin, InvoiceAddress, Order, OrderPosition, ReusableMedium, + Checkin, InvoiceAddress, Item, Order, OrderPosition, ReusableMedium, ) # Lots of this code is overlapping with test_checkin.py, and some of it is arguably redundant since it's triggering @@ -286,12 +286,12 @@ def test_by_secret_special_chars(token_client, organizer, clist, event, order): @pytest.mark.django_db def test_by_medium(token_client, organizer, clist, event, order): with scopes_disabled(): - ReusableMedium.objects.create( + rm = ReusableMedium.objects.create( type="barcode", identifier="abcdef", organizer=organizer, - linked_orderposition=order.positions.first(), ) + rm.linked_orderpositions.add(order.positions.first()) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) assert resp.status_code == 201 assert resp.data['status'] == 'ok' @@ -301,6 +301,71 @@ def test_by_medium(token_client, organizer, clist, event, order): assert ci.raw_source_type == "barcode" +@pytest.mark.django_db +def test_by_medium_multiple_orderpositions(token_client, organizer, clist_all, event, order): + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="barcode", + identifier="abcdef", + organizer=organizer, + ) + op_item_first = order.positions.first() + rm.linked_orderpositions.add(op_item_first) + op_item_other = order.positions.all()[1] + rm.linked_orderpositions.add(op_item_other) + + # multiple tickets are valid => no check-in + resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'ambiguous' + + with scopes_disabled(): + op_item_other.valid_from = datetime.datetime(2020, 1, 1, 12, 0, 0, tzinfo=event.timezone) + op_item_other.valid_until = datetime.datetime(2020, 1, 1, 15, 0, 0, tzinfo=event.timezone) + op_item_other.save() + + with freeze_time("2020-01-01 13:45:00"): + # multiple tickets are valid => no check-in + resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'ambiguous' + + with freeze_time("2020-01-01 10:45:00"): + resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + with freeze_time("2020-01-01 15:45:00"): + resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'already_redeemed' + + with scopes_disabled(): + op_item_first.valid_from = datetime.datetime(2020, 1, 1, 10, 0, 0, tzinfo=event.timezone) + op_item_first.valid_until = datetime.datetime(2020, 1, 1, 12, 0, 0, tzinfo=event.timezone) + op_item_first.save() + + with freeze_time("2020-01-01 15:45:00"): + resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'invalid_time' + + with scopes_disabled(): + op_item_first.canceled = True + op_item_first.save() + op_item_other.canceled = True + op_item_other.save() + + resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'canceled' + + @pytest.mark.django_db def test_by_medium_not_connected(token_client, organizer, clist, event, order): with scopes_disabled(): @@ -318,12 +383,12 @@ def test_by_medium_not_connected(token_client, organizer, clist, event, order): @pytest.mark.django_db def test_by_medium_wrong_event(token_client, organizer, clist, event, order2): with scopes_disabled(): - ReusableMedium.objects.create( + rm = ReusableMedium.objects.create( type="barcode", identifier="abcdef", organizer=organizer, - linked_orderposition=order2.positions.first(), ) + rm.linked_orderpositions.add(order2.positions.first()) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) assert resp.status_code == 404 assert resp.data['status'] == 'error' @@ -337,12 +402,12 @@ def test_by_medium_wrong_event(token_client, organizer, clist, event, order2): @pytest.mark.django_db def test_by_medium_wrong_type(token_client, organizer, clist, event, order): with scopes_disabled(): - ReusableMedium.objects.create( + rm = ReusableMedium.objects.create( type="nfc_uid", identifier="abcdef", organizer=organizer, - linked_orderposition=order.positions.first(), ) + rm.linked_orderpositions.add(order.positions.first()) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) assert resp.status_code == 404 assert resp.data['status'] == 'error' @@ -355,13 +420,13 @@ def test_by_medium_wrong_type(token_client, organizer, clist, event, order): @pytest.mark.django_db def test_by_medium_inactive(token_client, organizer, clist, event, order): with scopes_disabled(): - ReusableMedium.objects.create( + rm = ReusableMedium.objects.create( type="barcode", identifier="abcdef", organizer=organizer, active=False, - linked_orderposition=order.positions.first(), ) + rm.linked_orderpositions.add(order.positions.first()) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) assert resp.status_code == 404 assert resp.data['status'] == 'error' @@ -1188,3 +1253,489 @@ def test_annul_failures(device_client, team, organizer, clist, clist_event2, eve with scopes_disabled(): ci = p.all_checkins.get() assert ci.successful + + +@pytest.mark.django_db +def test_exchange_incomplete_body(token_client, organizer, clist, event, order): + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid" + }) + assert resp.status_code == 400 + assert resp.data == { + 'non_field_errors': ['If you set any of exchange_medium_type or exchange_medium_identifier, you need to set both of them.'] + } + + +@pytest.mark.django_db +def test_exchange_medium_for_medium(token_client, organizer, clist, event, order): + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="barcode", + identifier="abcdef", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.first()) + resp = _redeem(token_client, organizer, clist, "abcdef", { + "source_type": "barcode", + "exchange_medium_type": "barcode", + "exchange_medium_identifier": "hijkl", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'error' + + +@pytest.mark.django_db +def test_exchange_unknown_media_type(token_client, organizer, clist, event, order): + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "unknown", + "exchange_medium_identifier": "hijkl", + }) + assert resp.status_code == 400 + assert resp.data == {"exchange_medium_type": ["\"unknown\" is not a valid choice."]} + + +@pytest.mark.django_db +def test_exchange_disabled_media_type(token_client, organizer, clist, event, order): + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "hijkl", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'error' + assert resp.data['reason_explanation'] == 'Medium type is not enabled for organizer.' + + +@pytest.mark.django_db +def test_exchange_mismatch_media_type(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "barcode" + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'product' + assert resp.data['reason_explanation'] == 'Incorrect medium type for product.' + + +@pytest.mark.django_db +def test_exchange_no_item_policy(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'product' + assert resp.data['reason_explanation'] == 'Product does not support medium exchange.' + + +@pytest.mark.django_db +def test_exchange_reuse_or_new_new(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + with scopes_disabled(): + rm = ReusableMedium.objects.get( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + assert rm.linked_orderpositions.get().secret == "z3fsn8jyufm5kpk768q69gkbyr5f4h6w" + + +@pytest.mark.django_db +def test_exchange_reuse_or_new_reuse_replace(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW + item.save() + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.last()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + rm.refresh_from_db() + with scopes_disabled(): + assert rm.linked_orderpositions.get().secret == "z3fsn8jyufm5kpk768q69gkbyr5f4h6w" + + +@pytest.mark.django_db +def test_exchange_reuse_or_new_reuse_append(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_APPEND_OR_NEW + item.save() + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.last()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + rm.refresh_from_db() + with scopes_disabled(): + assert rm.linked_orderpositions.count() == 2 + assert rm.linked_orderpositions.filter(secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w").exists() + + +@pytest.mark.django_db +def test_exchange_reuse_exists_append(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_APPEND_OR_NEW + item.save() + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.last()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + rm.refresh_from_db() + with scopes_disabled(): + assert rm.linked_orderpositions.count() == 2 + assert rm.linked_orderpositions.filter(secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w").exists() + + +@pytest.mark.django_db +def test_exchange_reuse_expired(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_REUSE + item.save() + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + expires=now() - datetime.timedelta(hours=2), + ) + rm.linked_orderpositions.add(order.positions.last()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'medium_invalid' + + +@pytest.mark.django_db +def test_exchange_reuse_not_exists(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_REUSE + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'medium_invalid' + + +@pytest.mark.django_db +def test_exchange_new_exists(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_NEW + item.save() + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.last()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + "exchange_link_action": "append", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'medium_exists' + + +@pytest.mark.django_db +def test_exchange_new_not_exists(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_NEW + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + "exchange_link_action": "replace", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + with scopes_disabled(): + rm = ReusableMedium.objects.get( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + assert rm.linked_orderpositions.get().secret == "z3fsn8jyufm5kpk768q69gkbyr5f4h6w" + + +@pytest.mark.django_db +def test_exchange_required(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_NEW + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'exchange' + assert resp.data['media_policy'] == 'new' + assert resp.data['media_type'] == 'nfc_uid' + + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.first()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + # Force works + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "force": True, + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + +@pytest.mark.django_db +def test_exchanged_original_barcode_ok(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_NEW + item.save() + + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.first()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + +@pytest.mark.django_db +def test_exchanged_original_barcode_not_ok(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + organizer.settings.reusable_media_usage_enforced = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_NEW + item.save() + + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.first()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'already_exchanged' + # Force works + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "force": True, + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + +@pytest.mark.django_db +def test_exchanged_scan_medium_ok(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + organizer.settings.reusable_media_usage_enforced = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_NEW + item.save() + + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.first()) + resp = _redeem(token_client, organizer, clist, "12345678", { + "source_type": "nfc_uid", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + +@pytest.mark.django_db +def test_exchanged_double_exchange(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + organizer.settings.reusable_media_usage_enforced = False + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_NEW + item.save() + + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.first()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "87654321", + "exchange_link_action": "replace", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'already_exchanged' + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "media_policy,media_type", + [ + (Item.MEDIA_POLICY_NEW, "nfc_mf0aes"), + (Item.MEDIA_POLICY_REUSE_OR_NEW, "nfc_mf0aes"), + (Item.MEDIA_POLICY_APPEND_OR_NEW, "nfc_mf0aes"), + (Item.MEDIA_POLICY_NEW, "barcode"), + (Item.MEDIA_POLICY_REUSE_OR_NEW, "barcode"), + (Item.MEDIA_POLICY_APPEND_OR_NEW, "barcode"), + ] +) +def test_exchange_unsupported_media_type_for_new(token_client, organizer, clist, event, order, item, media_policy, media_type): + organizer.settings.set(f'reusable_media_type_{media_type}', True) + # Shouldn't be configurable, but test that the logic is solid anyway + item.media_type = media_type + item.media_policy = media_policy + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": media_type, + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'medium_invalid' + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "media_policy", + [ + Item.MEDIA_POLICY_NEW, + Item.MEDIA_POLICY_REUSE_OR_NEW, + Item.MEDIA_POLICY_APPEND_OR_NEW, + ] +) +def test_exchange_rejected_media_identifier(token_client, organizer, clist, event, order, item, media_policy): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = media_policy + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "08RANDOM", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'medium_invalid' + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "media_policy", + [ + Item.MEDIA_POLICY_NEW, + Item.MEDIA_POLICY_REUSE_OR_NEW, + Item.MEDIA_POLICY_APPEND_OR_NEW, + ] +) +def test_exchange_create_gift_card(token_client, organizer, clist, event, order, item, media_policy): + organizer.settings.reusable_media_type_nfc_uid = True + organizer.settings.reusable_media_type_nfc_uid_autocreate_giftcard = True + organizer.settings.reusable_media_type_nfc_uid_autocreate_giftcard_currency = "EUR" + item.media_type = "nfc_uid" + item.media_policy = media_policy + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "0412345", + }) + assert resp.status_code == 201 + with scopes_disabled(): + rm = ReusableMedium.objects.get(identifier="0412345") + assert rm.linked_giftcard.currency == "EUR" diff --git a/src/tests/api/test_exporters.py b/src/tests/api/test_exporters.py index 7282def74f..23495065fe 100644 --- a/src/tests/api/test_exporters.py +++ b/src/tests/api/test_exporters.py @@ -1079,3 +1079,18 @@ def test_event_edit_restrictions(client, event, organizer, user, team): assert _get_and_patch_event_export(user2_client, s2) assert _get_and_patch_event_export(team1_client, s2) assert _get_and_patch_event_export(user1_client, s2) + + +@pytest.mark.django_db +def test_event_checkinlist_patch(user_client, organizer, event, user, event_scheduled_export, clist): + event_scheduled_export.export_identifier = "checkinlistpdf" + event_scheduled_export.save() + + resp = user_client.patch( + '/api/v1/organizers/{}/events/{}/scheduled_exports/{}/'.format(organizer.slug, event.slug, event_scheduled_export.id), + data={ + "export_form_data": {"list": clist.pk}, + }, + format='json', + ) + assert resp.status_code == 200 diff --git a/src/tests/api/test_giftcards.py b/src/tests/api/test_giftcards.py index 4fdb89cfeb..ef3db84b48 100644 --- a/src/tests/api/test_giftcards.py +++ b/src/tests/api/test_giftcards.py @@ -171,6 +171,35 @@ def test_giftcard_detail_expand(token_client, organizer, event, giftcard): } +@pytest.mark.django_db +def test_giftcard_detail_expand_without_permissions(team, token_client, organizer, event, giftcard): + with scopes_disabled(): + o = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), + sales_channel=event.organizer.sales_channels.get(identifier="web"), + total=14, locale='en' + ) + ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, + personalized=True) + op = o.positions.create(item=ticket, price=Decimal("14")) + giftcard.owner_ticket = op + giftcard.save() + + team.all_event_permissions = False + team.save() + + res = dict(TEST_GC_RES) + res["id"] = giftcard.pk + res["issuance"] = giftcard.issuance.isoformat().replace('+00:00', 'Z') + resp = token_client.get('/api/v1/organizers/{}/giftcards/{}/?expand=owner_ticket'.format(organizer.slug, giftcard.pk)) + assert resp.status_code == 200 + + assert resp.data["owner_ticket"] == { + "id": op.pk, + } + + TEST_GIFTCARD_CREATE_PAYLOAD = { "secret": "DEFABC", "value": "12.00", diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 25ea7cea88..b2aef4737c 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -530,6 +530,7 @@ def test_item_detail_program_times(token_client, organizer, event, team, item, c res["program_times"] = [{ "start": "2017-12-27T00:00:00Z", "end": "2017-12-28T00:00:00Z", + "location": None }] resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk)) @@ -1972,32 +1973,54 @@ def program_time2(item, category): end=datetime(2017, 12, 30, 0, 0, 0, tzinfo=timezone.utc)) +@pytest.fixture +def program_time3(item, category): + return item.program_times.create(start=datetime(2017, 12, 30, 0, 0, 0, tzinfo=timezone.utc), + end=datetime(2017, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + location='Testlocation') + + TEST_PROGRAM_TIMES_RES = { 0: { "start": "2017-12-27T00:00:00Z", "end": "2017-12-28T00:00:00Z", + "location": None, }, 1: { "start": "2017-12-29T00:00:00Z", "end": "2017-12-30T00:00:00Z", + "location": None, + }, + 2: { + "start": "2017-12-30T00:00:00Z", + "end": "2017-12-31T00:00:00Z", + "location": {"en": "Testlocation"}, } } @pytest.mark.django_db -def test_program_times_list(token_client, organizer, event, item, program_time, program_time2): +def test_program_times_list(token_client, organizer, event, item, program_time, program_time2, program_time3): res = dict(TEST_PROGRAM_TIMES_RES) res[0]["id"] = program_time.pk res[1]["id"] = program_time2.pk + res[2]["id"] = program_time3.pk resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug, item.pk)) assert resp.status_code == 200 assert res[0]['start'] == resp.data['results'][0]['start'] assert res[0]['end'] == resp.data['results'][0]['end'] assert res[0]['id'] == resp.data['results'][0]['id'] + assert res[0] == resp.data['results'][0] assert res[1]['start'] == resp.data['results'][1]['start'] assert res[1]['end'] == resp.data['results'][1]['end'] assert res[1]['id'] == resp.data['results'][1]['id'] + assert res[1] == resp.data['results'][1] + assert res[2]['start'] == resp.data['results'][2]['start'] + assert res[2]['end'] == resp.data['results'][2]['end'] + assert res[2]['location'] == resp.data['results'][2]['location'] + assert res[2]['id'] == resp.data['results'][2]['id'] + assert res[2] == resp.data['results'][2] @pytest.mark.django_db @@ -2039,6 +2062,59 @@ def test_program_times_create(token_client, organizer, event, item): assert resp.content.decode() == '{"non_field_errors":["The program end must not be before the program start."]}' +@pytest.mark.django_db +def test_program_times_create_location(token_client, organizer, event, item): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug, item.pk), + { + "start": "2017-12-27T00:00:00Z", + "end": "2017-12-28T00:00:00Z", + "location": { + "en": "Testlocation", + "de": "Testort" + } + }, + format='json' + ) + assert resp.status_code == 201 + with scopes_disabled(): + program_time = ItemProgramTime.objects.get(pk=resp.data['id']) + assert "Testlocation" == program_time.location.localize("en") + assert "Testort" == program_time.location.localize("de") + + +@pytest.mark.django_db +def test_program_times_create_without_location(token_client, organizer, event, item): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug, item.pk), + { + "start": "2017-12-27T00:00:00Z", + "end": "2017-12-28T00:00:00Z" + }, + format='json' + ) + assert resp.status_code == 201 + assert resp.data['location'] is None + with scopes_disabled(): + program_time = ItemProgramTime.objects.get(pk=resp.data['id']) + assert str(program_time.location) == "" + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug, item.pk), + { + "start": "2017-12-27T00:00:00Z", + "end": "2017-12-28T00:00:00Z", + "location": None + }, + format='json' + ) + assert resp.status_code == 201 + assert resp.data['location'] is None + with scopes_disabled(): + program_time = ItemProgramTime.objects.get(pk=resp.data['id']) + assert str(program_time.location) == "" + + @pytest.mark.django_db def test_program_times_update(token_client, organizer, event, item, program_time): resp = token_client.patch( diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index fe0377946c..e055231b17 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -3121,9 +3121,68 @@ def test_order_create_use_medium(token_client, organizer, event, item, quota, qu with scopes_disabled(): o = Order.objects.get(code=resp.data['code']) medium.refresh_from_db() - assert o.positions.first() == medium.linked_orderposition + assert o.positions.first() == medium.linked_orderpositions.first() assert resp.data['positions'][0]['pdf_data']['medium_identifier'] == medium.identifier + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + medium.refresh_from_db() + assert medium.linked_orderpositions.count() == 1 + assert o.positions.first() == medium.linked_orderpositions.first() + assert resp.data['positions'][0]['pdf_data']['medium_identifier'] == medium.identifier + + +@pytest.mark.django_db +def test_order_create_add_to_medium(token_client, organizer, event, item, quota, question, medium): + item.media_type = medium.type + item.media_policy = Item.MEDIA_POLICY_APPEND_OR_NEW + item.save() + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['use_reusable_medium'] = medium.pk + res['positions'][0]['answers'][0]['question'] = question.pk + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + medium.refresh_from_db() + assert medium.linked_orderpositions.count() == 1 + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + medium.refresh_from_db() + assert medium.linked_orderpositions.count() == 2 + + item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW + item.save() + res['positions'][0]['use_reusable_medium'] = medium.pk + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + medium.refresh_from_db() + assert medium.linked_orderpositions.count() == 1 + assert o.positions.first() == medium.linked_orderpositions.first() + @pytest.mark.django_db def test_order_create_use_medium_other_organizer(token_client, organizer, event, item, quota, question, medium2): @@ -3168,7 +3227,7 @@ def test_order_create_create_medium(token_client, organizer, event, item, quota, i = resp.data['positions'][0]['pdf_data']['medium_identifier'] assert i m = organizer.reusable_media.get(identifier=i) - assert m.linked_orderposition == o.positions.first() + assert m.linked_orderpositions.first() == o.positions.first() assert m.type == "barcode" diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 6d8f2cc144..570178974c 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -2053,7 +2053,7 @@ def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_q assert not resp.data['positions'][0].get('pdf_data') # order list - with django_assert_max_num_queries(33): + with django_assert_max_num_queries(34): resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( organizer.slug, event.slug )) @@ -2068,7 +2068,7 @@ def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_q assert not resp.data['results'][0]['positions'][0].get('pdf_data') # position list - with django_assert_max_num_queries(35): + with django_assert_max_num_queries(36): resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/?pdf_data=true'.format( organizer.slug, event.slug )) diff --git a/src/tests/api/test_reusable_media.py b/src/tests/api/test_reusable_media.py index 30614e67be..2646d2a2dd 100644 --- a/src/tests/api/test_reusable_media.py +++ b/src/tests/api/test_reusable_media.py @@ -89,10 +89,13 @@ TEST_MEDIUM_RES = { "organizer": "dummy", "identifier": "ABCDEFGH", "type": "barcode", + "claim_token": None, + "label": None, "active": True, "expires": None, "customer": None, "linked_orderposition": None, + "linked_orderpositions": [], "linked_giftcard": None, "notes": None, "info": {}, @@ -170,7 +173,7 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, personalized=True) op = o.positions.create(item=ticket, price=Decimal("14")) - medium.linked_orderposition = op + medium.linked_orderpositions.add(op) medium.linked_giftcard = giftcard medium.customer = customer medium.save() @@ -252,6 +255,76 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome } +@pytest.mark.django_db +def test_medium_detail_event_permission_missing(token_client, organizer, event, medium, giftcard, customer, team): + team.all_organizer_permissions = False + team.limit_organizer_permissions = { + "organizer.reusablemedia:read": True, + "organizer.customers:read": True, + "organizer.giftcards:read": True, + } + team.all_event_permissions = False + team.save() + + with scopes_disabled(): + o = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), + sales_channel=event.organizer.sales_channels.get(identifier="web"), + total=14, locale='en' + ) + ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, + personalized=True) + op = o.positions.create(item=ticket, price=Decimal("14")) + medium.linked_orderpositions.add(op) + medium.linked_giftcard = giftcard + medium.customer = customer + medium.save() + giftcard.owner_ticket = op + giftcard.save() + + resp = token_client.get( + '/api/v1/organizers/{}/reusablemedia/{}/?expand=linked_giftcard&expand=' + 'linked_giftcard.owner_ticket&expand=linked_orderposition&expand=customer'.format( + organizer.slug, medium.pk + ) + ) + assert resp.status_code == 200 + + assert resp.data["linked_orderposition"] == { + "id": op.pk, + } + + assert resp.data["linked_giftcard"] == { + "id": giftcard.pk, + "secret": "ABCDEF", + "issuance": giftcard.issuance.isoformat().replace("+00:00", "Z"), + "value": "23.00", + "currency": "EUR", + "testmode": False, + "expires": None, + "conditions": None, + "owner_ticket": {"id": op.pk}, + "issuer": "dummy", + } + + assert resp.data["customer"] == { + "identifier": customer.identifier, + "external_identifier": None, + "email": "foo@example.org", + "phone": None, + "name": "Foo", + "name_parts": {"_legacy": "Foo"}, + "is_active": True, + "is_verified": False, + "last_login": None, + "date_joined": customer.date_joined.isoformat().replace("+00:00", "Z"), + "locale": "en", + "last_modified": customer.last_modified.isoformat().replace("+00:00", "Z"), + "notes": None + } + + TEST_MEDIUM_CREATE_PAYLOAD = { "type": "barcode", "identifier": "FOOBAR", @@ -282,6 +355,110 @@ def test_medium_create(token_client, organizer, giftcard): assert m.updated > now() - timedelta(minutes=10) +@pytest.mark.django_db +def test_medium_create_linked_orderposition(token_client, organizer, event, org2_event, medium): + with scopes_disabled(): + o = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), + sales_channel=event.organizer.sales_channels.get(identifier="web"), + total=14, locale='en' + ) + ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, + personalized=True) + op = o.positions.create(item=ticket, price=Decimal("14")) + op2 = o.positions.create(item=ticket, price=Decimal("14")) + + org2_o = Order.objects.create( + code='FOO', event=org2_event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), + sales_channel=org2_event.organizer.sales_channels.get(identifier="web"), + total=14, locale='en' + ) + org2_ticket = org2_event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, + personalized=True) + org2_op = org2_o.positions.create(item=org2_ticket, price=Decimal("14")) + + payload = dict(TEST_MEDIUM_CREATE_PAYLOAD) + + # wrong orderposition for organizer + payload['linked_orderposition'] = org2_op.pk + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 400 + + # unkown orderposition + payload['linked_orderposition'] = "unknown" + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 400 + + # create with linked_orderposition + payload['linked_orderposition'] = op.pk + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 201 + with scopes_disabled(): + m = ReusableMedium.objects.get(pk=resp.data['id']) + assert list(m.linked_orderpositions.values_list('pk', flat=True)) == [op.pk] + + # double-check API-response for fallback-values + resp = token_client.get( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, resp.data['id']) + ) + assert resp.status_code == 200 + assert resp.data['linked_orderposition'] == op.pk + assert resp.data['linked_orderpositions'] == [op.pk] + + # create with linked_orderposition and linked_orderpositions (not allowed) + payload['identifier'] = "FOOBAZ" + payload['linked_orderpositions'] = [op.pk, org2_op.pk] + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 400 + + # multiple linked_orderpositions, but from different organizers + del payload['linked_orderposition'] + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 400 + + # multiple linked_orderpositions from same organizer + payload['linked_orderpositions'] = [op.pk, op2.pk] + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 201 + with scopes_disabled(): + m = ReusableMedium.objects.get(pk=resp.data['id']) + assert list(m.linked_orderpositions.values_list('pk', flat=True)) == [op.pk, op2.pk] + + # double-check API-response for fallback-values + resp = token_client.get( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, resp.data['id']) + ) + assert resp.status_code == 200 + assert resp.data['linked_orderposition'] is None + assert resp.data['linked_orderpositions'] == [op.pk, op2.pk] + + @pytest.mark.django_db def test_medium_foreignkeyval(token_client, organizer, giftcard2): payload = dict(TEST_MEDIUM_CREATE_PAYLOAD) @@ -328,6 +505,68 @@ def test_medium_patch(token_client, organizer, event, medium, giftcard, customer assert medium.info == {'test': 2} assert medium.identifier == "ABCDEFGH" + # test patch with linked_orderpositions + with scopes_disabled(): + o = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), + sales_channel=event.organizer.sales_channels.get(identifier="web"), + total=14, locale='en' + ) + ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, + personalized=True) + op = o.positions.create(item=ticket, price=Decimal("14")) + op2 = o.positions.create(item=ticket, price=Decimal("14")) + + resp = token_client.patch( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk), + { + 'linked_orderposition': op.pk, + }, + format='json' + ) + assert resp.status_code == 200 + medium.refresh_from_db() + with scopes_disabled(): + assert list(medium.linked_orderpositions.values_list('pk', flat=True)) == [op.pk] + assert medium.all_logentries().count() == 2 + + resp = token_client.patch( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk), + { + 'linked_orderpositions': [op.pk, op2.pk], + }, + format='json' + ) + assert resp.status_code == 200 + medium.refresh_from_db() + with scopes_disabled(): + assert list(medium.linked_orderpositions.values_list('pk', flat=True)) == [op.pk, op2.pk] + assert medium.all_logentries().count() == 3 + + resp = token_client.patch( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk), + { + 'linked_orderpositions': [op2.pk], + }, + format='json' + ) + assert resp.status_code == 200 + medium.refresh_from_db() + with scopes_disabled(): + assert list(medium.linked_orderpositions.values_list('pk', flat=True)) == [op2.pk] + assert medium.all_logentries().count() == 4 + + resp = token_client.patch( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk), + { + 'linked_orderposition': op.pk, + 'linked_orderpositions': [op.pk, op2.pk], + }, + format='json' + ) + assert resp.status_code == 400 + @pytest.mark.django_db def test_medium_no_deletion(token_client, organizer, event, medium): @@ -468,7 +707,7 @@ def test_medium_lookup_cross_organizer(token_client, organizer, organizer2, org2 ticket = org2_event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, personalized=True) op = o.positions.create(item=ticket, price=Decimal("14")) - medium2.linked_orderposition = op + medium2.linked_orderpositions.add(op) medium2.linked_giftcard = giftcard2 medium2.save() diff --git a/src/tests/base/test_checkin.py b/src/tests/base/test_checkin.py index 04f51d4a65..2fb7559b04 100644 --- a/src/tests/base/test_checkin.py +++ b/src/tests/base/test_checkin.py @@ -1186,7 +1186,7 @@ def test_rules_reasoning_prefer_number_over_date(event, position, clist): @pytest.mark.django_db(transaction=True) def test_position_queries(django_assert_max_num_queries, position, clist): - with django_assert_max_num_queries(13) as captured: + with django_assert_max_num_queries(12) as captured: perform_checkin(position, clist, {}) if 'sqlite' not in settings.DATABASES['default']['ENGINE']: assert any('FOR UPDATE' in s['sql'] for s in captured) diff --git a/src/tests/base/test_event_clone.py b/src/tests/base/test_event_clone.py index 784bd079a4..a003bf54ec 100644 --- a/src/tests/base/test_event_clone.py +++ b/src/tests/base/test_event_clone.py @@ -82,7 +82,11 @@ def test_full_clone_same_organizer(): assert item1.meta_data ItemProgramTime.objects.create(item=item1, start=datetime.datetime(2017, 12, 27, 0, 0, 0, tzinfo=datetime.timezone.utc), - end=datetime.datetime(2017, 12, 28, 0, 0, 0, tzinfo=datetime.timezone.utc)) + end=datetime.datetime(2017, 12, 28, 0, 0, 0, tzinfo=datetime.timezone.utc), + location={ + "en": "Testlocation", + "de": "Testort" + }) assert item1.program_times item2 = event.items.create(category=category, tax_rule=tax_rule, name="T-shirt", default_price=15, hidden_if_item_available=item1) @@ -169,6 +173,7 @@ def test_full_clone_same_organizer(): assert copied_item1.meta_data == item1.meta_data assert copied_item1.program_times.first().start == item1.program_times.first().start assert copied_item1.program_times.first().end == item1.program_times.first().end + assert copied_item1.program_times.first().location == item1.program_times.first().location assert copied_item2.variations.get().meta_data == item2v.meta_data assert copied_item1.hidden_if_available == copied_q2 assert copied_item1.grant_membership_type == membership_type diff --git a/src/tests/base/test_invoices.py b/src/tests/base/test_invoices.py index 6d8ee71f6b..790633df82 100644 --- a/src/tests/base/test_invoices.py +++ b/src/tests/base/test_invoices.py @@ -123,6 +123,8 @@ def env(): ExchangeRate.objects.create(source_date=date.today(), source='eu:ecb:eurofxref-daily', source_currency='EUR', other_currency=currency, rate=rate) ExchangeRate.objects.create(source_date=date.today(), source='cz:cnb:rate-fixing-daily', source_currency='EUR', other_currency='CZK', rate=Decimal('25.0000')) + ExchangeRate.objects.create(source_date=date.today(), source='pl:nbp:table-a', source_currency='EUR', + other_currency='PLN', rate=Decimal('4.2355')) yield event, o @@ -347,6 +349,23 @@ def test_invoice_indirect_currency_conversion(env): assert inv.foreign_currency_source == 'eu:ecb:eurofxref-daily' +@pytest.mark.django_db +def test_invoice_pln_currency_conversion(env): + event, order = env + event.settings.invoice_eu_currencies = 'PLN' + + event.settings.set('invoice_language', 'en') + InvoiceAddress.objects.create(company='Acme Company', street='221B Baker Street', zipcode='12345', city='Warsaw', + country=Country('PL'), vat_id='PL123456780', vat_id_validated=True, order=order, + is_business=True) + + inv = generate_invoice(order) + assert inv.foreign_currency_display == "PLN" + assert inv.foreign_currency_rate == Decimal("4.2355") + assert inv.foreign_currency_rate_date == date.today() + assert inv.foreign_currency_source == 'pl:nbp:table-a' + + @pytest.mark.django_db def test_invoice_czk_currency_conversion(env): event, order = env diff --git a/src/tests/base/test_mail.py b/src/tests/base/test_mail.py index 8ccf0965bf..581710f48e 100644 --- a/src/tests/base/test_mail.py +++ b/src/tests/base/test_mail.py @@ -35,8 +35,11 @@ import datetime import os import re +import socket +from contextlib import contextmanager from decimal import Decimal from email.mime.text import MIMEText +from unittest import mock import pytest from django.conf import settings @@ -591,3 +594,117 @@ def test_attached_ical_localization(env, order): assert len(djmail.outbox) == 1 assert len(djmail.outbox[0].attachments) == 1 assert description in djmail.outbox[0].attachments[0][1] + + +PRIVATE_IPS_RES = [ + [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('10.0.0.3', 443))], + [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('0.0.0.0', 443))], + [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('127.1.1.1', 443))], + [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('192.168.5.3', 443))], + [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('224.0.0.1', 443))], + [(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('::1', 443, 0, 0))], + [(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('fe80::1', 443, 0, 0))], + [(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('ff00::1', 443, 0, 0))], + [(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('fc00::1', 443, 0, 0))], +] + + +@contextmanager +def assert_mail_connection(res, should_connect, use_ssl): + with ( + mock.patch('socket.socket') as mock_socket, + mock.patch('socket.getaddrinfo', return_value=res), + mock.patch('smtplib.SMTP.getreply', return_value=(220, "")), + mock.patch('smtplib.SMTP.sendmail'), + mock.patch('ssl.SSLContext.wrap_socket') as mock_ssl + ): + yield + + if should_connect: + mock_socket.assert_called_once() + mock_socket.return_value.connect.assert_called_once_with(res[0][-1]) + if use_ssl: + mock_ssl.assert_called_once() + else: + mock_socket.assert_not_called() + mock_socket.return_value.connect.assert_not_called() + mock_ssl.assert_not_called() + + +@pytest.mark.parametrize("res", PRIVATE_IPS_RES) +@pytest.mark.parametrize("use_ssl", [ + True, False +]) +def test_private_smtp_ip(res, use_ssl, settings): + settings.EMAIL_CUSTOM_SMTP_BACKEND = 'pretix.base.email.CheckPrivateNetworkSmtpBackend' + settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = False + with assert_mail_connection(res=res, should_connect=False, use_ssl=use_ssl), pytest.raises(match="Request to .* blocked"): + connection = djmail.get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND, + host="localhost", + use_ssl=use_ssl) + connection.open() + + settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = True + with assert_mail_connection(res=res, should_connect=True, use_ssl=use_ssl): + connection = djmail.get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND, + host="localhost", + use_ssl=use_ssl) + connection.open() + + +@pytest.mark.parametrize("use_ssl", [ + True, False +]) +@pytest.mark.parametrize("allow_private", [ + True, False +]) +def test_public_smtp_ip(use_ssl, allow_private, settings): + settings.EMAIL_CUSTOM_SMTP_BACKEND = 'pretix.base.email.CheckPrivateNetworkSmtpBackend' + settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = allow_private + + with assert_mail_connection(res=[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('8.8.8.8', 443))], should_connect=True, use_ssl=use_ssl): + connection = djmail.get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND, + host="localhost", + use_ssl=use_ssl) + connection.open() + + +@pytest.mark.django_db +@pytest.mark.parametrize("use_ssl", [ + True, False +]) +@pytest.mark.parametrize("allow_private_networks", [ + True, False +]) +@pytest.mark.parametrize("res", PRIVATE_IPS_RES) +def test_send_mail_private_ip(res, use_ssl, allow_private_networks, env): + settings.EMAIL_CUSTOM_SMTP_BACKEND = 'pretix.base.email.CheckPrivateNetworkSmtpBackend' + settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = allow_private_networks + + event, user, organizer = env + event.settings.smtp_use_custom = True + event.settings.smtp_host = "example.com" + event.settings.smtp_use_ssl = use_ssl + event.settings.smtp_use_tls = False + + def send_mail(): + m = OutgoingMail.objects.create( + to=['recipient@example.com'], + subject='Test', + body_plain='Test', + sender='sender@example.com', + event=event + ) + assert m.status == OutgoingMail.STATUS_QUEUED + mail_send_task.apply(kwargs={ + 'outgoing_mail': m.pk, + }, max_retries=0) + m.refresh_from_db() + return m + + with assert_mail_connection(res=res, should_connect=allow_private_networks, use_ssl=use_ssl): + m = send_mail() + if allow_private_networks: + assert m.status == OutgoingMail.STATUS_SENT + else: + assert m.status == OutgoingMail.STATUS_FAILED diff --git a/src/tests/base/test_modelimport_orders.py b/src/tests/base/test_modelimport_orders.py index 2cb57aed68..1c51ab219d 100644 --- a/src/tests/base/test_modelimport_orders.py +++ b/src/tests/base/test_modelimport_orders.py @@ -991,3 +991,30 @@ def test_import_mixed_order_size_consistency(user, event, item): ).get() assert ('Inconsistent data in row 2: Column Email address contains value "a2@example.com", but for this order, ' 'the value has already been set to "a1@example.com".') in str(excinfo.value) + + +@pytest.mark.django_db +@scopes_disabled() +def test_import_line_endings_mix(event, item, user): + # Ensures import works with mixed file endings. + # See Ticket#23230806 where a file to import ends with \r\n + settings = dict(DEFAULT_SETTINGS) + settings['item'] = 'static:{}'.format(item.pk) + + cf = inputfile_factory() + file = cf.file + file.seek(0) + data = file.read() + data = data.replace(b'\n', b'\r') + data = data.rstrip(b'\r\r') + data = data + b'\r\n' + + print(data) + cf.file.save("input.csv", ContentFile(data)) + cf.save() + + import_orders.apply( + args=(event.pk, cf.id, settings, 'en', user.pk) + ) + assert event.orders.count() == 3 + assert OrderPosition.objects.count() == 3 diff --git a/src/tests/base/test_notifications.py b/src/tests/base/test_notifications.py index 31e4386e93..f285f6e05e 100644 --- a/src/tests/base/test_notifications.py +++ b/src/tests/base/test_notifications.py @@ -24,7 +24,6 @@ from decimal import Decimal import pytest from django.core import mail as djmail -from django.db import transaction from django.utils.timezone import now from django_scopes import scope @@ -75,47 +74,42 @@ def user(team): return user -@pytest.fixture -def monkeypatch_on_commit(monkeypatch): - monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t()) - - @pytest.mark.django_db -def test_notification_trigger_event_specific(event, order, user, monkeypatch_on_commit): +def test_notification_trigger_event_specific(event, order, user, django_capture_on_commit_callbacks): djmail.outbox = [] user.notification_settings.create( method='mail', event=event, action_type='pretix.event.order.paid', enabled=True ) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): order.log_action('pretix.event.order.paid', {}) assert len(djmail.outbox) == 1 assert djmail.outbox[0].subject.endswith("DUMMY: Order FOO has been marked as paid.") @pytest.mark.django_db -def test_notification_trigger_global(event, order, user, monkeypatch_on_commit): +def test_notification_trigger_global(event, order, user, django_capture_on_commit_callbacks): djmail.outbox = [] user.notification_settings.create( method='mail', event=None, action_type='pretix.event.order.paid', enabled=True ) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): order.log_action('pretix.event.order.paid', {}) assert len(djmail.outbox) == 1 @pytest.mark.django_db -def test_notification_trigger_global_wildcard(event, order, user, monkeypatch_on_commit): +def test_notification_trigger_global_wildcard(event, order, user, django_capture_on_commit_callbacks): djmail.outbox = [] user.notification_settings.create( method='mail', event=None, action_type='pretix.event.order.changed.*', enabled=True ) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): order.log_action('pretix.event.order.changed.item', {}) assert len(djmail.outbox) == 1 @pytest.mark.django_db -def test_notification_enabled_global_ignored_specific(event, order, user, monkeypatch_on_commit): +def test_notification_enabled_global_ignored_specific(event, order, user, django_capture_on_commit_callbacks): djmail.outbox = [] user.notification_settings.create( method='mail', event=None, action_type='pretix.event.order.paid', enabled=True @@ -123,24 +117,24 @@ def test_notification_enabled_global_ignored_specific(event, order, user, monkey user.notification_settings.create( method='mail', event=event, action_type='pretix.event.order.paid', enabled=False ) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): order.log_action('pretix.event.order.paid', {}) assert len(djmail.outbox) == 0 @pytest.mark.django_db -def test_notification_ignore_same_user(event, order, user, monkeypatch_on_commit): +def test_notification_ignore_same_user(event, order, user, django_capture_on_commit_callbacks): djmail.outbox = [] user.notification_settings.create( method='mail', event=event, action_type='pretix.event.order.paid', enabled=True ) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): order.log_action('pretix.event.order.paid', {}, user=user) assert len(djmail.outbox) == 0 @pytest.mark.django_db -def test_notification_ignore_insufficient_permissions(event, order, user, team, monkeypatch_on_commit): +def test_notification_ignore_insufficient_permissions(event, order, user, team, django_capture_on_commit_callbacks): djmail.outbox = [] team.all_event_permissions = False team.limit_event_permissions = {"event.vouchers:read": True} @@ -148,7 +142,7 @@ def test_notification_ignore_insufficient_permissions(event, order, user, team, user.notification_settings.create( method='mail', event=event, action_type='pretix.event.order.paid', enabled=True ) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): order.log_action('pretix.event.order.paid', {}) assert len(djmail.outbox) == 0 diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index b46745a368..821a4fc5f3 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -28,8 +28,9 @@ from zoneinfo import ZoneInfo import pytest from django.conf import settings from django.core import mail as djmail +from django.db import transaction from django.db.models import F, Sum -from django.test import TestCase, override_settings +from django.test import TestCase, TransactionTestCase, override_settings from django.utils.timezone import make_aware, now from django_countries.fields import Country from django_scopes import scope @@ -1225,12 +1226,6 @@ class DownloadReminderTests(TestCase): assert len(djmail.outbox) == 0 -@pytest.fixture -def class_monkeypatch(request, monkeypatch): - request.cls.monkeypatch = monkeypatch - - -@pytest.mark.usefixtures("class_monkeypatch") class OrderCancelTests(TestCase): def setUp(self): super().setUp() @@ -1258,7 +1253,6 @@ class OrderCancelTests(TestCase): self.order.create_transactions() generate_invoice(self.order) djmail.outbox = [] - self.monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t()) @classscope(attr='o') def test_cancel_canceled(self): @@ -1351,14 +1345,14 @@ class OrderCancelTests(TestCase): self.order.status = Order.STATUS_PAID self.order.save() djmail.outbox = [] - cancel_order(self.order.pk, send_mail=True) - print([s.subject for s in djmail.outbox]) - print([s.to for s in djmail.outbox]) + with self.captureOnCommitCallbacks(execute=True): + cancel_order(self.order.pk, send_mail=True) + assert len(djmail.outbox) == 2 - assert ["invoice@example.org"] == djmail.outbox[0].to - assert any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments]) - assert ["dummy@dummy.test"] == djmail.outbox[1].to - assert not any(["Invoice_" in a[0] for a in djmail.outbox[1].attachments]) + assert ["dummy@dummy.test"] == djmail.outbox[0].to + assert not any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments]) + assert ["invoice@example.org"] == djmail.outbox[1].to + assert any(["Invoice_" in a[0] for a in djmail.outbox[1].attachments]) @classscope(attr='o') def test_cancel_paid_with_too_high_fee(self): @@ -1488,8 +1482,7 @@ class OrderCancelTests(TestCase): assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists() -@pytest.mark.usefixtures("class_monkeypatch") -class OrderChangeManagerTests(TestCase): +class BaseOrderChangeManagerTestCase: def setUp(self): super().setUp() self.o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer') @@ -1552,7 +1545,6 @@ class OrderChangeManagerTests(TestCase): self.seat_a1 = self.event.seats.create(seat_number="A1", product=self.stalls, seat_guid="A1") self.seat_a2 = self.event.seats.create(seat_number="A2", product=self.stalls, seat_guid="A2") self.seat_a3 = self.event.seats.create(seat_number="A3", product=self.stalls, seat_guid="A3") - self.monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t()) def _enable_reverse_charge(self): self.tr7.eu_reverse_charge = True @@ -1566,6 +1558,8 @@ class OrderChangeManagerTests(TestCase): country=Country('AT') ) + +class OrderChangeManagerTests(BaseOrderChangeManagerTestCase, TestCase): @classscope(attr='o') def test_multiple_commits_forbidden(self): self.ocm.change_price(self.op1, Decimal('10.00')) @@ -2406,6 +2400,15 @@ class OrderChangeManagerTests(TestCase): self.ocm.commit() assert self.order.positions.count() == 2 + @classscope(attr='o') + def test_add_item_quota_partial(self): + q1 = self.event.quotas.create(name='Test', size=1) + q1.items.add(self.shirt) + self.ocm.add_position(self.shirt, None, None, None, count=2) + with self.assertRaises(OrderError): + self.ocm.commit() + assert self.order.positions.count() == 2 + @classscope(attr='o') def test_add_item_addon(self): self.shirt.category = self.event.categories.create(name='Add-ons', is_addon=True) @@ -3895,15 +3898,16 @@ class OrderChangeManagerTests(TestCase): @classscope(attr='o') def test_set_valid_until(self): - self.event.settings.ticket_secret_generator = "pretix_sig1" - assign_ticket_secret(self.event, self.op1, force_invalidate=True, save=True) - old_secret = self.op1.secret + with transaction.atomic(): + self.event.settings.ticket_secret_generator = "pretix_sig1" + assign_ticket_secret(self.event, self.op1, force_invalidate=True, save=True) + old_secret = self.op1.secret - dt = make_aware(datetime(2022, 9, 20, 15, 0, 0, 0)) - self.ocm.change_valid_until(self.op1, dt) - self.ocm.commit() - self.op1.refresh_from_db() - assert self.op1.secret != old_secret + dt = make_aware(datetime(2022, 9, 20, 15, 0, 0, 0)) + self.ocm.change_valid_until(self.op1, dt) + self.ocm.commit() + self.op1.refresh_from_db() + assert self.op1.secret != old_secret @classscope(attr='o') def test_unset_valid_from_until(self): @@ -3928,6 +3932,8 @@ class OrderChangeManagerTests(TestCase): assert len(djmail.outbox) == 1 assert len(["Invoice_" in a[0] for a in djmail.outbox[0].attachments]) == 2 + +class OrderChangeManagerTransactionalTests(BaseOrderChangeManagerTestCase, TransactionTestCase): @classscope(attr='o') def test_new_invoice_send_somewhere_else(self): generate_invoice(self.order) @@ -4117,8 +4123,8 @@ def test_giftcard_multiple(event): for p in order.payments.all(): p.payment_provider.execute_payment(None, p) - assert order.payments.get(info__icontains=gc1.pk).amount == Decimal('12.00') - assert order.payments.get(info__icontains=gc2.pk).amount == Decimal('11.00') + assert order.payments.get(amount=Decimal("12.00")).info_data["gift_card"] == gc1.pk + assert order.payments.get(amount=Decimal("11.00")).info_data["gift_card"] == gc2.pk gc1 = GiftCard.objects.get(pk=gc1.pk) assert gc1.value == 0 gc2 = GiftCard.objects.get(pk=gc2.pk) diff --git a/src/tests/base/test_webhooks.py b/src/tests/base/test_webhooks.py index ab5a905dd8..3b342a06cc 100644 --- a/src/tests/base/test_webhooks.py +++ b/src/tests/base/test_webhooks.py @@ -25,7 +25,6 @@ from decimal import Decimal import pytest import responses -from django.db import transaction from django.utils.timezone import now from django_scopes import scopes_disabled @@ -82,14 +81,9 @@ def force_str(v): return v.decode() if isinstance(v, bytes) else str(v) -@pytest.fixture -def monkeypatch_on_commit(monkeypatch): - monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t()) - - @pytest.mark.django_db @responses.activate -def test_webhook_trigger_event_specific(event, order, webhook, monkeypatch_on_commit): +def test_webhook_trigger_event_specific(event, order, webhook, django_capture_on_commit_callbacks): responses.add_callback( responses.POST, 'https://google.com', callback=lambda r: (200, {}, 'ok'), @@ -97,7 +91,7 @@ def test_webhook_trigger_event_specific(event, order, webhook, monkeypatch_on_co match_querystring=None, # https://github.com/getsentry/responses/issues/464 ) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): le = order.log_action('pretix.event.order.paid', {}) assert len(responses.calls) == 1 assert json.loads(force_str(responses.calls[0].request.body)) == { @@ -119,12 +113,12 @@ def test_webhook_trigger_event_specific(event, order, webhook, monkeypatch_on_co @pytest.mark.django_db @responses.activate -def test_webhook_trigger_global(event, order, webhook, monkeypatch_on_commit): +def test_webhook_trigger_global(event, order, webhook, django_capture_on_commit_callbacks): webhook.limit_events.clear() webhook.all_events = True webhook.save() responses.add(responses.POST, 'https://google.com', status=200) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): le = order.log_action('pretix.event.order.paid', {}) assert len(responses.calls) == 1 assert json.loads(force_str(responses.calls[0].request.body)) == { @@ -138,13 +132,13 @@ def test_webhook_trigger_global(event, order, webhook, monkeypatch_on_commit): @pytest.mark.django_db @responses.activate -def test_webhook_trigger_global_wildcard(event, order, webhook, monkeypatch_on_commit): +def test_webhook_trigger_global_wildcard(event, order, webhook, django_capture_on_commit_callbacks): webhook.listeners.create(action_type="pretix.event.order.changed.*") webhook.limit_events.clear() webhook.all_events = True webhook.save() responses.add(responses.POST, 'https://google.com', status=200) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): le = order.log_action('pretix.event.order.changed.item', {}) assert len(responses.calls) == 1 assert json.loads(force_str(responses.calls[0].request.body)) == { @@ -158,30 +152,30 @@ def test_webhook_trigger_global_wildcard(event, order, webhook, monkeypatch_on_c @pytest.mark.django_db @responses.activate -def test_webhook_ignore_wrong_action_type(event, order, webhook, monkeypatch_on_commit): +def test_webhook_ignore_wrong_action_type(event, order, webhook, django_capture_on_commit_callbacks): responses.add(responses.POST, 'https://google.com', status=200) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): order.log_action('pretix.event.order.changed.item', {}) assert len(responses.calls) == 0 @pytest.mark.django_db @responses.activate -def test_webhook_ignore_disabled(event, order, webhook, monkeypatch_on_commit): +def test_webhook_ignore_disabled(event, order, webhook, django_capture_on_commit_callbacks): webhook.enabled = False webhook.save() responses.add(responses.POST, 'https://google.com', status=200) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): order.log_action('pretix.event.order.changed.item', {}) assert len(responses.calls) == 0 @pytest.mark.django_db @responses.activate -def test_webhook_ignore_wrong_event(event, order, webhook, monkeypatch_on_commit): +def test_webhook_ignore_wrong_event(event, order, webhook, django_capture_on_commit_callbacks): webhook.limit_events.clear() responses.add(responses.POST, 'https://google.com', status=200) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): order.log_action('pretix.event.order.changed.item', {}) assert len(responses.calls) == 0 @@ -189,10 +183,10 @@ def test_webhook_ignore_wrong_event(event, order, webhook, monkeypatch_on_commit @pytest.mark.django_db @pytest.mark.xfail(reason="retries can't be tested with celery_always_eager") @responses.activate -def test_webhook_retry(event, order, webhook, monkeypatch_on_commit): +def test_webhook_retry(event, order, webhook, django_capture_on_commit_callbacks): responses.add(responses.POST, 'https://google.com', status=500) responses.add(responses.POST, 'https://google.com', status=200) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): order.log_action('pretix.event.order.paid', {}) assert len(responses.calls) == 2 with scopes_disabled(): @@ -216,9 +210,9 @@ def test_webhook_retry(event, order, webhook, monkeypatch_on_commit): @pytest.mark.django_db @responses.activate -def test_webhook_disable_gone(event, order, webhook, monkeypatch_on_commit): +def test_webhook_disable_gone(event, order, webhook, django_capture_on_commit_callbacks): responses.add(responses.POST, 'https://google.com', status=410) - with transaction.atomic(): + with django_capture_on_commit_callbacks(execute=True): order.log_action('pretix.event.order.paid', {}) assert len(responses.calls) == 1 webhook.refresh_from_db() diff --git a/src/tests/concurrency_tests/conftest.py b/src/tests/concurrency_tests/conftest.py index eea651711b..cbe5cc0ab7 100644 --- a/src/tests/concurrency_tests/conftest.py +++ b/src/tests/concurrency_tests/conftest.py @@ -19,14 +19,13 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import aiohttp import pytest import pytest_asyncio from django.utils.timezone import now from django_scopes import scopes_disabled -from pytz import UTC from pretix.base.models import ( Device, Event, Item, Organizer, Quota, SeatingPlan, @@ -53,7 +52,7 @@ def organizer(): def event(organizer): e = Event.objects.create( organizer=organizer, name='Dummy', slug='dummy', - date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC), + date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=timezone.utc), presale_end=now() + timedelta(days=300), plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf', is_public=True, live=True @@ -109,8 +108,8 @@ def customer(event, membership_type): def membership(event, membership_type, customer): return customer.memberships.create( membership_type=membership_type, - date_start=datetime(2017, 1, 1, 0, 0, tzinfo=UTC), - date_end=datetime(2099, 1, 1, 0, 0, tzinfo=UTC), + date_start=datetime(2017, 1, 1, 0, 0, tzinfo=timezone.utc), + date_end=datetime(2099, 1, 1, 0, 0, tzinfo=timezone.utc), ) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 418c20ab6e..aadca81098 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -27,7 +27,7 @@ from django.core.cache import cache from django.test import override_settings from django.utils import translation from django_scopes import scopes_disabled -from fakeredis import FakeConnection +from fakeredis import FakeRedisConnection from xdist.dsession import DSession from pretix.testutils.mock import get_redis_connection @@ -97,21 +97,21 @@ def fakeredis_client(monkeypatch): 'BACKEND': 'django.core.cache.backends.redis.RedisCache', 'LOCATION': f'redis://127.0.0.1:{redis_port}', 'OPTIONS': { - 'connection_class': FakeConnection + 'connection_class': FakeRedisConnection } }, 'redis_session': { 'BACKEND': 'django.core.cache.backends.redis.RedisCache', 'LOCATION': f'redis://127.0.0.1:{redis_port}', 'OPTIONS': { - 'connection_class': FakeConnection + 'connection_class': FakeRedisConnection } }, 'default': { 'BACKEND': 'django.core.cache.backends.redis.RedisCache', 'LOCATION': f'redis://127.0.0.1:{redis_port}', 'OPTIONS': { - 'connection_class': FakeConnection + 'connection_class': FakeRedisConnection } }, } @@ -131,3 +131,8 @@ def set_lock_namespaces(request): yield else: yield + + +@pytest.fixture +def class_monkeypatch(request, monkeypatch): + request.cls.monkeypatch = monkeypatch diff --git a/src/tests/control/test_auth.py b/src/tests/control/test_auth.py index 7b5fa54978..3a965c9333 100644 --- a/src/tests/control/test_auth.py +++ b/src/tests/control/test_auth.py @@ -385,11 +385,6 @@ class RegistrationFormTest(TestCase): self.assertEqual(response.status_code, 403) -@pytest.fixture -def class_monkeypatch(request, monkeypatch): - request.cls.monkeypatch = monkeypatch - - @pytest.mark.usefixtures("class_monkeypatch") class Login2FAFormTest(TestCase): diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py index d03aa5da05..efd8a64d86 100644 --- a/src/tests/control/test_events.py +++ b/src/tests/control/test_events.py @@ -49,11 +49,6 @@ from tests.base import SoupTest, extract_form_fields from pretix.base.models import Event, LogEntry, Order, Organizer, Team, User -@pytest.fixture -def class_monkeypatch(request, monkeypatch): - request.cls.monkeypatch = monkeypatch - - @pytest.mark.usefixtures("class_monkeypatch") class EventsTest(SoupTest): @scopes_disabled() diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py index d7d37eaaac..4d274e8ca6 100644 --- a/src/tests/control/test_items.py +++ b/src/tests/control/test_items.py @@ -692,7 +692,8 @@ class ItemsTest(ItemFormTest): self.item2.program_times.create(start=datetime.datetime(2017, 12, 27, 0, 0, 0, tzinfo=datetime.timezone.utc), end=datetime.datetime(2017, 12, 28, 0, 0, 0, - tzinfo=datetime.timezone.utc)) + tzinfo=datetime.timezone.utc), + location={"en": "Testlocation", "de": "Testort"}) doc = self.get_doc('/control/event/%s/%s/items/add?copy_from=%d' % (self.orga1.slug, self.event1.slug, self.item2.pk)) data = extract_form_fields(doc.select("form")[0]) @@ -723,6 +724,7 @@ class ItemsTest(ItemFormTest): assert set([str(v.value) for v in i_new.variations.all()]) == set([str(v.value) for v in i_old.variations.all()]) assert i_old.program_times.first().start == i_new.program_times.first().start assert i_old.program_times.first().end == i_new.program_times.first().end + assert i_old.program_times.first().location == i_new.program_times.first().location def test_add_to_existing_quota(self): with scopes_disabled(): diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index c6cae66bc4..f773c5be3d 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -1584,10 +1584,11 @@ class OrderChangeTests(SoupTest): 'add_position-MAX_NUM_FORMS': '100', 'add_position-0-itemvar': str(self.shirt.pk), 'add_position-0-do': 'on', + 'add_position-0-count': '2', 'add_position-0-price': '14.00', }) with scopes_disabled(): - assert self.order.positions.count() == 3 + assert self.order.positions.count() == 4 assert self.order.positions.last().item == self.shirt assert self.order.positions.last().price == 14 diff --git a/src/tests/control/test_organizer.py b/src/tests/control/test_organizer.py index c445e51f7f..5ef89e9001 100644 --- a/src/tests/control/test_organizer.py +++ b/src/tests/control/test_organizer.py @@ -33,11 +33,6 @@ from tests.base import SoupTest, extract_form_fields from pretix.base.models import Event, Organizer, OutgoingMail, Team, User -@pytest.fixture -def class_monkeypatch(request, monkeypatch): - request.cls.monkeypatch = monkeypatch - - @pytest.mark.usefixtures("class_monkeypatch") class OrganizerTest(SoupTest): @scopes_disabled() diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index aaff20d9ee..83f68da876 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -137,6 +137,7 @@ event_urls = [ "subevents/select2", "subevents/add", "subevents/2/delete", + "subevents/2/edit", "subevents/2/", "quotas/", "quotas/2/delete", @@ -360,8 +361,9 @@ event_permission_urls = [ ("event.items:write", "discounts/reorder", 400, HTTP_POST), ("event.items:write", "discounts/add", 200, HTTP_GET), (None, "subevents/", 200, HTTP_GET), - ("event.subevents:write", "subevents/2/", 404, HTTP_GET), - ("event.subevents:write", "subevents/2/", 404, HTTP_POST), + (None, "subevents/2/", 404, HTTP_GET), + ("event.subevents:write", "subevents/2/edit", 404, HTTP_GET), + ("event.subevents:write", "subevents/2/edit", 404, HTTP_POST), ("event.subevents:write", "subevents/2/delete", 404, HTTP_GET), ("event.subevents:write", "subevents/add", 200, HTTP_GET), ("event.subevents:write", "subevents/bulk_add", 200, HTTP_GET), diff --git a/src/tests/control/test_subevents.py b/src/tests/control/test_subevents.py index 273f1b3c17..1089e98912 100644 --- a/src/tests/control/test_subevents.py +++ b/src/tests/control/test_subevents.py @@ -110,9 +110,9 @@ class SubEventsTest(SoupTest): assert se.checkinlist_set.count() == 1 def test_modify(self): - doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/' % self.subevent1.pk) + doc = self.get_doc('/control/event/ccc/30c3/subevents/%d/edit' % self.subevent1.pk) assert doc.select("input[name=quotas-TOTAL_FORMS]") - doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/' % self.subevent1.pk, { + doc = self.post_doc('/control/event/ccc/30c3/subevents/%d/edit' % self.subevent1.pk, { 'name_0': 'SE2', 'active': 'on', 'date_from_0': '2017-07-01', @@ -747,6 +747,92 @@ class SubEventsTest(SoupTest): assert ses[1].date_from.isoformat() == "2018-04-12T11:29:31+00:00" assert ses[-1].date_from.isoformat() == "2019-03-28T12:29:31+00:00" + def test_create_bulk_skip_existing(self): + with scopes_disabled(): + self.event1.subevents.all().delete() + # SubEvent ends at rrule start time + self.event1.subevents.create( + date_from=datetime.datetime(2018, 4, 4, 9, 0, tzinfo=datetime.timezone.utc), + date_to=datetime.datetime(2018, 4, 4, 10, 0, tzinfo=datetime.timezone.utc), + ) + # SubEvent overlaps rrule start + self.event1.subevents.create( + date_from=datetime.datetime(2018, 4, 5, 9, 30, tzinfo=datetime.timezone.utc), + date_to=datetime.datetime(2018, 4, 5, 10, 30, tzinfo=datetime.timezone.utc), + ) + # SubEvent times are same as rrule + self.event1.subevents.create( + date_from=datetime.datetime(2018, 4, 6, 10, 0, tzinfo=datetime.timezone.utc), + date_to=datetime.datetime(2018, 4, 6, 11, 0, tzinfo=datetime.timezone.utc), + ) + # SubEvent starts at rrule end time + self.event1.subevents.create( + date_from=datetime.datetime(2018, 4, 7, 11, 0, tzinfo=datetime.timezone.utc), + date_to=datetime.datetime(2018, 4, 7, 12, 0, tzinfo=datetime.timezone.utc), + ) + # SubEvent overlaps entire rrule time + self.event1.subevents.create( + date_from=datetime.datetime(2018, 4, 8, 9, 0, tzinfo=datetime.timezone.utc), + date_to=datetime.datetime(2018, 4, 8, 12, 0, tzinfo=datetime.timezone.utc), + ) + # SubEvent has before rrule time and no end + self.event1.subevents.create( + date_from=datetime.datetime(2018, 4, 9, 9, 0, tzinfo=datetime.timezone.utc), + ) + existing_events = list(self.event1.subevents.values_list('pk', flat=True)) + + self.event1.settings.timezone = 'Europe/Berlin' + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { + 'rruleformset-TOTAL_FORMS': '1', + 'rruleformset-INITIAL_FORMS': '0', + 'rruleformset-MIN_NUM_FORMS': '0', + 'rruleformset-MAX_NUM_FORMS': '1000', + 'rruleformset-0-end': 'count', + 'rruleformset-0-count': '10', + 'rruleformset-0-interval': '1', + 'rruleformset-0-freq': 'weekly', + 'rruleformset-0-dtstart': '2018-04-03', + 'rruleformset-0-weekly_byweekday': ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'], + 'rruleformset-0-yearly_same': 'on', + 'rruleformset-0-monthly_same': 'on', + 'timeformset-TOTAL_FORMS': '1', + 'timeformset-INITIAL_FORMS': '0', + 'timeformset-MIN_NUM_FORMS': '1', + 'timeformset-MAX_NUM_FORMS': '1000', + 'timeformset-0-time_from': '12:00:00', + 'timeformset-0-time_to': '13:00:00', + 'rruleformset-0-until': '2019-04-03', + 'skip_if_overlap': 'on', + 'name_0': 'Foo', + 'active': 'on', + 'frontpage_text_0': '', + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '50', + 'quotas-0-itemvars': str(self.ticket.pk), + 'checkinlist_set-TOTAL_FORMS': '0', + 'checkinlist_set-INITIAL_FORMS': '0', + 'checkinlist_set-MIN_NUM_FORMS': '0', + 'checkinlist_set-MAX_NUM_FORMS': '1000', + }) + assert doc.select(".alert-success") + with scopes_disabled(): + ses = list(self.event1.subevents.exclude(pk__in=existing_events).order_by('date_from')) + + assert len(ses) == 7 + assert [s.date_from.date().isoformat() for s in ses] == [ + '2018-04-03', + '2018-04-04', + '2018-04-07', + '2018-04-09', + '2018-04-10', + '2018-04-11', + '2018-04-12' + ] + def test_delete_bulk(self): self.subevent2.active = True self.subevent2.save() diff --git a/src/tests/control/test_user.py b/src/tests/control/test_user.py index de58e9579e..a7a8f86c0a 100644 --- a/src/tests/control/test_user.py +++ b/src/tests/control/test_user.py @@ -286,11 +286,6 @@ class UserPasswordChangeTest(SoupTest): assert self.user.needs_password_change is False -@pytest.fixture -def class_monkeypatch(request, monkeypatch): - request.cls.monkeypatch = monkeypatch - - @pytest.mark.usefixtures("class_monkeypatch") class UserSettings2FATest(SoupTest): def setUp(self): diff --git a/src/tests/e2e/conftest.py b/src/tests/e2e/conftest.py index d09a151cc3..053073da6b 100644 --- a/src/tests/e2e/conftest.py +++ b/src/tests/e2e/conftest.py @@ -731,11 +731,13 @@ def event_series(organizer): """Create an event series with multiple subevents, items, and quotas.""" from pretix.base.models import ItemCategory + base_date = _future_dt(days=30, hour=19) + event = Event.objects.create( organizer=organizer, name='Concert Series', slug='concert-series', - date_from=_future_dt(days=30, hour=19), + date_from=base_date, has_subevents=True, currency='EUR', live=True, @@ -760,9 +762,8 @@ def event_series(organizer): ) subevents = [] - base_date = _future_dt(days=30, hour=19) - for i in range(15): + for i in range(20): se = SubEvent.objects.create( event=event, name=f'Concert Night {i + 1}', diff --git a/src/tests/helpers/test_urllib.py b/src/tests/helpers/test_urllib.py new file mode 100644 index 0000000000..b50c129163 --- /dev/null +++ b/src/tests/helpers/test_urllib.py @@ -0,0 +1,97 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-today pretix 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 socket import AF_INET, SOCK_STREAM +from unittest import mock + +import pytest +import requests +from django.test import override_settings +from dns.inet import AF_INET6 +from urllib3.exceptions import HTTPError + + +def test_local_blocked(): + with pytest.raises(HTTPError, match="Request to local address.*"): + requests.get("http://localhost", timeout=0.1) + with pytest.raises(HTTPError, match="Request to local address.*"): + requests.get("https://localhost", timeout=0.1) + + +def test_private_ip_blocked(): + with pytest.raises(HTTPError, match="Request to private address.*"): + requests.get("http://10.0.0.1", timeout=0.1) + with pytest.raises(HTTPError, match="Request to private address.*"): + requests.get("https://10.0.0.1", timeout=0.1) + with pytest.raises(HTTPError, match="Request to RFC 6598 address.*"): + requests.get("https://100.100.100.100", timeout=0.1) + + +@pytest.mark.django_db +@pytest.mark.parametrize("res", [ + [(AF_INET, SOCK_STREAM, 6, '', ('10.0.0.3', 443))], + [(AF_INET, SOCK_STREAM, 6, '', ('0.0.0.0', 443))], + [(AF_INET, SOCK_STREAM, 6, '', ('127.1.1.1', 443))], + [(AF_INET, SOCK_STREAM, 6, '', ('192.168.5.3', 443))], + [(AF_INET, SOCK_STREAM, 6, '', ('224.0.0.1', 443))], + [(AF_INET, SOCK_STREAM, 6, '', ('100.64.0.1', 443))], + [(AF_INET, SOCK_STREAM, 6, '', ('100.100.100.100', 443))], + [(AF_INET6, SOCK_STREAM, 6, '', ('::1', 443, 0, 0))], + [(AF_INET6, SOCK_STREAM, 6, '', ('fe80::1', 443, 0, 0))], + [(AF_INET6, SOCK_STREAM, 6, '', ('ff00::1', 443, 0, 0))], + [(AF_INET6, SOCK_STREAM, 6, '', ('fc00::1', 443, 0, 0))], +]) +def test_dns_resolving_to_local_blocked(res): + with mock.patch('socket.getaddrinfo') as mock_addr: + mock_addr.return_value = res + with pytest.raises(HTTPError, match="Request to (multicast|private|local|RFC 6598) address.*"): + requests.get("https://example.org", timeout=0.1) + with pytest.raises(HTTPError, match="Request to (multicast|private|local|RFC 6598) address.*"): + requests.get("http://example.org", timeout=0.1) + + +def test_dns_remote_allowed(): + class SocketOk(Exception): + pass + + def side_effect(*args, **kwargs): + raise SocketOk + + with mock.patch('socket.getaddrinfo') as mock_addr, mock.patch('socket.socket') as mock_socket: + mock_addr.return_value = [(AF_INET, SOCK_STREAM, 6, '', ('8.8.8.8', 443))] + mock_socket.side_effect = side_effect + with pytest.raises(SocketOk): + requests.get("https://example.org", timeout=0.1) + + +@override_settings(ALLOW_HTTP_TO_PRIVATE_NETWORKS=True) +def test_local_is_allowed(): + class SocketOk(Exception): + pass + + def side_effect(*args, **kwargs): + raise SocketOk + + with mock.patch('socket.getaddrinfo') as mock_addr, mock.patch('socket.socket') as mock_socket: + mock_addr.return_value = [(AF_INET, SOCK_STREAM, 6, '', ('10.0.0.1', 443))] + mock_socket.side_effect = side_effect + with pytest.raises(SocketOk): + requests.get("https://example.org", timeout=0.1) diff --git a/src/tests/plugins/autocheckin/test_api.py b/src/tests/plugins/autocheckin/test_api.py index af5f45762e..5f132d9c65 100644 --- a/src/tests/plugins/autocheckin/test_api.py +++ b/src/tests/plugins/autocheckin/test_api.py @@ -43,7 +43,7 @@ RES_RULE = { "limit_products": [], "limit_variations": [], "all_payment_methods": True, - "limit_payment_methods": set(), + "limit_payment_methods": [], } diff --git a/src/tests/plugins/banktransfer/test_import.py b/src/tests/plugins/banktransfer/test_import.py index ff5547e20f..508d6198bb 100644 --- a/src/tests/plugins/banktransfer/test_import.py +++ b/src/tests/plugins/banktransfer/test_import.py @@ -834,3 +834,56 @@ def test_ambigious_date_with_region(env, job): with scopes_disabled(): assert env[2].payments.last().info_data["date"] == "2016-05-03" + + +@pytest.mark.django_db +def test_event_name_prefix_contains_dash(env, orga_job): + event, user, o1, o2 = env + slugs = ['dummy-2', 'dummy2345'] + for slug in slugs: + event = Event.objects.create( + organizer=event.organizer, name=slug.upper(), slug=slug, + date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal' + ) + with scopes_disabled(): + o1.event = Event.objects.get(slug="dummy2345") + o1.save() + process_banktransfers(orga_job, [{ + 'payer': 'Karla Kundin', + 'reference': f'DUMMY2345-{o1.code}', + 'date': '2016-01-26', + 'amount': '23.00' + }]) + with scopes_disabled(): + job = BankImportJob.objects.last() + t = job.transactions.last() + assert t.state == BankTransaction.STATE_VALID + + +@pytest.mark.xfail +@pytest.mark.django_db +def test_event_name_prefix_multiple_dashes(env, orga_job): + # We decided to ignore the case of multiple events where the event slugs only differ in dashes + # for now. If we change that, switch this test case from xfail to regular test. + + event, user, o1, o2 = env + slugs = ['dummy-2', 'dummy--2', 'dummy2345'] + for slug in slugs: + event = Event.objects.create( + organizer=event.organizer, name=slug.upper(), slug=slug, + date_from=now(), plugins='pretix.plugins.banktransfer,pretix.plugins.paypal' + ) + + with scopes_disabled(): + o1.event = Event.objects.get(slug="dummy-2") + o1.save() + process_banktransfers(orga_job, [{ + 'payer': 'Karla Kundin', + 'reference': f'DUMMY2-{o1.code}', + 'date': '2016-01-26', + 'amount': '23.00' + }]) + with scopes_disabled(): + job = BankImportJob.objects.last() + t = job.transactions.last() + assert t.state == BankTransaction.STATE_VALID diff --git a/src/tests/plugins/sendmail/test_rules.py b/src/tests/plugins/sendmail/test_rules.py index 58f26aaae2..7b33f063f7 100644 --- a/src/tests/plugins/sendmail/test_rules.py +++ b/src/tests/plugins/sendmail/test_rules.py @@ -25,10 +25,11 @@ from zoneinfo import ZoneInfo import pytest from django.core import mail as djmail +from django.db.models import F from django.utils.timezone import now from django_scopes import scopes_disabled -from pretix.base.models import InvoiceAddress, Order +from pretix.base.models import Event, InvoiceAddress, Order from pretix.base.services.checkin import perform_checkin from pretix.plugins.sendmail.models import Rule, ScheduledMail from pretix.plugins.sendmail.signals import sendmail_run_rules @@ -687,3 +688,41 @@ def test_sendmail_context_localization(event, order, pos): sendmail_run_rules(None) assert "Hallo Herr Mustermann" in djmail.outbox[0].body + + +@pytest.mark.django_db +@scopes_disabled() +def test_event_clone_ignores_rules_with_subevent(event, order, pos): + event.has_subevents = True + event.save() + se1 = event.subevents.create(name="subevent 1", date_from=dt_now) + + event.sendmail_rules.create(date_is_absolute=False, send_offset_days=1, send_offset_time=datetime.time(hour=12), + subject='Mail To Subevent', template='TestBody', subevent=se1) + event.sendmail_rules.create(date_is_absolute=False, send_offset_days=1, send_offset_time=datetime.time(hour=12), + subject='Mail To All', template='TestBody') + + assert event.sendmail_rules.count() == 2 + assert event.scheduledmail_set.count() == 2 + for rule in event.sendmail_rules.all(): + assert rule.scheduledmail_set.count() == 1 + + copied_event = Event.objects.create( + organizer=event.organizer, name='Dummy2', slug='dummy2', + date_from=datetime.datetime(2022, 4, 15, 9, 0, 0, tzinfo=datetime.timezone.utc), + has_subevents=True + ) + copied_event.copy_data_from(event) + copied_event.refresh_from_db() + event.refresh_from_db() + + assert copied_event.sendmail_rules.count() == 1 + assert copied_event.sendmail_rules.first().scheduledmail_set.count() == 0 + assert str(copied_event.sendmail_rules.first().subject) == "Mail To All" + + copied_event.subevents.create(name="subevent 1 in copied", date_from=dt_now) + assert copied_event.sendmail_rules.first().scheduledmail_set.count() == 1 + + # Double-Check: no scheduled mails and rules exist where subevent.event and event do not align + assert ScheduledMail.objects.filter(rule__subevent__isnull=False).exclude(rule__subevent__event=F('rule__event')).count() == 0 + assert Rule.objects.filter(subevent__isnull=False).exclude(subevent__event=F('event')).count() == 0 diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index f8a6ebd758..15f99f030e 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -33,7 +33,7 @@ from django.conf import settings from django.core import mail as djmail from django.core.files.uploadedfile import SimpleUploadedFile from django.core.signing import dumps -from django.test import TestCase +from django.test import Client, TestCase, TransactionTestCase from django.utils.crypto import get_random_string from django.utils.timezone import now from django_countries.fields import Country @@ -60,12 +60,6 @@ from pretix.testutils.sessions import get_cart_session_key from .test_timemachine import TimemachineTestMixin -@pytest.fixture -def class_monkeypatch(request, monkeypatch): - request.cls.monkeypatch = monkeypatch - - -@pytest.mark.usefixtures("class_monkeypatch") class BaseCheckoutTestCase: @scopes_disabled() def setUp(self): @@ -104,7 +98,15 @@ class BaseCheckoutTestCase: self.workshopquota.items.add(self.workshop2) self.workshopquota.variations.add(self.workshop2a) self.workshopquota.variations.add(self.workshop2b) - self.monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t()) + + self.parkingcat = ItemCategory.objects.create(name="Parking", is_addon=True, event=self.event) + self.parkingquota = Quota.objects.create(event=self.event, name='Parking', size=5) + self.parking1 = Item.objects.create(event=self.event, name='Premium Parking', + category=self.parkingcat, default_price=Decimal('15.00')) + self.parking2 = Item.objects.create(event=self.event, name='Standard Parking', + category=self.parkingcat, default_price=Decimal('5.00')) + self.parkingquota.items.add(self.parking1) + self.parkingquota.items.add(self.parking2) def _set_session(self, key, value): session = self.client.session @@ -4209,6 +4211,58 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): assert '35.29' in response.content.decode() assert '10.08' in response.content.decode() + def test_set_addons_invalid_initial(self): + self.event.settings.locales = ['de', 'en'] + self.event.settings.locale = 'de' + with scopes_disabled(): + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1) + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.parkingcat, min_count=1) + cp1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10) + ) + self.workshop1.free_price = True + self.workshop1.save() + self.workshop2.free_price = True + self.workshop2.save() + + ws1_val = 'cp_{}_item_{}'.format(cp1.pk, self.workshop1.pk) + ws1_price = 'cp_{}_item_{}_price'.format(cp1.pk, self.workshop1.pk) + ws2a_val = 'cp_{}_variation_{}_{}'.format(cp1.pk, self.workshop2.pk, self.workshop2a.pk) + ws2a_price = 'cp_{}_variation_{}_{}_price'.format(cp1.pk, self.workshop2.pk, self.workshop2a.pk) + p1_val = 'cp_{}_item_{}'.format(cp1.pk, self.parking1.pk) + p2_val = 'cp_{}_item_{}'.format(cp1.pk, self.parking2.pk) + + response = self.client.post('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), { + ws1_val: '1', + ws2a_val: '1', + }) + assert response.status_code == 200 + with scopes_disabled(): + assert cp1.addons.count() == 0 + doc = BeautifulSoup(response.text, 'lxml') + assert doc.find('input', {'name': ws1_val}).attrs.get('checked') + assert doc.find('input', {'name': ws2a_val}).attrs.get('checked') + assert not doc.find('input', {'name': p1_val}).attrs.get('checked') + assert not doc.find('input', {'name': p2_val}).attrs.get('checked') + + response = self.client.post('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), { + ws1_val: '1', + ws1_price: '222,22', + ws2a_val: '1', + ws2a_price: '333.33', + }) + assert response.status_code == 200 + with scopes_disabled(): + assert cp1.addons.count() == 0 + doc = BeautifulSoup(response.text, 'lxml') + assert doc.find('input', {'name': ws1_val}).attrs.get('checked') + assert doc.find('input', {'name': ws1_price}).attrs.get('value') in ['222.22', '222,22'] + assert doc.find('input', {'name': ws2a_val}).attrs.get('checked') + assert doc.find('input', {'name': ws2a_price}).attrs.get('value') in ['333.33', '333,33'] + assert not doc.find('input', {'name': p1_val}).attrs.get('checked') + assert not doc.find('input', {'name': p2_val}).attrs.get('checked') + def test_confirm_subevent_presale_not_yet(self): with scopes_disabled(): self.event.has_subevents = True @@ -4420,6 +4474,20 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): assert len(djmail.outbox) == 1 assert any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments]) + def test_checkout_empty_session_valid_cart(self): + client = Client() + with scopes_disabled(): + api_cid = "{}@api".format(get_random_string(48)) + CartPosition.objects.create( + event=self.event, cart_id=api_cid, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + + response = client.get('/%s/%s/w/1234567890abcdef/checkout/questions/' % (self.orga.slug, self.event.slug), query_params={"take_cart_id": api_cid}) + assert '€23.00' in response.content.decode() + + +class CheckoutTransactionTestCase(BaseCheckoutTestCase, TransactionTestCase): def test_order_confirmation_mail_invoice_sent_somewhere_else(self): self.event.settings.invoice_address_asked = True self.event.settings.invoice_address_required = True diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 58505426e7..5ffd28db39 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -36,6 +36,7 @@ import datetime import re from decimal import Decimal +from importlib import import_module from json import loads from zoneinfo import ZoneInfo @@ -80,6 +81,34 @@ class EventMiddlewareTest(EventTestMixin, SoupTest): doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug)) self.assertIn(str(self.event.name), doc.find("h1").text) + def test_no_session_cookie_set_on_event_index_view(self): + resp = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertEqual(resp.status_code, 200) + assert settings.SESSION_COOKIE_NAME not in self.client.cookies + + def test_no_cart_session_added_on_event_index_view(self): + # Make sure a session is present by doing a cart op on another event + event2 = Event.objects.create( + organizer=self.orga, name='30C3b', slug='30c3b', + date_from=datetime.datetime(now().year + 1, 12, 26, 14, 0, tzinfo=datetime.timezone.utc), + live=True, + ) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, event2.slug), { + 'item_%d' % 1337: '1', # item does not need to exist + 'ajax': 1 + }) + assert settings.SESSION_COOKIE_NAME in self.client.cookies + + # Visit shop, make sure no session is created + resp = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertEqual(resp.status_code, 200) + + SessionStore = import_module(settings.SESSION_ENGINE).SessionStore + session = SessionStore(self.client.cookies[settings.SESSION_COOKIE_NAME].value).load() + assert set(session.keys()) == { + f"current_cart_event_{event2.pk}", "carts" + } + def test_not_found(self): resp = self.client.get('/%s/%s/' % ('foo', 'bar')) self.assertEqual(resp.status_code, 404) @@ -1133,6 +1162,65 @@ class WaitingListTest(EventTestMixin, SoupTest): assert wle.voucher is None assert wle.locale == 'en' + def test_initial_selection(self): + with scopes_disabled(): + cat = ItemCategory.objects.create(event=self.event, name='Tickets') + self.item.category = cat + self.item.save() + + item2 = Item.objects.create( + event=self.event, name='VIP ticket', + default_price=Decimal('25.00'), + active=True, category=cat, + ) + self.q.items.add(item2) + + response = self.client.get( + '/%s/%s/waitinglist/?item=%d' % ( + self.orga.slug, self.event.slug, item2.pk + ) + ) + self.assertEqual(response.status_code, 200) + doc = BeautifulSoup(response.render().content, "lxml") + + select = doc.find('select', {'name': 'itemvar'}) + optgroup = select.find('optgroup') + self.assertIsNotNone(optgroup, 'Choices should be grouped by category') + self.assertEqual(optgroup['label'], 'Tickets') + + selected = select.find_all('option', selected=True) + self.assertEqual(len(selected), 1, 'Exactly one option should be pre-selected') + self.assertEqual(selected[0]['value'], str(item2.pk)) + + def test_initial_selection_with_variation(self): + with scopes_disabled(): + cat = ItemCategory.objects.create(event=self.event, name='Tickets') + self.item.category = cat + self.item.has_variations = True + self.item.save() + + var1 = ItemVariation.objects.create(item=self.item, value='Standard') + var2 = ItemVariation.objects.create(item=self.item, value='Premium') + self.q.variations.add(var1, var2) + + response = self.client.get( + '/%s/%s/waitinglist/?item=%d&var=%d' % ( + self.orga.slug, self.event.slug, + self.item.pk, var2.pk, + ) + ) + self.assertEqual(response.status_code, 200) + doc = BeautifulSoup(response.render().content, "lxml") + + select = doc.find('select', {'name': 'itemvar'}) + optgroup = select.find('optgroup') + self.assertIsNotNone(optgroup, 'Choices should be grouped by category') + self.assertEqual(optgroup['label'], 'Tickets') + + selected = select.find_all('option', selected=True) + self.assertEqual(len(selected), 1, 'Exactly one option should be pre-selected') + self.assertEqual(selected[0]['value'], '%d-%d' % (self.item.pk, var2.pk)) + def test_subevent_valid(self): with scopes_disabled(): self.event.has_subevents = True diff --git a/vite.config.ts b/vite.config.ts index d2dadcba26..b0578d76c3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,28 +1,150 @@ // vite build config for control UI // widget has its own config, see src/pretix/static/pretixpresale/widget/vite.config.ts -import { defineConfig } from 'vite' +import { defineConfig, type Plugin } from 'vite' import vue from '@vitejs/plugin-vue' import path from 'path' +import { execSync } from 'child_process' +import { readFileSync } from 'fs' +import { parse as parseToml } from 'smol-toml' + +// Shared dependencies exposed to plugins via import map. +// Adding a dep here auto-generates a _vendor/{name} entry and +// makes it available in the import map. +const SHARED_DEPS = ['vue'] + +const { entries: pretixPluginEntries } = discoverPretixPlugins() +const pluginDirs = [...new Set(Object.values(pretixPluginEntries).map(p => path.dirname(p)))] export default defineConfig({ plugins: [ - vue() + vue(), + sharedDepsPlugin(), + pretixPluginDevEntries(), ], + resolve: { + // Pin shared deps to pretix's node_modules to prevent duplicate instances + // across plugins whose node_modules live in sibling directories + dedupe: [...SHARED_DEPS, '@vue/runtime-core', '@vue/reactivity', '@vue/shared'], + }, + server: { + fs: { + // Allow serving source files from sibling plugin directories + allow: ['src', ...pluginDirs], + }, + }, build: { manifest: true, outDir: path.resolve(__dirname, 'src/pretix/static.dist/vite/control'), rollupOptions: { + preserveEntrySignatures: 'exports-only', input: { 'webcheckin/main': path.resolve(__dirname, 'src/pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.ts'), 'checkinrules/main': path.resolve(__dirname, 'src/pretix/static/pretixcontrol/js/ui/checkinrules/index.ts'), - 'questionnaires/main': path.resolve(__dirname, 'src/pretix/static/pretixcontrol/js/ui/questionnaires/index.ts'), + ...Object.fromEntries(SHARED_DEPS.map(dep => [`_vendor/${dep}`, `virtual:vendor/${dep}`])), + ...pretixPluginEntries, }, } }, optimizeDeps: { exclude: ['moment', 'jquery'] - }, - server: { - cors: { origin: /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\]|[^:]+\.pretix\.work)(?::\d+)?$/ }, - }, + } }) + +// Virtual module plugin: generates re-export entries for each shared dep +// In dev mode, serves /__pretix_importmap so the python template tag can build the import map without hardcoding dep names. +function sharedDepsPlugin (): Plugin { + const PREFIX = 'virtual:vendor/' + const RESOLVED = '\0virtual:vendor/' + return { + name: 'pretix-shared-deps', + resolveId (id) { + if (id.startsWith(PREFIX)) + return RESOLVED + id.slice(PREFIX.length) + }, + load (id) { + if (id.startsWith(RESOLVED)) { + const pkg = id.slice(RESOLVED.length) + return `export * from '${pkg}'` + } + }, + // Serve the import map data so the Python template tag can fetch it in dev mode + configureServer (server) { + server.middlewares.use((req, res, next) => { + if (req.url === '/__pretix_importmap') { + const imports = Object.fromEntries( + SHARED_DEPS.map(dep => [ + dep, + `/node_modules/.vite/deps/${dep.replace('/', '_')}.js`, + ]) + ) + res.setHeader('Content-Type', 'application/json') + res.setHeader('Access-Control-Allow-Origin', '*') + res.end(JSON.stringify(imports)) + return + } + next() + }) + }, + } +} + +// TODO move to separate file? +function discoverPretixPlugins (): { entries: Record } { + let manifestFiles: string[] = [] + try { + const raw = execSync(`python -c " +import importlib_metadata as metadata, json, pathlib, tomllib +result = [] +for ep in metadata.entry_points(group='pretix.plugin'): + dist = ep.dist + if not dist: continue + try: + url_info = json.loads(dist.read_text('direct_url.json') or '{}') + if not url_info.get('dir_info', {}).get('editable'): + continue # non-editable plugins build their own assets + p = pathlib.Path(url_info['url'].replace('file://', '')) / 'pretixplugin.toml' + if not p.exists(): + continue + with p.open('rb') as f: + if 'vite' in tomllib.load(f): + result.append(str(p)) + except Exception: + pass +print(json.dumps(result)) +"`, { stdio: ['pipe', 'pipe', 'inherit'] }).toString().trim() + manifestFiles = JSON.parse(raw) + } catch (error) { + console.error('Failed to discover pretix plugins, skipping plugin entries:', error) + } + const entries: Record = {} + for (const manifestFile of manifestFiles) { + const packageRoot = manifestFile.replace(/[/\\]pretixplugin\.toml$/, '') + const parsed = parseToml(readFileSync(manifestFile, 'utf8')) as { + vite: { entries: Record } + } + for (const [name, rel] of Object.entries(parsed.vite.entries)) { + entries[name] = path.join(packageRoot, rel) + } + } + return { entries } +} + +// In dev mode, the browser requests /{entryName} from the Vite dev server. +// Vite can't find these files since they live outside the project root. +// This plugin rewrites those URLs to /@fs{absPath} so Vite serves them directly. +function pretixPluginDevEntries (): Plugin { + return { + name: 'pretix-plugin-dev-entries', + configureServer (server) { + server.middlewares.use((req, _res, next) => { + const urlPath = req.url?.split('?')[0] + if (urlPath) { + const name = urlPath.slice(1) // strip leading / + if (name in pretixPluginEntries) + req.url = `/@fs${pretixPluginEntries[name]}` + } + next() + }) + }, + } +}