Compare commits

..
Author SHA1 Message Date
Mira Weller 0630b67546 Improve comment on multidomainurl 2024-05-07 20:53:42 +02:00
Mira Weller 9101238743 Add test cases, fix docs 2024-05-07 20:49:17 +02:00
Mira Weller cefbfc1ad1 Redirect directly to time machine page after session transfer 2024-05-07 12:42:07 +02:00
Mira Weller 69a798046e Create absmainurl template tag, use for session transfer link 2024-05-07 12:38:21 +02:00
Mira Weller c7983bf811 unused imports 2024-04-30 21:55:15 +02:00
Mira Weller 042be3603b use time_machine_now for waiting_list_auto_disable 2024-04-30 21:52:44 +02:00
Mira Weller 1de2320cc5 use time_machine_now for GiftCard, membership and discount validity 2024-04-30 21:49:58 +02:00
Mira Weller 8042d9d3f4 Scope time machine state per event 2024-04-30 21:49:58 +02:00
Mira Weller 44afe9e193 Improve form/error handling 2024-04-30 21:49:58 +02:00
Mira Weller 46dce1bf43 Implement review comments 2024-04-30 21:49:58 +02:00
Mira Weller 4865879978 fontawesome elements have wider characters 2024-04-30 21:49:58 +02:00
4c793076b7 Apply suggestions from code review
Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Raphael Michel <michel@rami.io>
2024-04-30 21:49:58 +02:00
Mira Weller 5294d819a9 flake8 2024-04-30 21:49:58 +02:00
Mira Weller 7bfe94139a Fix exception if parent session expired 2024-04-30 21:49:58 +02:00
Mira Weller e5cbaa9246 Improve docs 2024-04-30 21:49:58 +02:00
Mira Weller 37208286b1 Improve UI 2024-04-30 21:49:58 +02:00
Mira Weller 6da56291d4 Put text on "Time machine" button 2024-04-30 21:49:58 +02:00
Mira Weller 2d335cd095 improve documentation 2024-04-30 21:49:58 +02:00
Mira Weller 569f1719b0 add documentation 2024-04-30 21:49:58 +02:00
Mira Weller 00dd1a5b31 add documentation 2024-04-30 21:49:58 +02:00
Mira Weller 18bebb6d31 Separate event_access user from regular user 2024-04-30 21:49:58 +02:00
Mira Weller 77c8e81cd7 Remove stray print() call 2024-04-30 21:49:58 +02:00
Mira Weller 3d03f30119 Fix bootstrap classes 2024-04-30 21:49:58 +02:00
Mira Weller 91b2d685da Use ContextVar instead of threading.local 2024-04-30 21:49:58 +02:00
Mira Weller 9787ed1820 Move time machine logic into contextmanager 2024-04-30 21:49:58 +02:00
Mira Weller 204b8e53de Changes from review 2024-04-30 21:49:58 +02:00
Mira Weller 64358be4ae Move timemachine controls into presale, implement session transfer for multidomain time machine support 2024-04-30 21:49:58 +02:00
Mira Weller 5b1175ff05 Code formatting 2024-04-30 21:49:58 +02:00
Mira Weller e6f56bfdc2 Fix dynamic validity and add test cases 2024-04-30 21:49:58 +02:00
Mira Weller 9610e9c89f Pass time_machine_now to async tasks 2024-04-30 21:49:58 +02:00
Mira Weller c5f4eeeb28 Distinguish real and time_machine now in Order creation 2024-04-30 21:49:58 +02:00
Mira Weller b61880fb5b Allow passing a fallback now_dt to time_machine_now 2024-04-30 21:49:58 +02:00
Mira Weller b29c7fc11d Enable time machine only in testmode 2024-04-30 21:49:58 +02:00
Mira Weller d99bf7437a Use time_machine_now in more Order related checks and for Order.datetime 2024-04-30 21:49:58 +02:00
Mira Weller 648cc14ae0 Move timemachine to pretix.base, revert package refactor 2024-04-30 21:49:58 +02:00
Mira Weller 5d71cb500a Move timemachine to pretix.base.middleware 2024-04-30 21:49:58 +02:00
Mira Weller 68d81982ba Make pretix.base.middleware a package 2024-04-30 21:49:58 +02:00
Mira Weller efa0d5f362 use time_machine_now for order expiry 2024-04-30 21:49:58 +02:00
Mira Weller 046898678b Improve UX 2024-04-30 21:49:58 +02:00
Mira Weller f38ecd0ec7 remove some print() logging 2024-04-30 21:49:57 +02:00
Mira Weller 3dca6c232e add time machine form to live.html 2024-04-30 21:49:57 +02:00
Mira Weller 297bf566ad fix live.html layout 2024-04-30 21:49:57 +02:00
Mira Weller 67f09b5ede Enable time machine for membership and ticket validity dates 2024-04-30 21:49:57 +02:00
Mira Weller 752137ad84 timemachine 2024-04-30 21:49:57 +02:00
Mira Weller 100528ad0f start implementing time machine mode (thread local) 2024-04-30 21:49:57 +02:00
398 changed files with 164861 additions and 195554 deletions
+16 -16
View File
@@ -1,30 +1,29 @@
before_script:
tests:
image:
name: pretix/ci-image
stage: test
before_script:
- pip install -U pip uv
- uv pip install --system -U wheel setuptools
script:
- uv pip install --system -e ".[dev]"
- virtualenv env
- source env/bin/activate
- pip install -U pip wheel setuptools
- XDG_CACHE_HOME=/cache pip3 install -e ".[dev]"
- cd src
- python manage.py check
- make all compress
- PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg py.test --reruns 3 -n 3 tests --maxfail=100
- py.test --reruns 3 -n 3 tests
tags:
- python3
except:
- pypi
pypi:
stage: release
image:
name: pretix/ci-image
before_script:
- cat $PYPIRC > ~/.pypirc
- pip install -U pip uv
- uv pip install --system -U wheel setuptools twine build pretix-plugin-build check-manifest
script:
- uv pip install --system -e ".[dev]"
- cp /keys/.pypirc ~/.pypirc
- virtualenv env
- source env/bin/activate
- pip install -U pip wheel setuptools check-manifest twine
- XDG_CACHE_HOME=/cache pip3 install -e ".[dev]"
- python setup.py sdist
- uv pip install --system dist/pretix-*.tar.gz
- pip install dist/pretix-*.tar.gz
- python -m pretix migrate
- python -m pretix check
- cd src
@@ -34,12 +33,13 @@ pypi:
- python -m build
- twine check dist/*
- twine upload dist/*
tags:
- python3
only:
- pypi
artifacts:
paths:
- src/dist/
stages:
- test
- build
+1 -1
View File
@@ -47,7 +47,7 @@ if [ "$1" == "taskworker" ]; then
fi
if [ "$1" == "upgrade" ]; then
exec python3 -m pretix updateassets
exec python3 -m pretix updatestyles
fi
exec python3 -m pretix "$@"
+2 -7
View File
@@ -3,16 +3,11 @@
.. _`community`:
Community install guides
========================
================================
.. warning:: The guides are maintained by the community and not by the pretix core team. If you encounter any issues with the guides, please report them to the maintainers of the guides. The pretix core team can not provide support for installs using these guides.
Kubernetes
----------
------------
- Helm Chart by techwolf12 - A Helm chart for deploying pretix on Kubernetes. The chart documentation is available on `ArtifactHub <https://artifacthub.io/packages/helm/techwolf12/pretix>`_ and the source code is available on `GitHub <https://github.com/Techwolf12/charts/tree/main/pretix-helm>`_.
Docker
------
- `docker compose setup <https://github.com/ZPascal/pretix-docker-compose>`_ by ZPascal
+1 -1
View File
@@ -19,7 +19,7 @@ You can use ``pip`` to update pretix directly to the development branch. Then, u
(venv)$ pip3 install -U "git+https://github.com/pretix/pretix.git#egg=pretix"
(venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild
(venv)$ python -m pretix updateassets
(venv)$ python -m pretix updatestyles
# systemctl restart pretix-web pretix-worker
Docker installation
+2 -2
View File
@@ -285,7 +285,7 @@ To upgrade to a new pretix release, pull the latest code changes and run the fol
(venv)$ pip3 install -U --upgrade-strategy eager pretix gunicorn
(venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild
(venv)$ python -m pretix updateassets
(venv)$ python -m pretix updatestyles
# systemctl restart pretix-web pretix-worker
Make sure to also read :ref:`update_notes` and the release notes of the version you are updating to. Pay special
@@ -325,7 +325,7 @@ Then, proceed like after any plugin installation::
(venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild
(venv)$ python -m pretix updateassets
(venv)$ python -m pretix updatestyles
# systemctl restart pretix-web pretix-worker
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-22-04
+2 -2
View File
@@ -103,10 +103,10 @@ pretix_celery_tasks_queued_count
pretix_celery_tasks_queued_age_seconds
The age of the longest-waiting in the worker queue in seconds, labeled with ``queue``.
pretix_logins_successful
pretix_successful_logins
Counter. The number of successful backend logins.
pretix_logins_failed
pretix_failed_logins
Counter. The number of failed backend logins, labeled with ``reason``.
.. _metric types: https://prometheus.io/docs/concepts/metric_types/
+2 -16
View File
@@ -20,12 +20,8 @@ id integer Internal ID
active boolean The discount will be ignored if this is ``false``
internal_name string A name for the rule used in the backend
position integer An integer, used for sorting the rules which are applied in order
all_sales_channels boolean If ``true`` (default), the discount is available on all sales channels
that support discounts.
limit_sales_channels list of strings List of sales channel identifiers the discount is available on
if ``all_sales_channels`` is ``false``.
sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels``
and ``limit_sales_channels`` instead.
sales_channels list of strings Sales channels this discount is available on, such as
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
available_from datetime The first date time at which this discount can be applied
(or ``null``).
available_until datetime The last date time at which this discount can be applied
@@ -99,8 +95,6 @@ Endpoints
"active": true,
"internal_name": "3 for 2",
"position": 1,
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
@@ -157,8 +151,6 @@ Endpoints
"active": true,
"internal_name": "3 for 2",
"position": 1,
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
@@ -201,8 +193,6 @@ Endpoints
"active": true,
"internal_name": "3 for 2",
"position": 1,
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
@@ -234,8 +224,6 @@ Endpoints
"active": true,
"internal_name": "3 for 2",
"position": 1,
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
@@ -296,8 +284,6 @@ Endpoints
"active": false,
"internal_name": "3 for 2",
"position": 1,
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
+13 -20
View File
@@ -49,11 +49,8 @@ item_meta_properties object Item-specific m
valid_keys object Cryptographic keys for non-default signature schemes.
For performance reason, value is omitted in lists and
only contained in detail views. Value can be cached.
all_sales_channels boolean If ``true`` (default), the event is available on all sales channels.
limit_sales_channels list of strings List of sales channel identifiers the event is available on
if ``all_sales_channels`` is ``false``.
sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels``
and ``limit_sales_channels`` instead.
sales_channels list A list of sales channels this event is available for
sale on.
public_url string The public, customer-facing URL of the event (read-only).
===================================== ========================== =======================================================
@@ -134,13 +131,11 @@ Endpoints
"pretix.plugins.paypal",
"pretix.plugins.ticketoutputpdf"
],
"all_sales_channels": false,
"limit_sales_channels": [
"sales_channels": [
"web",
"pretixpos",
"resellers"
],
"sales_channels": [],
"public_url": "https://pretix.eu/bigevents/sampleconf/"
}
]
@@ -230,8 +225,6 @@ Endpoints
"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTdBRDcvdkZBMzNFc1k0ejJQSHI3aVpQc1o4bjVkaDBhalA4Z3l6Tm1tSXM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo="
]
},
"all_sales_channels": true,
"limit_sales_channels": [],
"sales_channels": [
"web",
"pretixpos",
@@ -289,8 +282,11 @@ Endpoints
"pretix.plugins.stripe",
"pretix.plugins.paypal"
],
"all_sales_channels": true,
"limit_sales_channels": []
"sales_channels": [
"web",
"pretixpos",
"resellers"
]
}
**Example response**:
@@ -326,8 +322,6 @@ Endpoints
"pretix.plugins.stripe",
"pretix.plugins.paypal"
],
"all_sales_channels": true,
"limit_sales_channels": [],
"sales_channels": [
"web",
"pretixpos",
@@ -393,8 +387,11 @@ Endpoints
"pretix.plugins.stripe",
"pretix.plugins.paypal"
],
"all_sales_channels": true,
"limit_sales_channels": []
"sales_channels": [
"web",
"pretixpos",
"resellers"
]
}
**Example response**:
@@ -430,8 +427,6 @@ Endpoints
"pretix.plugins.stripe",
"pretix.plugins.paypal"
],
"all_sales_channels": true,
"limit_sales_channels": [],
"sales_channels": [
"web",
"pretixpos",
@@ -507,8 +502,6 @@ Endpoints
"pretix.plugins.paypal",
"pretix.plugins.pretixdroid"
],
"all_sales_channels": true,
"limit_sales_channels": [],
"sales_channels": [
"web",
"pretixpos",
-1
View File
@@ -30,7 +30,6 @@ at :ref:`plugin-docs`.
checkinlists
waitinglist
customers
saleschannels
membershiptypes
memberships
giftcards
+5 -19
View File
@@ -38,14 +38,11 @@ require_membership boolean If ``true``, bo
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
be hidden from users without a valid membership.
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
all_sales_channels boolean If ``true`` (default), the variation is available on all sales channels.
limit_sales_channels list of strings List of sales channel identifiers the variation is available on
if ``all_sales_channels`` is ``false``.
sales_channels list of strings Sales channels this variation is available on, such as
``"web"`` or ``"resellers"``. Defaults to all existing sales channels.
The item-level list takes precedence, i.e. a sales
channel needs to be on both lists for the variation to be
available (unless ``all_sales_channels`` is used).
sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels``
and ``limit_sales_channels`` instead.
channel needs to be on both lists for the item to be
available.
available_from datetime The first date time at which this variation can be bought
(or ``null``).
available_from_mode string If ``hide`` (the default), this variation is hidden in the shop
@@ -114,8 +111,6 @@ Endpoints
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
@@ -144,8 +139,6 @@ Endpoints
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
@@ -209,8 +202,6 @@ Endpoints
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
@@ -253,8 +244,7 @@ Endpoints
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
"available_until": null,
@@ -287,8 +277,6 @@ Endpoints
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
@@ -353,8 +341,6 @@ Endpoints
"require_membership": false,
"require_membership_hidden": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
+9 -42
View File
@@ -46,11 +46,8 @@ personalized boolean ``true`` for
position integer An integer, used for sorting
picture file A product picture to be displayed in the shop
(can be ``null``).
all_sales_channels boolean If ``true`` (default), the item is available on all sales channels.
limit_sales_channels list of strings List of sales channel identifiers the item is available on
if ``all_sales_channels`` is ``false``.
sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels``
and ``limit_sales_channels`` instead.
sales_channels list of strings Sales channels this product is available on, such as
``"web"`` or ``"resellers"``. Defaults to ``["web"]``.
available_from datetime The first date time at which this item can be bought
(or ``null``).
available_from_mode string If ``hide`` (the default), this item is hidden in the shop
@@ -160,14 +157,11 @@ variations list of objects A list with o
be hidden from users without a valid membership.
├ require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
Markdown syntax or can be ``null``.
all_sales_channels boolean If ``true`` (default), the variation is available on all sales channels.
├ limit_sales_channels list of strings List of sales channel identifiers the variation is available on
if ``all_sales_channels`` is ``false``.
├ sales_channels list of strings Sales channels this variation is available on, such as
``"web"`` or ``"resellers"``. Defaults to all existing sales channels.
The item-level list takes precedence, i.e. a sales
channel needs to be on both lists for the variation to be
available (unless ``all_sales_channels`` is used).
├ sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels``
and ``limit_sales_channels`` instead.
channel needs to be on both lists for the item to be
available.
├ available_from datetime The first date time at which this variation can be bought
(or ``null``).
├ available_from_mode string If ``hide`` (the default), this variation is hidden in the shop
@@ -282,8 +276,6 @@ Endpoints
"id": 1,
"name": {"en": "Standard ticket"},
"internal_name": "",
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"default_price": "23.00",
"original_price": null,
@@ -348,8 +340,6 @@ Endpoints
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
@@ -372,8 +362,6 @@ Endpoints
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
@@ -432,8 +420,6 @@ Endpoints
"id": 1,
"name": {"en": "Standard ticket"},
"internal_name": "",
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"default_price": "23.00",
"original_price": null,
@@ -499,8 +485,6 @@ Endpoints
"require_membership": false,
"require_membership_types": [],
"description": null,
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
@@ -522,8 +506,6 @@ Endpoints
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
@@ -563,8 +545,7 @@ Endpoints
"id": 1,
"name": {"en": "Standard ticket"},
"internal_name": "",
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"default_price": "23.00",
"original_price": null,
"category": null,
@@ -627,8 +608,7 @@ Endpoints
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
"available_until": null,
@@ -650,8 +630,7 @@ Endpoints
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
"available_until": null,
@@ -678,8 +657,6 @@ Endpoints
"id": 1,
"name": {"en": "Standard ticket"},
"internal_name": "",
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"default_price": "23.00",
"original_price": null,
@@ -744,8 +721,6 @@ Endpoints
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
@@ -768,8 +743,6 @@ Endpoints
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
@@ -828,8 +801,6 @@ Endpoints
"id": 1,
"name": {"en": "Ticket"},
"internal_name": "",
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"default_price": "25.00",
"original_price": null,
@@ -894,8 +865,6 @@ Endpoints
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
@@ -918,8 +887,6 @@ Endpoints
"require_approval": false,
"require_membership": false,
"require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"],
"available_from": null,
"available_from_mode": "hide",
-219
View File
@@ -1,219 +0,0 @@
Sales channels
==============
Resource description
--------------------
The sales channel resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
identifier string Internal ID of the sales channel. For sales channel types
that allow only one instance, this is the same as ``type``.
For sales channel types that allow multiple instances, this
is always prefixed with ``type.``.
label multi-lingual string Human-readable name of the sales channel
type string Type of the sales channel. Only channels with type ``api``
can currently be created through the API.
position integer Position for sorting lists of sales channels
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/saleschannels/
Returns a list of all sales channels within a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/saleschannels/ 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": [
{
"identifier": "web",
"name": {
"en": "Online shop"
},
"type": "web",
"position": 0
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/saleschannels/(identifier)/
Returns information on one sales channel, identified by its identifier.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/saleschannels/web/ 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
{
"identifier": "web",
"name": {
"en": "Online shop"
},
"type": "web",
"position": 0
}
:param organizer: The ``slug`` field of the organizer to fetch
:param identifier: The ``identifier`` field of the sales channel to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/saleschannels/
Creates a sales channel
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/saleschannels/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"identifier": "api.custom",
"name": {
"en": "Custom integration"
},
"type": "api",
"position": 2
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"identifier": "api.custom",
"name": {
"en": "Custom integration"
},
"type": "api",
"position": 2
}
:param organizer: The ``slug`` field of the organizer to create a sales channel for
:statuscode 201: no error
:statuscode 400: The sales channel could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/saleschannels/(identifier)/
Update a sales channel. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``identifier`` and ``type`` fields.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/saleschannels/web/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"position": 5
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"identifier": "web",
"name": {
"en": "Online shop"
},
"type": "web",
"position": 5
}
:param organizer: The ``slug`` field of the organizer to modify
:param identifier: The ``identifier`` field of the sales channel to modify
:statuscode 200: no error
:statuscode 400: The sales channel could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/saleschannels/(identifier)/
Delete a sales channel. You can not delete sales channels which have already been used or which are integral parts
of the system.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/saleschannels/api.custom/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param identifier: The ``identifier`` field of the sales channel to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource **or** the sales channel is currently in use.
+2 -2
View File
@@ -12,7 +12,7 @@ Core
.. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification,
item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter,
item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter,
register_ticket_secret_generators, gift_card_transaction_display,
register_text_placeholders, register_mail_placeholders
@@ -35,7 +35,7 @@ Frontend
--------
.. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_link, global_footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header
:members: html_head, html_footer, footer_link, global_footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header
.. automodule:: pretix.presale.signals
+1 -10
View File
@@ -15,7 +15,7 @@ includes serializers for serializing the following types:
* Built-in types: ``int``, ``float``, ``decimal.Decimal``, ``dict``, ``list``, ``bool``
* ``datetime.date``, ``datetime.datetime``, ``datetime.time``
* ``LazyI18nString``
* References to Django ``File`` objects that are already stored in a storage backend [#f1]_
* References to Django ``File`` objects that are already stored in a storage backend
* References to model instances
In code, we recommend to always use the ``.get()`` method on the settings object to access a value, but for
@@ -55,9 +55,6 @@ You can simply use it like this:
"preserve his reservation."),
)
.. _settings-defaults-in-plugins:
Defaults in plugins
-------------------
@@ -73,9 +70,3 @@ Make sure that you include this code in a module that is imported at app loading
.. _django-hierarkey: https://github.com/raphaelm/django-hierarkey
.. _documentation: https://django-hierarkey.readthedocs.io/en/latest/
.. rubric:: Footnotes
.. [#f1] If you store ``File`` instances in per-event settings, make sure to always register them with ``add_default``
as described above in :ref:`settings-defaults-in-plugins`. Otherwise, the file won't get copied properly if the
user copies the settings of an existing event to a new one.
+1 -1
View File
@@ -218,7 +218,7 @@ To update the frontend styles of shops with a custom styling, run the following
your virtual environment.::
python -m pretix collectstatic --noinput
python -m pretix updateassets
python -m pretix updatestyles
.. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver
+1 -1
View File
@@ -41,6 +41,6 @@ pretix/
tests/
This is the root directory for all test codes. It includes subdirectories ``api``, ``base``,
``control``, ``presale``, ``helpers``, ``multidomain`` and ``plugins`` to mirror the structure
``control``, ``presale``, ``helpers`, ``multidomain`` and ``plugins`` to mirror the structure
of the pretix source code as well as ``testdummy``, which is a pretix plugin used during
testing.
+5 -18
View File
@@ -45,8 +45,6 @@ allow_voucher_access boolean Enables access
lead_scanning_scope_by_device string Enables lead scanning to be handled as one lead per attendee
per scanning device, instead of only per exhibitor.
comment string Internal comment, not shown to exhibitor
exhibitor_tags list of strings Internal tags to categorize exhibitors, not shown to exhibitor.
The tags need to be created through the web interface currently.
===================================== ========================== =======================================================
You can also access the scanned leads through the API which contains the following public fields:
@@ -121,8 +119,7 @@ Endpoints
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": "",
"exhibitor_tags": []
"comment": ""
}
]
}
@@ -176,8 +173,7 @@ Endpoints
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": "",
"exhibitor_tags": []
"comment": ""
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -378,10 +374,7 @@ Endpoints
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": "",
"exhibitor_tags": [
"Gold Sponsor"
]
"comment": ""
}
**Example response**:
@@ -414,10 +407,7 @@ Endpoints
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": "",
"exhibitor_tags": [
"Gold Sponsor"
]
"comment": ""
}
:param organizer: The ``slug`` field of the organizer to create new exhibitor for
@@ -478,10 +468,7 @@ Endpoints
"allow_lead_scanning": true,
"allow_lead_access": true,
"allow_voucher_access": true,
"comment": "",
"exhibitor_tags": [
"Gold Sponsor"
]
"comment": ""
}
:param organizer: The ``slug`` field of the organizer to modify
+3 -3
View File
@@ -339,9 +339,9 @@ Currently, the following attributes are understood by pretix itself:
``data-attendee-name``, which will pre-fill the last part of the name, whatever that is.
* ``data-invoice-address-FIELD`` will pre-fill the corresponding field of the invoice address. Possible values for
``FIELD`` are ``company``, ``street``, ``zipcode``, ``city``, ``country``, ``internal-reference``, ``vat-id``, and
``custom-field``, as well as fields specified by the naming scheme such as ``name-title`` or ``name-given-name``
(see above). ``country`` expects a two-character country code.
``FIELD`` are ``company``, ``street``, ``zipcode``, ``city`` and ``country``, as well as fields specified by the
naming scheme such as ``name-title`` or ``name-given-name`` (see above). ``country`` expects a two-character
country code.
* If ``data-fix="true"`` is given, the user will not be able to change the other given values later. This currently
only works for the order email address as well as the invoice address. Attendee-level fields and questions can
+12 -12
View File
@@ -36,15 +36,15 @@ dependencies = [
"css-inline==0.14.*",
"defusedcsv>=1.1.0",
"dj-static",
"Django[argon2]==4.2.*",
"Django==4.2.*",
"django-bootstrap3==24.2",
"django-compressor==4.5",
"django-compressor==4.4",
"django-countries==7.6.*",
"django-filter==24.2",
"django-formset-js-improved==0.5.0.3",
"django-formtools==2.5.1",
"django-hierarkey==1.2.*",
"django-hijack==3.5.*",
"django-hierarkey==1.1.*",
"django-hijack==3.4.*",
"django-i18nfield==1.9.*,>=1.9.4",
"django-libsass==0.9",
"django-localflavor==4.0",
@@ -59,7 +59,7 @@ dependencies = [
"dnspython==2.6.*",
"drf_ujson2==1.7.*",
"geoip2==4.*",
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
"importlib_metadata==7.*", # Polyfill, we can probably drop this once we require Python 3.10+
"isoweek",
"jsonschema",
"kombu==5.3.*",
@@ -77,12 +77,12 @@ dependencies = [
"phonenumberslite==8.13.*",
"Pillow==10.3.*",
"pretix-plugin-build",
"protobuf==5.27.*",
"protobuf==5.26.*",
"psycopg2-binary",
"pycountry",
"pycparser==2.22",
"pycryptodome==3.20.*",
"pypdf==4.2.*",
"pypdf==3.9.*",
"python-bidi==0.4.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
"pytz",
@@ -92,7 +92,7 @@ dependencies = [
"redis==5.0.*",
"reportlab==4.2.*",
"requests==2.31.*",
"sentry-sdk==2.5.*",
"sentry-sdk==1.45.*",
"sepaxml==2.6.*",
"slimit",
"static3==0.7.*",
@@ -103,7 +103,7 @@ dependencies = [
"ua-parser==0.18.*",
"vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*",
"webauthn==2.2.*",
"webauthn==2.1.*",
"zeep==4.2.*"
]
@@ -113,11 +113,11 @@ dev = [
"aiohttp==3.9.*",
"coverage",
"coveralls",
"fakeredis==2.23.*",
"flake8==7.1.*",
"fakeredis==2.22.*",
"flake8==7.0.*",
"freezegun",
"isort==5.13.*",
"pep8-naming==0.14.*",
"pep8-naming==0.13.*",
"potypo",
"pytest-asyncio",
"pytest-cache",
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2024.7.0.dev0"
__version__ = "2024.5.0.dev0"
-3
View File
@@ -79,7 +79,6 @@ ALL_LANGUAGES = [
('de', _('German')),
('de-informal', _('German (informal)')),
('ar', _('Arabic')),
('ca', _('Catalan')),
('zh-hans', _('Chinese (simplified)')),
('zh-hant', _('Chinese (traditional)')),
('cs', _('Czech')),
@@ -99,8 +98,6 @@ ALL_LANGUAGES = [
('pt-br', _('Portuguese (Brazil)')),
('ro', _('Romanian')),
('ru', _('Russian')),
('sk', _('Slovak')),
('sv', _('Swedish')),
('es', _('Spanish')),
('tr', _('Turkish')),
('uk', _('Ukrainian')),
-55
View File
@@ -21,7 +21,6 @@
#
import json
from django.core.exceptions import ValidationError
from rest_framework import serializers
@@ -62,57 +61,3 @@ class CompatibleJSONField(serializers.JSONField):
if value:
return json.loads(value)
return value
class SalesChannelMigrationMixin:
"""
Translates between the old field "sales_channels" and the new field combo "all_sales_channels"/"limit_sales_channels".
"""
@property
def organizer(self):
if "organizer" in self.context:
return self.context["organizer"]
elif "event" in self.context:
return self.context["event"].organizer
else:
raise ValueError("organizer not in context")
def to_internal_value(self, data):
if "sales_channels" in data:
all_channels = {
s.identifier for s in
self.organizer.sales_channels.all()
}
if data.get("all_sales_channels") and set(data["sales_channels"]) != all_channels:
raise ValidationError(
"If 'all_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to "
"the list of all sales channels."
)
if data.get("limit_sales_channels") and set(data["sales_channels"]) != set(data["limit_sales_channels"]):
raise ValidationError(
"If 'limit_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to "
"the same list."
)
if data["sales_channels"] == all_channels:
data["all_sales_channels"] = True
data["limit_sales_channels"] = []
else:
data["all_sales_channels"] = False
data["limit_sales_channels"] = data["sales_channels"]
del data["sales_channels"]
return super().to_internal_value(data)
def to_representation(self, value):
value = super().to_representation(value)
if value.get("all_sales_channels"):
value["sales_channels"] = sorted([
s.identifier for s in
self.organizer.sales_channels.all()
])
else:
value["sales_channels"] = value["limit_sales_channels"]
return value
+2 -10
View File
@@ -33,7 +33,7 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
)
from pretix.base.models import SalesChannel, Seat, Voucher
from pretix.base.models import Seat, Voucher
from pretix.base.models.orders import CartPosition
@@ -212,11 +212,7 @@ class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
addons = BaseCartPositionCreateSerializer(many=True, required=False)
bundled = BaseCartPositionCreateSerializer(many=True, required=False)
seat = serializers.CharField(required=False, allow_null=True)
sales_channel = serializers.SlugRelatedField(
slug_field='identifier',
queryset=SalesChannel.objects.none(),
required=False,
)
sales_channel = serializers.CharField(required=False, default='sales_channel')
voucher = serializers.CharField(required=False, allow_null=True)
class Meta:
@@ -225,10 +221,6 @@ class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
'cart_id', 'expires', 'addons', 'bundled', 'seat', 'sales_channel', 'voucher'
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all()
def validate_cart_id(self, cid):
if cid and not cid.endswith('@api'):
raise ValidationError('Cart ID should end in @api or be empty.')
+6 -10
View File
@@ -25,20 +25,14 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.channels import get_all_sales_channels
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Checkin, CheckinList, SalesChannel
from pretix.base.models import Checkin, CheckinList
class CheckinListSerializer(I18nAwareModelSerializer):
checkin_count = serializers.IntegerField(read_only=True)
position_count = serializers.IntegerField(read_only=True)
auto_checkin_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta:
model = CheckinList
@@ -49,8 +43,6 @@ class CheckinListSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['auto_checkin_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
if 'subevent' in self.context['request'].query_params.getlist('expand'):
self.fields['subevent'] = SubEventSerializer(read_only=True)
@@ -80,6 +72,10 @@ class CheckinListSerializer(I18nAwareModelSerializer):
if full_data.get('subevent'):
raise ValidationError(_('The subevent does not belong to this event.'))
for channel in full_data.get('auto_checkin_sales_channels') or []:
if channel not in get_all_sales_channels():
raise ValidationError(_('Unknown sales channel.'))
CheckinList.validate_rules(data.get('rules'))
return data
+5 -15
View File
@@ -19,27 +19,18 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from rest_framework import serializers
from pretix.api.serializers import SalesChannelMigrationMixin
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Discount, SalesChannel
from pretix.base.models import Discount
class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class DiscountSerializer(I18nAwareModelSerializer):
class Meta:
model = Discount
fields = ('id', 'active', 'internal_name', 'position', 'all_sales_channels', 'limit_sales_channels',
'available_from', 'available_until', 'subevent_mode', 'condition_all_products',
'condition_limit_products', 'condition_apply_to_addons', 'condition_min_count', 'condition_min_value',
fields = ('id', 'active', 'internal_name', 'position', 'sales_channels', 'available_from',
'available_until', 'subevent_mode', 'condition_all_products', 'condition_limit_products',
'condition_apply_to_addons', 'condition_min_count', 'condition_min_value',
'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches',
'benefit_same_products', 'benefit_limit_products', 'benefit_apply_to_addons',
'benefit_ignore_voucher_discounted', 'condition_ignore_voucher_discounted')
@@ -48,7 +39,6 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
super().__init__(*args, **kwargs)
self.fields['condition_limit_products'].queryset = self.context['event'].items.all()
self.fields['benefit_limit_products'].queryset = self.context['event'].items.all()
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
def validate(self, data):
data = super().validate(data)
+5 -19
View File
@@ -46,14 +46,10 @@ from rest_framework import serializers
from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField
from pretix.api.serializers import (
CompatibleJSONField, SalesChannelMigrationMixin,
)
from pretix.api.serializers import CompatibleJSONField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.models import (
Device, Event, SalesChannel, TaxRule, TeamAPIToken,
)
from pretix.base.models import Device, Event, TaxRule, TeamAPIToken
from pretix.base.models.event import SubEvent
from pretix.base.models.items import (
ItemMetaProperty, SubEventItem, SubEventItemVariation,
@@ -165,7 +161,7 @@ class ValidKeysField(Field):
}
class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
class EventSerializer(I18nAwareModelSerializer):
meta_data = MetaDataField(required=False, source='*')
item_meta_properties = MetaPropertyField(required=False, source='*')
plugins = PluginsField(required=False, source='*')
@@ -174,13 +170,6 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
valid_keys = ValidKeysField(source='*', read_only=True)
best_availability_state = serializers.IntegerField(allow_null=True, read_only=True)
public_url = serializers.SerializerMethodField('get_event_url', read_only=True)
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
def get_event_url(self, event):
return build_absolute_uri(event, 'presale:event.index')
@@ -191,7 +180,7 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan',
'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties', 'valid_keys',
'all_sales_channels', 'limit_sales_channels', 'best_availability_state', 'public_url')
'sales_channels', 'best_availability_state', 'public_url')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -199,7 +188,6 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
self.fields.pop('valid_keys')
if not self.context.get('request') or 'with_availability_for' not in self.context['request'].GET:
self.fields.pop('best_availability_state')
self.fields['limit_sales_channels'].child_relation.queryset = self.context['organizer'].sales_channels.all()
def validate(self, data):
data = super().validate(data)
@@ -696,9 +684,8 @@ class EventSettingsSerializer(SettingsSerializer):
'locales',
'locale',
'region',
'allow_modifications',
'allow_modifications_after_checkin',
'last_order_modification_date',
'allow_modifications_after_checkin',
'show_quota_left',
'waiting_list_enabled',
'waiting_list_auto_disable',
@@ -748,7 +735,6 @@ class EventSettingsSerializer(SettingsSerializer):
'payment_term_accept_late',
'payment_explanation',
'payment_pending_hidden',
'payment_giftcard__enabled',
'mail_days_order_expire_warning',
'ticket_download',
'ticket_download_date',
+8 -36
View File
@@ -42,27 +42,19 @@ from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from pretix.api.serializers import SalesChannelMigrationMixin
from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
ItemVariationMetaValue, Question, QuestionOption, Quota, SalesChannel,
ItemVariationMetaValue, Question, QuestionOption, Quota,
)
class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
class InlineItemVariationSerializer(I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=13,
coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*')
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta:
model = ItemVariation
@@ -71,12 +63,11 @@ class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSe
'require_membership', 'require_membership_types', 'require_membership_hidden',
'checkin_attention', 'checkin_text',
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'all_sales_channels', 'limit_sales_channels', 'hide_without_voucher', 'meta_data')
'sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet)
self.fields['limit_sales_channels'].child_relation.queryset = lazy(lambda: self.context['event'].organizer.sales_channels.all(), QuerySet)
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
@@ -85,17 +76,10 @@ class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSe
return value
class ItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
class ItemVariationSerializer(I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=13,
coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*')
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta:
model = ItemVariation
@@ -104,12 +88,11 @@ class ItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializ
'require_membership', 'require_membership_types', 'require_membership_hidden',
'checkin_attention', 'checkin_text',
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'all_sales_channels', 'limit_sales_channels', 'hide_without_voucher', 'meta_data')
'sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
@transaction.atomic
def create(self, validated_data):
@@ -240,7 +223,7 @@ class ItemTaxRateField(serializers.Field):
return str(Decimal('0.00'))
class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
class ItemSerializer(I18nAwareModelSerializer):
addons = InlineItemAddOnSerializer(many=True, required=False)
bundles = InlineItemBundleSerializer(many=True, required=False)
variations = InlineItemVariationSerializer(many=True, required=False)
@@ -249,18 +232,11 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
'image/png', 'image/jpeg', 'image/gif'
), max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta:
model = Item
fields = ('id', 'category', 'name', 'internal_name', 'active', 'all_sales_channels', 'limit_sales_channels',
'description', 'default_price', 'free_price', 'free_price_suggestion', 'tax_rate', 'tax_rule', 'admission',
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description',
'default_price', 'free_price', 'free_price_suggestion', 'tax_rate', 'tax_rule', 'admission',
'personalized', 'position', 'picture',
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
@@ -283,7 +259,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
if not self.read_only:
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
def validate(self, data):
data = super().validate(data)
@@ -360,10 +335,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None)
require_membership_types = validated_data.pop('require_membership_types', [])
limit_sales_channels = validated_data.pop('limit_sales_channels', [])
item = Item.objects.create(**validated_data)
if limit_sales_channels:
item.limit_sales_channels.add(*limit_sales_channels)
if picture:
item.picture.save(os.path.basename(picture.name), picture)
if require_membership_types:
+24 -31
View File
@@ -46,12 +46,13 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import (
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
)
from pretix.base.channels import get_all_sales_channels
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher,
ReusableMedium, Seat, SubEvent, TaxRule, Voucher,
)
from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -563,8 +564,6 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
attendee_name = AttendeeNameField(source='*')
attendee_name_parts = AttendeeNamePartsField(source='*')
order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order')
order__valid_if_pending = serializers.SlugRelatedField(read_only=True, slug_field='valid_if_pending', source='order')
order__require_approval = serializers.SlugRelatedField(read_only=True, slug_field='require_approval', source='order')
class Meta:
model = OrderPosition
@@ -572,8 +571,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
'company', 'street', 'zipcode', 'city', 'country', 'state',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'require_attention',
'order__status', 'order__valid_if_pending', 'order__require_approval', 'valid_from', 'valid_until',
'blocked')
'order__status', 'valid_from', 'valid_until', 'blocked')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -713,11 +711,6 @@ class OrderSerializer(I18nAwareModelSerializer):
payment_provider = OrderPaymentTypeField(source='*', read_only=True)
url = OrderURLField(source='*', read_only=True)
customer = serializers.SlugRelatedField(slug_field='identifier', read_only=True)
sales_channel = serializers.SlugRelatedField(
slug_field='identifier',
queryset=SalesChannel.objects.none(),
required=False,
)
class Meta:
model = Order
@@ -736,10 +729,6 @@ class OrderSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if "organizer" in self.context:
self.fields["sales_channel"].queryset = self.context["organizer"].sales_channels.all()
else:
self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all()
if not self.context['pdf_data']:
self.fields['positions'].child.fields.pop('pdf_data', None)
@@ -1041,18 +1030,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
require_approval = serializers.BooleanField(default=False, required=False)
simulate = serializers.BooleanField(default=False, required=False)
customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none(), required=False)
sales_channel = serializers.SlugRelatedField(
slug_field='identifier',
queryset=SalesChannel.objects.none(),
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all()
self.fields['customer'].queryset = self.context['event'].organizer.customers.all()
self.fields['expires'].required = False
self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all()
class Meta:
model = Order
@@ -1073,6 +1056,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
raise ValidationError('Expiration date must be in the future.')
return expires
def validate_sales_channel(self, channel):
if channel not in get_all_sales_channels():
raise ValidationError('Unknown sales channel.')
return channel
def validate_code(self, code):
if code and Order.objects.filter(event__organizer=self.context['event'].organizer, code=code).exists():
raise ValidationError(
@@ -1134,6 +1122,20 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(errs)
return data
def validate_testmode(self, testmode):
if 'sales_channel' in self.initial_data:
try:
sales_channel = get_all_sales_channels()[self.initial_data['sales_channel']]
if testmode and not sales_channel.testmode_supported:
raise ValidationError('This sales channel does not provide support for test mode.')
except KeyError:
# We do not need to raise a ValidationError here, since there is another check to validate the
# sales_channel
pass
return testmode
def create(self, validated_data):
fees_data = validated_data.pop('fees') if 'fees' in validated_data else []
positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
@@ -1142,16 +1144,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
payment_date = validated_data.pop('payment_date', now())
force = validated_data.pop('force', False)
simulate = validated_data.pop('simulate', False)
if not validated_data.get("sales_channel"):
validated_data["sales_channel"] = self.context['event'].organizer.sales_channels.get(identifier="web")
if validated_data.get("testmode") and not validated_data["sales_channel"].type_instance.testmode_supported:
raise ValidationError({"testmode": ["This sales channel does not provide support for test mode."]})
self._send_mail = validated_data.pop('send_email', False)
if self._send_mail is None:
self._send_mail = validated_data["sales_channel"].identifier in self.context['event'].settings.mail_sales_channel_placed_paid
self._send_mail = validated_data.get('sales_channel') in self.context['event'].settings.mail_sales_channel_placed_paid
if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address')
@@ -1311,8 +1306,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
errs[i]['seat'] = ['The specified seat does not exist.']
else:
seat_usage[seat] += 1
sales_channel_id = validated_data['sales_channel'].identifier
if (seat_usage[seat] > 0 and not seat.is_available(sales_channel=sales_channel_id)) or seat_usage[seat] > 1:
if (seat_usage[seat] > 0 and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat_usage[seat] > 1:
errs[i]['seat'] = [gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
elif seated:
errs[i]['seat'] = ['The specified product requires to choose a seat.']
@@ -1371,7 +1365,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if validated_data.get('locale', None) is None:
validated_data['locale'] = self.context['event'].settings.locale
order = Order(event=self.context['event'], **validated_data)
if not validated_data.get('expires'):
order.set_expires(subevents=[p.get('subevent') for p in positions_data])
+1 -31
View File
@@ -38,7 +38,7 @@ from pretix.base.i18n import get_language_without_region
from pretix.base.models import (
Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
)
from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.services.mail import SendMailException, mail
@@ -165,36 +165,6 @@ class FlexibleTicketRelatedField(serializers.PrimaryKeyRelatedField):
self.fail('incorrect_type', data_type=type(data).__name__)
class SalesChannelSerializer(I18nAwareModelSerializer):
type = serializers.CharField(default="api")
class Meta:
model = SalesChannel
fields = ('identifier', 'type', 'label', 'position')
def validate_type(self, value):
if (not self.instance or not self.instance.pk) and value != "api":
raise ValidationError(
"You can currently only create channels of type 'api' through the API."
)
if value and self.instance and self.instance.pk and self.instance.type != value:
raise ValidationError(
"You cannot change the type of a sales channel."
)
return value
def validate_identifier(self, value):
if (not self.instance or not self.instance.pk) and not value.startswith("api."):
raise ValidationError(
"Your identifier needs to start with 'api.'."
)
if value and self.instance and self.instance.pk and self.instance.identifier != value:
raise ValidationError(
"You cannot change the identifier of a sales channel."
)
return value
class GiftCardSerializer(I18nAwareModelSerializer):
value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00'))
owner_ticket = FlexibleTicketRelatedField(required=False, allow_null=True, queryset=OrderPosition.all.none())
-1
View File
@@ -56,7 +56,6 @@ orga_router.register(r'webhooks', webhooks.WebHookViewSet)
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
orga_router.register(r'giftcards', organizer.GiftCardViewSet)
orga_router.register(r'customers', organizer.CustomerViewSet)
orga_router.register(r'saleschannels', organizer.SalesChannelViewSet)
orga_router.register(r'memberships', organizer.MembershipViewSet)
orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet)
orga_router.register(r'reusablemedia', media.ReusableMediaViewSet)
+1 -5
View File
@@ -211,12 +211,8 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
if validated_data.get('seat'):
# Assumption: Add-ons currently can't have seats, thus we only need to check the main product
if validated_data.get('sales_channel'):
sales_channel_id = validated_data.get('sales_channel').identifier
else:
sales_channel_id = "web"
if not validated_data['seat'].is_available(
sales_channel=sales_channel_id,
sales_channel=validated_data.get('sales_channel', 'web'),
distance_ignore_cart_id=validated_data['cart_id'],
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
):
+1 -1
View File
@@ -115,7 +115,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
if 'subevent' in self.request.query_params.getlist('expand'):
qs = qs.prefetch_related(
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
'subevent__seat_category_mappings', 'subevent__meta_values', 'auto_checkin_sales_channels'
'subevent__seat_category_mappings', 'subevent__meta_values'
)
return qs
+1 -3
View File
@@ -60,9 +60,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
write_permission = 'can_change_items'
def get_queryset(self):
return self.request.event.discounts.prefetch_related(
'limit_sales_channels',
)
return self.request.event.discounts.all()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
+8 -19
View File
@@ -57,8 +57,10 @@ from pretix.base.models import (
)
from pretix.base.models.event import SubEvent
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.settings import SETTINGS_AFFECTING_CSS
from pretix.helpers.dicts import merge_dicts
from pretix.helpers.i18n import i18ncomp
from pretix.presale.style import regenerate_css
from pretix.presale.views.organizer import filter_qs_by_attr
with scopes_disabled():
@@ -113,10 +115,7 @@ with scopes_disabled():
return queryset.exclude(expr)
def sales_channel_qs(self, queryset, name, value):
return queryset.filter(
Q(all_sales_channels=True) |
Q(limit_sales_channels__identifier=value)
)
return queryset.filter(sales_channels__contains=value)
def search_qs(self, queryset, name, value):
return queryset.filter(
@@ -138,12 +137,6 @@ class EventViewSet(viewsets.ModelViewSet):
ordering_fields = ('date_from', 'slug')
filterset_class = EventFilter
def get_serializer_context(self):
return {
**super().get_serializer_context(),
"organizer": self.request.organizer,
}
def get_copy_from_queryset(self):
if isinstance(self.request.auth, (TeamAPIToken, Device)):
return self.request.auth.get_events_with_any_permission()
@@ -169,7 +162,6 @@ class EventViewSet(viewsets.ModelViewSet):
'meta_values',
'meta_values__property',
'item_meta_properties',
'limit_sales_channels',
Prefetch(
'seat_category_mappings',
to_attr='_seat_category_mappings',
@@ -278,6 +270,8 @@ class EventViewSet(viewsets.ModelViewSet):
new_event.is_public = serializer.validated_data['is_public']
if 'testmode' in serializer.validated_data:
new_event.testmode = serializer.validated_data['testmode']
if 'sales_channels' in serializer.validated_data:
new_event.sales_channels = serializer.validated_data['sales_channels']
if 'has_subevents' in serializer.validated_data:
new_event.has_subevents = serializer.validated_data['has_subevents']
if 'date_admission' in serializer.validated_data:
@@ -285,10 +279,6 @@ class EventViewSet(viewsets.ModelViewSet):
new_event.save()
if 'timezone' in serializer.validated_data:
new_event.settings.timezone = serializer.validated_data['timezone']
if 'all_sales_channels' in serializer.validated_data and 'sales_channels' in serializer.validated_data:
new_event.all_sales_channels = serializer.validated_data['all_sales_channels']
new_event.limit_sales_channels.set(serializer.validated_data['limit_sales_channels'])
else:
serializer.instance.set_defaults()
@@ -391,10 +381,7 @@ with scopes_disabled():
return queryset.exclude(expr)
def sales_channel_qs(self, queryset, name, value):
return queryset.filter(
Q(event__all_sales_channels=True) |
Q(event__limit_sales_channels__identifier=value)
)
return queryset.filter(event__sales_channels__contains=value)
def search_qs(self, queryset, name, value):
return queryset.filter(
@@ -649,6 +636,8 @@ class EventSettingsView(views.APIView):
k: v for k, v in s.validated_data.items()
}
)
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
regenerate_css.apply_async(args=(request.event.pk,))
s = EventSettingsSerializer(
instance=request.event.settings, event=request.event, context={
'request': request
+1 -3
View File
@@ -87,7 +87,6 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
'variations__meta_values', 'variations__meta_values__property',
'require_membership_types', 'variations__require_membership_types',
'limit_sales_channels', 'variations__limit_sales_channels',
).all()
def perform_create(self, serializer):
@@ -153,8 +152,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
return self.item.variations.all().prefetch_related(
'meta_values',
'meta_values__property',
'require_membership_types',
'limit_sales_channels',
'require_membership_types'
)
def get_serializer_context(self):
+1 -6
View File
@@ -229,7 +229,7 @@ class OrderViewSetMixin:
if 'customer' not in self.request.GET.getlist('exclude'):
qs = qs.select_related('customer')
qs = qs.select_related('sales_channel').prefetch_related(self._positions_prefetch(self.request))
qs = qs.prefetch_related(self._positions_prefetch(self.request))
return qs
def _positions_prefetch(self, request):
@@ -316,11 +316,6 @@ class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
else:
raise PermissionDenied()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
permission = 'can_view_orders'
+8 -69
View File
@@ -43,16 +43,18 @@ from pretix.api.serializers.organizer import (
CustomerCreateSerializer, CustomerSerializer, DeviceSerializer,
GiftCardSerializer, GiftCardTransactionSerializer, MembershipSerializer,
MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer,
SalesChannelSerializer, SeatingPlanSerializer, TeamAPITokenSerializer,
TeamInviteSerializer, TeamMemberSerializer, TeamSerializer,
SeatingPlanSerializer, TeamAPITokenSerializer, TeamInviteSerializer,
TeamMemberSerializer, TeamSerializer,
)
from pretix.base.models import (
Customer, Device, GiftCard, GiftCardTransaction, Membership,
MembershipType, Organizer, SalesChannel, SeatingPlan, Team, TeamAPIToken,
TeamInvite, User,
MembershipType, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite,
User,
)
from pretix.base.settings import SETTINGS_AFFECTING_CSS
from pretix.helpers import OF_SELF
from pretix.helpers.dicts import merge_dicts
from pretix.presale.style import regenerate_organizer_css
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
@@ -502,6 +504,8 @@ class OrganizerSettingsView(views.APIView):
k: v for k, v in s.validated_data.items()
}
)
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
regenerate_organizer_css.apply_async(args=(request.organizer.pk,))
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
'request': request
})
@@ -675,68 +679,3 @@ class MembershipViewSet(viewsets.ModelViewSet):
data=self.request.data,
)
return inst
with scopes_disabled():
class SalesChannelFilter(FilterSet):
class Meta:
model = SalesChannel
fields = ['type', 'identifier']
class SalesChannelViewSet(viewsets.ModelViewSet):
serializer_class = SalesChannelSerializer
queryset = SalesChannel.objects.none()
permission = 'can_change_organizer_settings'
write_permission = 'can_change_organizer_settings'
filter_backends = (DjangoFilterBackend,)
filterset_class = SalesChannelFilter
lookup_field = 'identifier'
lookup_url_kwarg = 'identifier'
lookup_value_regex = r"[a-zA-Z0-9.\-_]+"
def get_queryset(self):
return self.request.organizer.sales_channels.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(
organizer=self.request.organizer,
type="api"
)
inst.log_action(
'pretix.saleschannel.created',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk})
)
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save(
type=serializer.instance.type,
identifier=serializer.instance.identifier,
)
inst.log_action(
'pretix.sales_channel.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied("Can only be deleted if unused.")
instance.log_action(
'pretix.saleschannel.deleted',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk}
)
instance.delete()
+25 -85
View File
@@ -20,83 +20,56 @@
# <https://www.gnu.org/licenses/>.
#
import logging
import warnings
from collections import OrderedDict
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from pretix.base.signals import (
register_sales_channel_types, register_sales_channels,
)
from pretix.base.signals import register_sales_channels
logger = logging.getLogger(__name__)
_ALL_CHANNEL_TYPES = None
_ALL_CHANNELS = None
class SalesChannelType:
class SalesChannel:
def __repr__(self):
return '<SalesChannelType: {}>'.format(self.identifier)
return '<SalesChannel: {}>'.format(self.identifier)
@property
def identifier(self) -> str:
"""
The internal identifier of this sales channel type.
The internal identifier of this sales channel.
"""
raise NotImplementedError() # NOQA
@property
def verbose_name(self) -> str:
"""
A human-readable name of this sales channel type.
A human-readable name of this sales channel.
"""
raise NotImplementedError() # NOQA
@property
def description(self) -> str:
"""
A human-readable description of this sales channel type.
"""
return ""
@property
def icon(self) -> str:
"""
This can be:
- The name of a Font Awesome icon to represent this channel type.
- The name of a SVG icon file that is resolvable through the static file system. We recommend to design for a size of 18x14 pixels.
The name of a Font Awesome icon to represent this channel
"""
return "circle"
@property
def default_created(self) -> bool:
"""
Indication, if a sales channel of this type should automatically be created for every organizer
"""
return True
@property
def multiple_allowed(self) -> bool:
"""
Indication, if multiple sales channels of this type may exist in the same organizer
"""
return False
@property
def testmode_supported(self) -> bool:
"""
Indication, if a sales channel of this type supports test mode orders
Indication, if a saleschannels supports test mode orders
"""
return True
@property
def payment_restrictions_supported(self) -> bool:
"""
If this property is ``True``, organizers can restrict the usage of payment providers to this sales channel type.
If this property is ``True``, organizers can restrict the usage of payment providers to this sales channel.
Example: pretixPOS provides its own sales channel type, ignores the configured payment providers completely and
handles payments locally. Therefore, this property should be set to ``False`` for the pretixPOS sales channel as
Example: pretixPOS provides its own sales channel, ignores the configured payment providers completely and
handles payments locally. Therefor, this property should be set to ``False`` for the pretixPOS sales channel as
the event organizer cannot restrict the usage of any payment provider through the backend.
"""
return True
@@ -104,8 +77,8 @@ class SalesChannelType:
@property
def unlimited_items_per_order(self) -> bool:
"""
If this property is ``True``, purchases made using sales channels of this type are not limited to the maximum
amount of items defined in the event settings.
If this property is ``True``, purchases made using this sales channel are not limited to the maximum amount of
items defined in the event settings.
"""
return False
@@ -123,67 +96,34 @@ class SalesChannelType:
"""
return True
@property
def required_event_plugin(self) -> str:
"""
Name of an event plugin that is required for this sales channel to be useful. Defaults to ``None``.
"""
return
def get_all_sales_channels():
global _ALL_CHANNELS
def get_all_sales_channel_types():
from pretix.base.signals import register_sales_channel_types
global _ALL_CHANNEL_TYPES
if _ALL_CHANNEL_TYPES:
return _ALL_CHANNEL_TYPES
if _ALL_CHANNELS:
return _ALL_CHANNELS
channels = []
for recv, ret in register_sales_channel_types.send(None):
if isinstance(ret, (list, tuple)):
channels += ret
else:
channels.append(ret)
for recv, ret in register_sales_channels.send(None): # todo: remove me
for recv, ret in register_sales_channels.send(None):
if isinstance(ret, (list, tuple)):
channels += ret
else:
channels.append(ret)
channels.sort(key=lambda c: c.identifier)
_ALL_CHANNEL_TYPES = OrderedDict([(c.identifier, c) for c in channels])
if 'web' in _ALL_CHANNEL_TYPES:
_ALL_CHANNEL_TYPES.move_to_end('web', last=False)
return _ALL_CHANNEL_TYPES
_ALL_CHANNELS = OrderedDict([(c.identifier, c) for c in channels])
if 'web' in _ALL_CHANNELS:
_ALL_CHANNELS.move_to_end('web', last=False)
return _ALL_CHANNELS
def get_all_sales_channels():
# TODO: remove me
warnings.warn('Using get_all_sales_channels() is no longer appropriate, use get_al_sales_channel_types() instead.',
DeprecationWarning, stacklevel=2)
return get_all_sales_channel_types()
class WebshopSalesChannelType(SalesChannelType):
class WebshopSalesChannel(SalesChannel):
identifier = "web"
verbose_name = _('Online shop')
icon = "globe"
class ApiSalesChannelType(SalesChannelType):
identifier = "api"
verbose_name = _('API')
description = _('API sales channels come with no built-in functionality, but may be used for custom integrations.')
icon = "exchange"
default_created = False
multiple_allowed = True
SalesChannel = SalesChannelType # TODO: remove me
@receiver(register_sales_channel_types, dispatch_uid="base_register_default_sales_channel_types")
@receiver(register_sales_channels, dispatch_uid="base_register_default_sales_channels")
def base_sales_channels(sender, **kwargs):
return (
WebshopSalesChannelType(),
ApiSalesChannelType(),
WebshopSalesChannel(),
)
+4 -12
View File
@@ -27,6 +27,7 @@ from openpyxl.styles import Alignment
from openpyxl.utils import get_column_letter
from ...helpers.safe_openpyxl import SafeCell
from ..channels import get_all_sales_channels
from ..exporter import ListExporter
from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue
from ..signals import register_data_exporters
@@ -52,7 +53,7 @@ class ItemDataExporter(ListExporter):
def iterate_list(self, form_data):
locales = self.event.settings.locales
scs = self.organizer.sales_channels.all()
scs = get_all_sales_channels()
header = [
_("Product ID"),
_("Variation ID"),
@@ -140,15 +141,9 @@ class ItemDataExporter(ListExporter):
row.append(i.name.localize(l))
for l in locales:
row.append(v.value.localize(l))
sales_channels = list(scs)
if not i.all_sales_channels:
sales_channels = [s for s in sales_channels if s.identifier in (p.identifier for p in i.limit_sales_channels.all())]
if not v.all_sales_channels:
sales_channels = [s for s in sales_channels if s.identifier in (p.identifier for p in v.limit_sales_channels.all())]
row += [
_("Yes") if i.active and v.active else "",
", ".join([str(sn.label) for sn in sales_channels]),
", ".join([str(sn.verbose_name) for s, sn in scs.items() if s in i.sales_channels and s in v.sales_channels]),
v.default_price or i.default_price,
_("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "",
@@ -191,12 +186,9 @@ class ItemDataExporter(ListExporter):
row.append(i.name.localize(l))
for l in locales:
row.append("")
sales_channels = list(scs)
if not i.all_sales_channels:
sales_channels = [s for s in sales_channels if s.identifier in (p.identifier for p in i.limit_sales_channels.all())]
row += [
_("Yes") if i.active else "",
", ".join([str(sn.label) for sn in sales_channels]),
", ".join([str(sn.verbose_name) for s, sn in scs.items() if s in i.sales_channels]),
i.default_price,
_("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "",
+3 -8
View File
@@ -54,7 +54,6 @@ class JSONExporter(BaseExporter):
'import in third-party systems.')
def render(self, form_data):
all_sales_channels = self.event.organizer.sales_channels.all()
jo = {
'event': {
'name': str(self.event.name),
@@ -86,7 +85,7 @@ class JSONExporter(BaseExporter):
'admission': item.admission,
'personalized': item.personalized,
'active': item.active,
'sales_channels': [c.identifier for c in (all_sales_channels if item.all_sales_channels else item.limit_sales_channels.all())],
'sales_channels': item.sales_channels,
'description': str(item.description),
'available_from': item.available_from,
'available_until': item.available_until,
@@ -115,9 +114,7 @@ class JSONExporter(BaseExporter):
'checkin_text': variation.checkin_text,
'require_approval': variation.require_approval,
'require_membership': variation.require_membership,
'sales_channels': [
c.identifier for c in (all_sales_channels if variation.all_sales_channels else variation.limit_sales_channels.all())
],
'sales_channels': variation.sales_channels,
'available_from': variation.available_from,
'available_until': variation.available_until,
'hide_without_voucher': variation.hide_without_voucher,
@@ -125,7 +122,6 @@ class JSONExporter(BaseExporter):
} for variation in item.variations.all()
]
} for item in self.event.items.select_related('tax_rule').prefetch_related(
'limit_sales_channels',
Prefetch(
'meta_values',
ItemMetaValue.objects.select_related('property'),
@@ -134,7 +130,6 @@ class JSONExporter(BaseExporter):
Prefetch(
'variations',
queryset=ItemVariation.objects.prefetch_related(
'limit_sales_channels',
Prefetch(
'meta_values',
ItemVariationMetaValue.objects.select_related('property'),
@@ -172,7 +167,7 @@ class JSONExporter(BaseExporter):
'require_approval': order.require_approval,
'checkin_attention': order.checkin_attention,
'checkin_text': order.checkin_text,
'sales_channel': order.sales_channel.identifier,
'sales_channel': order.sales_channel,
'expires': order.expires,
'datetime': order.datetime,
'fees': [
+5 -30
View File
@@ -32,12 +32,11 @@
# 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.
from collections import OrderedDict, defaultdict
from collections import OrderedDict
from decimal import Decimal
from zoneinfo import ZoneInfo
from django import forms
from django.conf import settings
from django.db.models import (
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
Q, Subquery, Sum, When,
@@ -55,7 +54,7 @@ from openpyxl.comments import Comment
from openpyxl.styles import Font, PatternFill
from pretix.base.models import (
Checkin, GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order,
GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order,
OrderPosition, Question,
)
from pretix.base.models.orders import (
@@ -542,22 +541,6 @@ class OrderListExporter(MultiSheetListExporter):
).order_by()
qs = base_qs.annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
checked_in_lists=Subquery(
Checkin.objects.filter(
successful=True,
type=Checkin.TYPE_ENTRY,
position=OuterRef("pk"),
).order_by().values("position").annotate(
c=GroupConcat(
"list__name",
# These appear not to work properly on SQLite. Well, we don't support SQLite outside testing
# anyways.
ordered='sqlite' not in settings.DATABASES['default']['ENGINE'],
distinct='sqlite' not in settings.DATABASES['default']['ENGINE'],
delimiter=", "
)
).values("c")
),
).select_related(
'order', 'order__invoice_address', 'order__customer', 'item', 'variation',
'voucher', 'tax_rule'
@@ -622,9 +605,10 @@ class OrderListExporter(MultiSheetListExporter):
]
questions = list(Question.objects.filter(event__in=self.events))
options = defaultdict(list)
options = {}
for q in questions:
if q.type == Question.TYPE_CHOICE_MULTIPLE:
options[q.pk] = []
if form_data['group_multiple_choice']:
for o in q.options.all():
options[q.pk].append(o)
@@ -634,9 +618,6 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(str(q.question) + ' ' + str(o.answer))
options[q.pk].append(o)
else:
if q.type == Question.TYPE_CHOICE:
for o in q.options.all():
options[q.pk].append(o)
headers.append(str(q.question))
headers += [
_('Company'),
@@ -655,7 +636,6 @@ class OrderListExporter(MultiSheetListExporter):
_('Sales channel'), _('Order locale'),
_('E-mail address verified'),
_('External customer ID'),
_('Check-in lists'),
_('Payment providers'),
]
@@ -747,7 +727,7 @@ class OrderListExporter(MultiSheetListExporter):
for a in op.answers.all():
# We do not want to localize Date, Time and Datetime question answers, as those can lead
# to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French).
if a.question.type in (Question.TYPE_CHOICE_MULTIPLE, Question.TYPE_CHOICE):
if a.question.type == Question.TYPE_CHOICE_MULTIPLE:
acache[a.question_id] = set(o.pk for o in a.options.all())
elif a.question.type in Question.UNLOCALIZED_TYPES:
acache[a.question_id] = a.answer
@@ -760,10 +740,6 @@ class OrderListExporter(MultiSheetListExporter):
else:
for o in options[q.pk]:
row.append(_('Yes') if o.pk in acache.get(q.pk, set()) else _('No'))
elif q.type == Question.TYPE_CHOICE:
# Join is only necessary if the question type was modified but also keeps the code simpler here
# as we'd otherwise need some [0] and existance checks
row.append(", ".join(str(o.answer) for o in options[q.pk] if o.pk in acache.get(q.pk, set())))
else:
row.append(acache.get(q.pk, ''))
@@ -794,7 +770,6 @@ class OrderListExporter(MultiSheetListExporter):
_('Yes') if order.email_known_to_work else _('No'),
str(order.customer.external_identifier) if order.customer and order.customer.external_identifier else '',
]
row.append(op.checked_in_lists or "")
row.append(', '.join([
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
if p and p != 'free'
+2 -2
View File
@@ -1035,7 +1035,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.all_optional = kwargs.pop('all_optional', False)
kwargs.setdefault('initial', {})
if (not kwargs.get('instance') or not kwargs['instance'].country) and not kwargs["initial"].get("country"):
if not kwargs.get('instance') or not kwargs['instance'].country:
kwargs['initial']['country'] = guess_country_from_request(self.request, self.event)
super().__init__(*args, **kwargs)
@@ -1171,7 +1171,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.instance.vat_id_validated = False
messages.warning(self.request, e.message)
else:
raise ValidationError({"vat_id": e.message})
raise ValidationError(e.message)
except VATIDTemporaryError as e:
self.instance.vat_id_validated = False
if self.request and self.vat_warning:
+16 -15
View File
@@ -32,13 +32,13 @@
# 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 re
from django.core.exceptions import ValidationError
from django.core.validators import BaseValidator
from django.utils.translation import gettext_lazy as _
from i18nfield.strings import LazyI18nString
from pretix.helpers.format import format_map
class PlaceholderValidator(BaseValidator):
"""
@@ -47,12 +47,6 @@ class PlaceholderValidator(BaseValidator):
which are not presented in taken list.
"""
error_message = _(
'There is an error with your placeholder syntax. Please check that the opening "{" and closing "}" curly '
'brackets on your placeholders match up. '
'Please note: to use literal "{" or "}", you need to double them as "{{" and "}}".'
)
def __init__(self, limit_value):
super().__init__(limit_value)
self.limit_value = limit_value
@@ -63,15 +57,22 @@ class PlaceholderValidator(BaseValidator):
self.__call__(v)
return
try:
format_map(value, {key.strip('{}'): "" for key in self.limit_value}, raise_on_missing=True)
except ValueError:
raise ValidationError(self.error_message, code='invalid_placeholder_syntax')
except KeyError as e:
if value.count('{') != value.count('}'):
raise ValidationError(
_('Invalid placeholder: {%(value)s}'),
_('Invalid placeholder syntax: You used a different number of "{" than of "}".'),
code='invalid_placeholder_syntax',
)
data_placeholders = list(re.findall(r'({[^}]*})', value, re.X))
invalid_placeholders = []
for placeholder in data_placeholders:
if placeholder not in self.limit_value:
invalid_placeholders.append(placeholder)
if invalid_placeholders:
raise ValidationError(
_('Invalid placeholder(s): %(value)s'),
code='invalid_placeholders',
params={'value': e.args[0]})
params={'value': ", ".join(invalid_placeholders,)})
def clean(self, x):
return x
+2
View File
@@ -256,6 +256,8 @@ class SecurityMiddleware(MiddlewareMixin):
# pages
return resp
resp['X-XSS-Protection'] = '1'
# We just need to have a P3P, not matter whats in there
# https://blogs.msdn.microsoft.com/ieinternals/2013/09/17/a-quick-look-at-p3p/
# https://github.com/pretix/pretix/issues/765
@@ -2,7 +2,7 @@
from django.db import migrations
from pretix.base.channels import get_all_sales_channel_types
from pretix.base.channels import get_all_sales_channels
def set_sales_channels(apps, schema_editor):
@@ -11,7 +11,7 @@ def set_sales_channels(apps, schema_editor):
# Therefore, for existing events, we enable all sales channels
Event_SettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
Event = apps.get_model('pretixbase', 'Event')
all_sales_channels = "[" + ", ".join('"' + sc + '"' for sc in get_all_sales_channel_types()) + "]"
all_sales_channels = "[" + ", ".join('"' + sc + '"' for sc in get_all_sales_channels()) + "]"
batch_size = 1000
Event_SettingsStore.objects.bulk_create([
Event_SettingsStore(
@@ -3,7 +3,7 @@
from django.db import migrations
import pretix.base.models.fields
from pretix.base.channels import get_all_sales_channel_types
from pretix.base.channels import get_all_sales_channels
class Migration(migrations.Migration):
@@ -15,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='event',
name='sales_channels',
field=pretix.base.models.fields.MultiStringField(default=list(get_all_sales_channel_types().keys())),
field=pretix.base.models.fields.MultiStringField(default=list(get_all_sales_channels().keys())),
),
]
@@ -1,24 +0,0 @@
# Generated by Django 4.2.11 on 2024-05-16 11:07
from django.db import migrations, models
import pretix.base.models.orders
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0263_auto_20240409_0732"),
]
operations = [
migrations.AddField(
model_name="order",
name="internal_secret",
field=models.CharField(
default=None,
max_length=32,
null=True,
),
),
]
@@ -1,110 +0,0 @@
# Generated by Django 4.2.8 on 2024-03-24 17:43
import django.db.models.deletion
import i18nfield.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0264_order_internal_secret"),
]
operations = [
migrations.CreateModel(
name="SalesChannel",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("label", i18nfield.fields.I18nCharField(max_length=200)),
("identifier", models.CharField(max_length=200)),
("type", models.CharField(max_length=200)),
("position", models.PositiveIntegerField(default=0)),
("configuration", models.JSONField(default=dict)),
],
),
migrations.RenameField(
model_name="checkinlist",
old_name="auto_checkin_sales_channels",
new_name="auto_checkin_sales_channel_types",
),
migrations.AddField(
model_name="discount",
name="all_sales_channels",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="event",
name="all_sales_channels",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="item",
name="all_sales_channels",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="itemvariation",
name="all_sales_channels",
field=models.BooleanField(default=True),
),
migrations.RenameField(
model_name="order",
old_name="sales_channel",
new_name="sales_channel_type",
),
migrations.AddField(
model_name="saleschannel",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="sales_channels",
to="pretixbase.organizer",
),
),
migrations.AddField(
model_name="discount",
name="limit_sales_channels",
field=models.ManyToManyField(to="pretixbase.saleschannel"),
),
migrations.AddField(
model_name="event",
name="limit_sales_channels",
field=models.ManyToManyField(to="pretixbase.saleschannel"),
),
migrations.AddField(
model_name="item",
name="limit_sales_channels",
field=models.ManyToManyField(to="pretixbase.saleschannel"),
),
migrations.AddField(
model_name="itemvariation",
name="limit_sales_channels",
field=models.ManyToManyField(to="pretixbase.saleschannel"),
),
migrations.AddField(
model_name="checkinlist",
name="auto_checkin_sales_channels",
field=models.ManyToManyField(to="pretixbase.saleschannel"),
),
migrations.AddField(
model_name="order",
name="sales_channel",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="pretixbase.saleschannel",
),
),
migrations.AlterUniqueTogether(
name="saleschannel",
unique_together={("organizer", "identifier")},
),
]
@@ -1,84 +0,0 @@
# Generated by Django 4.2.8 on 2024-03-24 17:55
from django.db import migrations
from i18nfield.strings import LazyI18nString
from pretix.base.channels import get_all_sales_channel_types
def create_sales_channels(apps, schema_editor):
channel_types = get_all_sales_channel_types()
type_to_channel = dict()
full_discount_set = set()
full_set = set()
Organizer = apps.get_model("pretixbase", "Organizer")
for o in Organizer.objects.all().iterator():
for i, t in enumerate(channel_types.values()):
if not t.default_created:
continue
type_to_channel[t.identifier, o.pk] = o.sales_channels.get_or_create(
type=t.identifier,
defaults=dict(
position=i,
identifier=t.identifier,
label=LazyI18nString.from_gettext(t.verbose_name),
),
)[0]
full_set.add(t.identifier)
if t.discounts_supported:
full_discount_set.add(t.identifier)
Event = apps.get_model("pretixbase", "Event")
for d in Event.objects.all().iterator():
if set(d.sales_channels) != full_set:
d.all_sales_channels = False
d.save()
for s in d.sales_channels:
d.limit_sales_channels.add(type_to_channel[s, d.organizer_id])
Item = apps.get_model("pretixbase", "Item")
for d in Item.objects.select_related("event").iterator():
if set(d.sales_channels) != full_set:
d.all_sales_channels = False
d.save()
for s in d.sales_channels:
d.limit_sales_channels.add(type_to_channel[s, d.event.organizer_id])
ItemVariation = apps.get_model("pretixbase", "ItemVariation")
for d in ItemVariation.objects.select_related("item__event").iterator():
if set(d.sales_channels) != full_set:
d.all_sales_channels = False
d.save()
for s in d.sales_channels:
d.limit_sales_channels.add(type_to_channel[s, d.item.event.organizer_id])
Discount = apps.get_model("pretixbase", "Discount")
for d in Discount.objects.select_related("event").iterator():
if set(d.sales_channels) != full_discount_set:
d.all_sales_channels = False
d.save()
for s in d.sales_channels:
d.limit_sales_channels.add(type_to_channel[s, d.event.organizer_id])
CheckinList = apps.get_model("pretixbase", "CheckinList")
for c in CheckinList.objects.select_related("event").iterator():
for s in c.auto_checkin_sales_channel_types:
c.auto_checkin_sales_channels.add(type_to_channel[s, c.event.organizer_id])
Order = apps.get_model("pretixbase", "Order")
for (k, orgid), v in type_to_channel.items():
Order.objects.filter(sales_channel_type=k, event__organizer_id=orgid, sales_channel__isnull=True).update(
sales_channel=v
)
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0265_saleschannel_and_more"),
]
operations = [
migrations.RunPython(create_sales_channels, migrations.RunPython.noop),
]
@@ -1,46 +0,0 @@
# Generated by Django 4.2.8 on 2024-03-25 13:34
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0266_saleschannel_migrate_data"),
]
operations = [
migrations.RemoveField(
model_name="checkinlist",
name="auto_checkin_sales_channel_types",
),
migrations.RemoveField(
model_name="discount",
name="sales_channels",
),
migrations.RemoveField(
model_name="event",
name="sales_channels",
),
migrations.RemoveField(
model_name="item",
name="sales_channels",
),
migrations.RemoveField(
model_name="itemvariation",
name="sales_channels",
),
migrations.RemoveField(
model_name="order",
name="sales_channel_type",
),
migrations.AlterField(
model_name="order",
name="sales_channel",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="pretixbase.saleschannel",
),
),
]
@@ -1,31 +0,0 @@
# Generated by Django 4.2.8 on 2024-07-01 09:26
from django.db import migrations, models
import pretix.base.models.orders
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0267_remove_old_sales_channels"),
]
operations = [
migrations.RemoveField(
model_name="subevent",
name="items",
),
migrations.RemoveField(
model_name="subevent",
name="variations",
),
migrations.AlterField(
model_name="order",
name="internal_secret",
field=models.CharField(
default=pretix.base.models.orders.generate_secret,
max_length=32,
null=True,
),
),
]
@@ -1,54 +0,0 @@
# Generated by Django 4.2.8 on 2024-07-01 09:27
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
(
"pretixbase",
"0268_remove_subevent_items_remove_subevent_variations_and_more",
),
]
operations = [
migrations.RunSQL(
"UPDATE pretixbase_order "
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_event e WHERE e.id = pretixbase_order.event_id) "
"WHERE pretixbase_order.id IN (SELECT id FROM pretixbase_order o2 WHERE o2.organizer_id IS NULL);"
),
migrations.RunSQL(
"UPDATE pretixbase_orderposition "
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_order o LEFT JOIN pretixbase_event e ON e.id = o.event_id WHERE o.id = pretixbase_orderposition.order_id) "
"WHERE pretixbase_orderposition.id IN (SELECT id FROM pretixbase_orderposition op2 WHERE op2.organizer_id IS NULL);"
),
migrations.AlterField(
model_name="order",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="orders",
to="pretixbase.organizer",
),
),
migrations.AlterField(
model_name="order",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="orders",
to="pretixbase.organizer",
),
),
migrations.AlterField(
model_name="orderposition",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="order_positions",
to="pretixbase.organizer",
),
),
]
+9 -45
View File
@@ -19,7 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import datetime
from collections import defaultdict
import pycountry
@@ -27,7 +26,6 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import EmailValidator
from django.db.models import Q
from django.utils import formats
from django.utils.functional import cached_property
from django.utils.translation import (
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
@@ -38,6 +36,7 @@ from i18nfield.strings import LazyI18nString
from phonenumber_field.phonenumber import to_python
from phonenumbers import SUPPORTED_REGIONS
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.questions import guess_country
from pretix.base.modelimport import (
DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, SubeventColumnMixin,
@@ -510,55 +509,21 @@ class ValidUntil(DatetimeColumnMixin, ImportColumn):
position.valid_until = value
class Expires(DatetimeColumnMixin, ImportColumn):
identifier = 'expires'
verbose_name = gettext_lazy('Expiry date')
def clean(self, value, previous_values):
if not value:
return
input_formats = formats.get_format('DATE_INPUT_FORMATS', use_l10n=True)
for format in input_formats:
try:
d = datetime.datetime.strptime(value, format)
d = d.replace(tzinfo=self.timezone, hour=23, minute=59, second=59)
return d
except (ValueError, TypeError):
pass
else:
return super().clean(value, previous_values) # parse date
def assign(self, value, order, position, invoice_address, **kwargs):
if value:
order.expires = value
class Saleschannel(ImportColumn):
identifier = 'sales_channel'
verbose_name = gettext_lazy('Sales channel')
default_value = None
initial = 'static:web'
@cached_property
def channels(self):
return list(self.event.organizer.sales_channels.all())
def static_choices(self):
return [
(c.identifier, str(c.label)) for c in self.channels
(sc.identifier, sc.verbose_name) for sc in get_all_sales_channels().values()
]
def clean(self, value, previous_values):
matches = [
p for p in self.channels
if p.identifier == value or any((v and v == value) for v in i18n_flat(p.label))
]
if len(matches) == 0:
if not value:
value = 'web'
if value not in get_all_sales_channels():
raise ValidationError(_("Please enter a valid sales channel."))
if len(matches) > 1:
raise ValidationError(_("Please enter a valid sales channel."))
return matches[0]
return value
def assign(self, value, order, position, invoice_address, **kwargs):
order.sales_channel = value
@@ -737,13 +702,12 @@ def get_order_import_columns(event):
AttendeeState(event),
Price(event),
Secret(event),
SeatColumn(event),
ValidFrom(event),
ValidUntil(event),
Locale(event),
Saleschannel(event),
Expires(event),
SeatColumn(event),
Comment(event),
ValidFrom(event),
ValidUntil(event),
]
for q in event.questions.prefetch_related('options').exclude(type=Question.TYPE_FILE):
default.append(QuestionColumn(event, q))
+3 -1
View File
@@ -44,6 +44,8 @@ class CodeColumn(ImportColumn):
super().__init__(*args)
def clean(self, value, previous_values):
if not value:
raise ValidationError(_('A voucher cannot be created without a code.'))
if value:
MinLengthValidator(5)(value)
if value and (value in self._cached or Voucher.objects.filter(event=self.event, code=value).exists()):
@@ -75,7 +77,7 @@ class MaxUsagesColumn(IntegerColumnMixin, ImportColumn):
]
def clean(self, value, previous_values):
if value is None and previous_values.get("code"):
if value is None:
raise ValidationError(_('The maximum number of usages must be set.'))
return super().clean(value, previous_values)
+1 -2
View File
@@ -51,8 +51,7 @@ from .orders import (
generate_secret,
)
from .organizer import (
Organizer, Organizer_SettingsStore, SalesChannel, Team, TeamAPIToken,
TeamInvite,
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
)
from .seating import Seat, SeatCategoryMapping, SeatingPlan
from .tax import TaxRule
+3 -7
View File
@@ -418,22 +418,18 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
else:
return set()
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
"""
Checks if this user is part of any team that grants access of type ``perm_name``
to the event ``event``.
Either ``request`` or ``session_key`` are required to detect staff sessions properly.
:param organizer: The organizer of the event
:param event: The event to check
:param perm_name: The permission, e.g. ``can_change_teams``
:param request: The current request (optional)
:param session_key: The current session key (optional)
:param request: The current request (optional). Required to detect staff sessions properly.
:return: bool
"""
assert not (session_key and request)
if (session_key or request) and self.has_active_staff_session(session_key or request.session.session_key):
if request and self.has_active_staff_session(request.session.session_key):
return True
teams = self._get_teams_for_event(organizer, event)
if teams:
+5 -4
View File
@@ -46,6 +46,7 @@ from django_scopes import ScopedManager, scopes_disabled
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.helpers import PostgresWindowFrame
@@ -99,13 +100,13 @@ class CheckinList(LoggedModel):
verbose_name=_('Automatically check out everyone at'),
null=True, blank=True
)
auto_checkin_sales_channels = models.ManyToManyField(
"SalesChannel",
auto_checkin_sales_channels = MultiStringField(
default=[],
blank=True,
verbose_name=_('Sales channels to automatically check in'),
help_text=_('All items on this check-in list will be automatically marked as checked-in when purchased through '
'any of the selected sales channels. This option can be useful when tickets sold at the box office '
'are not checked again before entry and should be considered validated directly upon purchase.'),
blank=True,
'are not checked again before entry and should be considered validated directly upon purchase.')
)
rules = models.JSONField(default=dict, blank=True)
+6 -10
View File
@@ -23,7 +23,6 @@
from collections import defaultdict
from decimal import Decimal
from itertools import groupby
from math import ceil
from typing import Dict, Optional, Tuple
from django.core.exceptions import ValidationError
@@ -34,6 +33,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models import fields
from pretix.base.models.base import LoggedModel
@@ -64,14 +64,10 @@ class Discount(LoggedModel):
default=0,
verbose_name=_("Position")
)
all_sales_channels = models.BooleanField(
verbose_name=_("All supported sales channels"),
default=True,
)
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Sales channels"),
blank=True,
sales_channels = fields.MultiStringField(
verbose_name=_('Sales channels'),
default=['web'],
blank=False,
)
available_from = models.DateTimeField(
@@ -276,7 +272,7 @@ class Discount(LoggedModel):
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3
n_groups = min(len(condition_idx_group) // self.condition_min_count, ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches))
n_groups = min(len(condition_idx_group) // self.condition_min_count, len(benefit_idx_group))
consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
else:
+33 -68
View File
@@ -36,7 +36,6 @@ import logging
import os
import string
import uuid
import warnings
from collections import Counter, OrderedDict, defaultdict
from datetime import datetime, time, timedelta
from operator import attrgetter
@@ -46,7 +45,6 @@ from zoneinfo import ZoneInfo
import pytz_deprecation_shim
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files import File
from django.core.files.storage import default_storage
from django.core.mail import get_connection
from django.core.validators import (
@@ -67,6 +65,7 @@ from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.timemachine import time_machine_now
from pretix.base.validators import EventSlugBanlistValidator
@@ -307,7 +306,6 @@ class EventMixin:
def annotated(cls, qs, channel='web', voucher=None):
from pretix.base.models import Item, ItemVariation, Quota
assert isinstance(channel, str)
sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher).filter(
Q(variations__isnull=True)
& Q(quotas__pk=OuterRef('pk'))
@@ -317,14 +315,14 @@ class EventMixin:
q_variation = (
Q(active=True)
& Q(Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=channel))
& Q(sales_channels__contains=channel)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()))
& Q(item__active=True)
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=time_machine_now()))
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=time_machine_now()))
& Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False))
& Q(Q(item__all_sales_channels=True) | Q(item__limit_sales_channels__identifier=channel))
& Q(item__sales_channels__contains=channel)
& Q(item__require_bundling=False)
& Q(quotas__pk=OuterRef('pk'))
)
@@ -468,7 +466,6 @@ class EventMixin:
return best_state_found, num_tickets_found, num_tickets_possible
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
assert isinstance(sales_channel, str) or sales_channel is None
qs_annotated = self._seats(ignore_voucher=ignore_voucher)
qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False)
@@ -497,13 +494,10 @@ class EventMixin:
return qs.filter(q)
def default_sales_channels(): # kept for legacy migration
from ..channels import get_all_sales_channel_types
def default_sales_channels():
from ..channels import get_all_sales_channels
if "PYTEST_CURRENT_TEST" not in os.environ:
warnings.warn('Method should not be used in new code.', DeprecationWarning)
return list(get_all_sales_channel_types().keys())
return list(get_all_sales_channels().keys())
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
@@ -540,10 +534,8 @@ class Event(EventMixin, LoggedModel):
:type plugins: str
:param has_subevents: Enable event series functionality
:type has_subevents: bool
:param all_sales_channels: A flag indicating that this event is available on all channels and limit_sales_channels will be ignored.
:type all_sales_channels: bool
:param limit_sales_channels: A list of sales channel identifiers, that this event is available for sale on
:type limit_sales_channels: list
:param sales_channels: A list of sales channel identifiers, that this event is available for sale on
:type sales_channels: list
"""
settings_namespace = 'event'
@@ -635,14 +627,10 @@ class Event(EventMixin, LoggedModel):
auto_now=True, db_index=True
)
all_sales_channels = models.BooleanField(
verbose_name=_("Sell on all sales channels"),
default=True,
)
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Restrict to specific sales channels"),
blank=True,
sales_channels = MultiStringField(
verbose_name=_('Restrict to specific sales channels'),
help_text=_('Only sell tickets for this event on the following sales channels.'),
default=default_sales_channels,
)
objects = ScopedManager(organizer='organizer')
@@ -800,6 +788,8 @@ class Event(EventMixin, LoggedModel):
), tz)
def copy_data_from(self, other, skip_meta_data=False):
from pretix.presale.style import regenerate_css
from ..signals import event_copy_data
from . import (
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
@@ -816,17 +806,10 @@ class Event(EventMixin, LoggedModel):
if other.date_admission:
self.date_admission = self.date_from + (other.date_admission - other.date_from)
self.testmode = other.testmode
self.all_sales_channels = other.all_sales_channels
self.sales_channels = other.sales_channels
self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
if not self.all_sales_channels:
self.limit_sales_channels.set(
self.organizer.sales_channels.filter(
identifier__in=other.limit_sales_channels.values_list("identifier", flat=True)
)
)
if not skip_meta_data:
for emv in EventMetaValue.objects.filter(event=other):
emv.pk = None
@@ -864,17 +847,12 @@ class Event(EventMixin, LoggedModel):
item_map = {}
variation_map = {}
for i in Item.objects.filter(event=other).prefetch_related(
'variations', 'limit_sales_channels', 'require_membership_types',
'variations__limit_sales_channels', 'variations__require_membership_types',
):
for i in Item.objects.filter(event=other).prefetch_related('variations'):
vars = list(i.variations.all())
require_membership_types = list(i.require_membership_types.all())
limit_sales_channels = list(i.limit_sales_channels.all())
item_map[i.pk] = i
i.pk = None
i.event = self
i._prefetched_objects_cache = {}
if i.picture:
i.picture.save(os.path.basename(i.picture.name), i.picture)
if i.category_id:
@@ -891,23 +869,12 @@ class Event(EventMixin, LoggedModel):
if require_membership_types and other.organizer_id == self.organizer_id:
i.require_membership_types.set(require_membership_types)
if not i.all_sales_channels:
i.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
for v in vars:
require_membership_types = list(v.require_membership_types.all())
limit_sales_channels = list(v.limit_sales_channels.all())
variation_map[v.pk] = v
v.pk = None
v.item = i
v._prefetched_objects_cache = {}
v.save(force_insert=True)
if require_membership_types and other.organizer_id == self.organizer_id:
v.require_membership_types.set(require_membership_types)
if not v.all_sales_channels:
v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
for i in self.items.filter(hidden_if_item_available__isnull=False):
i.hidden_if_item_available = item_map[i.hidden_if_item_available_id]
i.save()
@@ -945,7 +912,6 @@ class Event(EventMixin, LoggedModel):
vars = list(q.variations.all())
oldid = q.pk
q.pk = None
q._prefetched_objects_cache = {}
q.event = self
q.closed = False
q.save(force_insert=True)
@@ -957,15 +923,11 @@ class Event(EventMixin, LoggedModel):
q.variations.add(variation_map[v.pk])
self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q)
for d in Discount.objects.filter(event=other).prefetch_related(
'condition_limit_products', 'benefit_limit_products', 'limit_sales_channels'
):
for d in Discount.objects.filter(event=other).prefetch_related('condition_limit_products'):
c_items = list(d.condition_limit_products.all())
b_items = list(d.benefit_limit_products.all())
limit_sales_channels = list(v.limit_sales_channels.all())
d.pk = None
d.event = self
d._prefetched_objects_cache = {}
d.save(force_insert=True)
d.log_action('pretix.object.cloned')
for i in c_items:
@@ -975,16 +937,12 @@ class Event(EventMixin, LoggedModel):
if i.pk in item_map:
d.benefit_limit_products.add(item_map[i.pk])
if not d.all_sales_channels:
d.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
question_map = {}
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
items = list(q.items.all())
opts = list(q.options.all())
question_map[q.pk] = q
q.pk = None
q._prefetched_objects_cache = {}
q.event = self
q.save(force_insert=True)
q.log_action('pretix.object.cloned')
@@ -1015,14 +973,10 @@ class Event(EventMixin, LoggedModel):
_walk_rules(i)
checkin_list_map = {}
for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related(
'limit_products', 'auto_checkin_sales_channels'
):
for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related('limit_products'):
items = list(cl.limit_products.all())
auto_checkin_sales_channels = list(cl.auto_checkin_sales_channels.all())
checkin_list_map[cl.pk] = cl
cl.pk = None
cl._prefetched_objects_cache = {}
cl.event = self
rules = cl.rules
_walk_rules(rules)
@@ -1031,8 +985,6 @@ class Event(EventMixin, LoggedModel):
cl.log_action('pretix.object.cloned')
for i in items:
cl.limit_products.add(item_map[i.pk])
if auto_checkin_sales_channels:
cl.auto_checkin_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in auto_checkin_sales_channels]))
if other.seating_plan:
if other.seating_plan.organizer_id == self.organizer_id:
@@ -1058,10 +1010,10 @@ class Event(EventMixin, LoggedModel):
s.product = item_map[s.product_id]
s.save(force_insert=True)
has_custom_style = other.settings.presale_css_file or other.settings.presale_widget_css_file
skip_settings = (
'ticket_secrets_pretix_sig1_pubkey',
'ticket_secrets_pretix_sig1_privkey',
# no longer used, but we still don't need to copy them
'presale_css_file',
'presale_css_checksum',
'presale_widget_css_file',
@@ -1074,7 +1026,7 @@ class Event(EventMixin, LoggedModel):
s.object = self
s.pk = None
if s.value.startswith('file://') and settings_hierarkey.get_declared_type(s.key) == File:
if s.value.startswith('file://'):
fi = default_storage.open(s.value[len('file://'):], 'rb')
nonce = get_random_string(length=8)
fname_base = clean_filename(os.path.basename(s.value))
@@ -1104,6 +1056,9 @@ class Event(EventMixin, LoggedModel):
question_map=question_map, checkin_list_map=checkin_list_map, quota_map=quota_map,
)
if has_custom_style:
regenerate_css.apply_async(args=(self.pk,))
def get_payment_providers(self, cached=False) -> dict:
"""
Returns a dictionary of initialized payment providers mapped by their identifiers.
@@ -1381,12 +1336,18 @@ class Event(EventMixin, LoggedModel):
def enable_plugin(self, module, allow_restricted=frozenset()):
plugins_active = self.get_plugins()
from pretix.presale.style import regenerate_css
if module not in plugins_active:
plugins_active.append(module)
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
regenerate_css.apply_async(args=(self.pk,))
def disable_plugin(self, module):
plugins_active = self.get_plugins()
from pretix.presale.style import regenerate_css
if module in plugins_active:
plugins_active.remove(module)
self.set_active_plugins(plugins_active)
@@ -1395,6 +1356,8 @@ class Event(EventMixin, LoggedModel):
if module in plugins_available and hasattr(plugins_available[module].app, 'uninstalled'):
getattr(plugins_available[module].app, 'uninstalled')(self)
regenerate_css.apply_async(args=(self.pk,))
@staticmethod
def clean_has_subevents(event, has_subevents):
if event is not None and event.has_subevents is not None:
@@ -1504,6 +1467,8 @@ class SubEvent(EventMixin, LoggedModel):
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='subevents', verbose_name=_('Seating plan'))
items = models.ManyToManyField('Item', through='SubEventItem')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
comment = models.TextField(
verbose_name=_("Internal comment"),
null=True, blank=True
+15 -49
View File
@@ -34,10 +34,8 @@
# License for the specific language governing permissions and limitations under the License.
import calendar
import os
import sys
import uuid
import warnings
from collections import Counter, OrderedDict
from datetime import date, datetime, time, timedelta
from decimal import Decimal, DecimalException
@@ -63,6 +61,7 @@ from django_countries.fields import Country
from django_scopes import ScopedManager
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models import fields
from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.models.tax import TaxedPrice
@@ -123,16 +122,6 @@ class ItemCategory(LoggedModel):
return _('{category} (Add-On products)').format(category=str(name))
return str(name)
def get_category_type_display(self):
if self.is_addon:
return _('Add-On products')
else:
return None
@property
def category_type(self):
return 'addon' if self.is_addon else 'normal'
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
if self.event:
@@ -271,15 +260,13 @@ class SubEventItemVariation(models.Model):
def filter_available(qs, channel='web', voucher=None, allow_addons=False):
assert isinstance(channel, str)
q = (
# IMPORTANT: If this is updated, also update the ItemVariation query
# in models/event.py: EventMixin.annotated()
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info'))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info'))
& Q(Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=channel))
& Q(require_bundling=False)
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
)
if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
@@ -356,10 +343,8 @@ class Item(LoggedModel):
:type original_price: decimal.Decimal
:param require_approval: If set to ``True``, orders containing this product can only be processed and paid after approved by an administrator
:type require_approval: bool
:param all_sales_channels: A flag indicating that this item is available on all channels and limit_sales_channels will be ignored.
:type all_sales_channels: bool
:param limit_sales_channels: A list of sales channel identifiers, that this item is available for sale on.
:type limit_sales_channels: list
:param sales_channels: Sales channels this item is available on.
:type sales_channels: bool
:param issue_giftcard: If ``True``, buying this product will give you a gift card with the value of the product's price
:type issue_giftcard: bool
:param validity_mode: Instruction how to set ``valid_from``/``valid_until`` on tickets, ``null`` is default event validity.
@@ -459,8 +444,7 @@ class Item(LoggedModel):
free_price_suggestion = models.DecimalField(
verbose_name=_("Suggested price"),
help_text=_("This price will be used as the default value of the input field. The user can choose a lower "
"value, but not lower than the price this product would have without the free price option. This "
"will be ignored if a voucher is used that lowers the price."),
"value, but not lower than the price this product would have without the free price option."),
max_digits=13, decimal_places=2, null=True, blank=True,
)
tax_rule = models.ForeignKey(
@@ -614,14 +598,9 @@ class Item(LoggedModel):
help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
)
all_sales_channels = models.BooleanField(
verbose_name=_("Sell on all sales channels"),
default=True,
)
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Restrict to specific sales channels"),
help_text=_('Only sell tickets for this product on the selected sales channels.'),
sales_channels = fields.MultiStringField(
verbose_name=_('Sales channels'),
default=['web'],
blank=True,
)
issue_giftcard = models.BooleanField(
@@ -1043,13 +1022,9 @@ class Item(LoggedModel):
return None, None
def _all_sales_channels_identifiers(): # kept for legacy migrations
from pretix.base.channels import get_all_sales_channel_types
if "PYTEST_CURRENT_TEST" not in os.environ:
warnings.warn('Method should not be used in new code.', DeprecationWarning)
return list(get_all_sales_channel_types().keys())
def _all_sales_channels_identifiers():
from pretix.base.channels import get_all_sales_channels
return list(get_all_sales_channels().keys())
class ItemVariation(models.Model):
@@ -1072,10 +1047,6 @@ class ItemVariation(models.Model):
:param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after
approval by an administrator
:type require_approval: bool
:param all_sales_channels: A flag indicating that this variation is available on all channels and limit_sales_channels will be ignored.
:type all_sales_channels: bool
:param limit_sales_channels: A list of sales channel identifiers, that this variation is available for sale on.
:type limit_sales_channels: list
"""
item = models.ForeignKey(
@@ -1115,8 +1086,7 @@ class ItemVariation(models.Model):
free_price_suggestion = models.DecimalField(
verbose_name=_("Suggested price"),
help_text=_("This price will be used as the default value of the input field. The user can choose a lower "
"value, but not lower than the price this product would have without the free price option. This "
"will be ignored if a voucher is used that lowers the price."),
"value, but not lower than the price this product would have without the free price option."),
max_digits=13, decimal_places=2, null=True, blank=True,
)
require_approval = models.BooleanField(
@@ -1161,13 +1131,9 @@ class ItemVariation(models.Model):
default=Item.UNAVAIL_MODE_HIDDEN,
max_length=16,
)
all_sales_channels = models.BooleanField(
verbose_name=_("Sell on all sales channels the product is sold on"),
default=True,
)
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Restrict to specific sales channels"),
sales_channels = fields.MultiStringField(
verbose_name=_('Sales channels'),
default=_all_sales_channels_identifiers,
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
'selected here but not on product level, the variation will not be available.'),
blank=True,
+10 -83
View File
@@ -35,7 +35,6 @@
import copy
import hashlib
import hmac
import json
import logging
import operator
@@ -60,7 +59,7 @@ from django.db.models.functions import Coalesce, Greatest
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.urls import reverse
from django.utils.crypto import get_random_string, salted_hmac
from django.utils.crypto import get_random_string
from django.utils.encoding import escape_uri_path
from django.utils.formats import date_format
from django.utils.functional import cached_property
@@ -106,34 +105,6 @@ def generate_position_secret():
raise TypeError("Function no longer exists, use secret generators")
class OrderQuerySet(models.QuerySet):
def get_with_secret_check(self, code, received_secret, tag, secret_length=64):
dummy = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"[:secret_length]
try:
order = self.get(code=code)
except Order.DoesNotExist:
# Do a hash comparison as well to harden against timing attacks
hmac.compare_digest(
salted_hmac(key_salt=b"", value=tag, algorithm="sha256",
secret=dummy).hexdigest()[:secret_length],
received_secret[:secret_length]
)
raise Order.DoesNotExist
if not hmac.compare_digest(
order.tagged_secret(tag, secret_length) if tag else order.secret,
received_secret[:secret_length].lower() if tag else received_secret.lower()
) and not (
# TODO: remove this clause after a while (compatibility with old secrets currently in flight)
tag and hmac.compare_digest(
hashlib.sha1(order.secret.lower().encode()).hexdigest(),
received_secret.lower()
)
):
raise Order.DoesNotExist
return order
class Order(LockModel, LoggedModel):
"""
An order is created when a user clicks 'buy' on his cart. It holds
@@ -187,8 +158,8 @@ class Order(LockModel, LoggedModel):
:type require_approval: bool
:param meta_info: Additional meta information on the order, JSON-encoded.
:type meta_info: str
:param sales_channel: Foreign key to the sales channel this order was created through.
:type sales_channel: SalesChannel
:param sales_channel: Identifier of the sales channel this order was created through.
:type sales_channel: str
"""
STATUS_PENDING = "n"
@@ -223,6 +194,8 @@ class Order(LockModel, LoggedModel):
"Organizer",
related_name="orders",
on_delete=models.CASCADE,
null=True,
blank=True,
)
event = models.ForeignKey(
Event,
@@ -250,7 +223,6 @@ class Order(LockModel, LoggedModel):
verbose_name=_('Locale')
)
secret = models.CharField(max_length=32, default=generate_secret)
internal_secret = models.CharField(null=True, blank=True, max_length=32, default=generate_secret)
datetime = models.DateTimeField(
verbose_name=_("Date"), db_index=False
)
@@ -303,10 +275,7 @@ class Order(LockModel, LoggedModel):
require_approval = models.BooleanField(
default=False
)
sales_channel = models.ForeignKey(
"SalesChannel",
on_delete=models.PROTECT,
)
sales_channel = models.CharField(max_length=190, default="web")
email_known_to_work = models.BooleanField(
default=False,
verbose_name=_('E-mail address verified')
@@ -316,7 +285,7 @@ class Order(LockModel, LoggedModel):
default=False,
)
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Order")
@@ -882,9 +851,6 @@ class Order(LockModel, LoggedModel):
if self.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED):
return False
if self.event.settings.allow_modifications not in ("order", "attendee"):
return False
modify_deadline = self.modify_deadline
if modify_deadline is not None and time_machine_now() > modify_deadline:
return False
@@ -1254,10 +1220,6 @@ class Order(LockModel, LoggedModel):
_transactions_mark_order_clean(self.pk)
return create
def tagged_secret(self, tag, secret_length=64):
return salted_hmac(value=tag, key_salt=b"", algorithm="sha256",
secret=self.internal_secret or self.secret).hexdigest()[:secret_length]
def answerfile_name(instance, filename: str) -> str:
secret = get_random_string(length=32, allowed_chars=string.ascii_letters + string.digits)
@@ -1933,7 +1895,7 @@ class OrderPayment(models.Model):
trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment
)
if send_mail and self.order.sales_channel.identifier in self.order.event.settings.mail_sales_channel_placed_paid:
if send_mail and self.order.sales_channel in self.order.event.settings.mail_sales_channel_placed_paid:
self._send_paid_mail(invoice, user, mail_text)
if self.order.event.settings.mail_send_order_paid_attendee:
for p in self.order.positions.all():
@@ -2415,6 +2377,8 @@ class OrderPosition(AbstractPosition):
"Organizer",
related_name="order_positions",
on_delete=models.CASCADE,
null=True,
blank=True,
)
order = models.ForeignKey(
Order,
@@ -2550,43 +2514,6 @@ class OrderPosition(AbstractPosition):
reasons[b] = b
return reasons
@property
def can_modify_answers(self) -> bool:
"""
``True`` if the user can change the question answers / attendee names that are
related to the position. This checks order status and modification deadlines. It also
returns ``False`` if there are no questions that can be answered.
"""
from .checkin import Checkin
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID, Order.STATUS_EXPIRED):
return False
if self.event.settings.allow_modifications != "attendee":
return False
modify_deadline = self.order.modify_deadline
if modify_deadline is not None and now() > modify_deadline:
return False
positions = list(
self.order.positions.all().annotate(
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk'), list__consider_tickets_used=True))
).select_related('item').prefetch_related('item__questions')
)
if not self.event.settings.allow_modifications_after_checkin:
for cp in positions:
if cp.has_checkin:
return False
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
for cp in positions:
if cp.pk == self.pk or cp.addon_to_id == self.pk:
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
return True
return False # nothing there to modify
@classmethod
def transform_cart_positions(cls, cp: List, order) -> list:
from . import Voucher
-77
View File
@@ -46,9 +46,7 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scope
from i18nfield.fields import I18nCharField
from i18nfield.strings import LazyI18nString
from pretix.base.models.base import LoggedModel
from pretix.base.validators import OrganizerSlugBanlistValidator
@@ -106,8 +104,6 @@ class Organizer(LoggedModel):
if is_new:
kwargs.pop('update_fields', None) # does not make sense here
self.set_defaults()
with scope(organizer=self):
self.create_default_sales_channels()
else:
self.get_cache().clear()
return obj
@@ -216,24 +212,6 @@ class Organizer(LoggedModel):
else:
return get_connection(fail_silently=False)
def create_default_sales_channels(self):
from pretix.base.channels import get_all_sales_channel_types
i = 0
for channel in get_all_sales_channel_types().values():
if not channel.default_created:
continue
self.sales_channels.get_or_create(
identifier=channel.identifier,
defaults={
'label': LazyI18nString.from_gettext(channel.verbose_name),
'type': channel.identifier,
},
position=i
)
i += 1
def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
@@ -526,58 +504,3 @@ class OrganizerFooterLink(models.Model):
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.organizer.cache.clear()
class SalesChannel(LoggedModel):
organizer = models.ForeignKey('Organizer', on_delete=models.CASCADE, related_name='sales_channels')
label = I18nCharField(
max_length=200,
verbose_name=_("Name"),
)
identifier = models.CharField(
verbose_name=_("Identifier"),
max_length=200,
validators=[
RegexValidator(
regex=r"^[a-zA-Z0-9.\-_]+$",
message=_("The identifier may only contain letters, numbers, dots, dashes, and underscores."),
),
],
)
type = models.CharField(
verbose_name=_("Type"),
max_length=200,
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
configuration = models.JSONField(default=dict)
objects = ScopedManager(organizer="organizer")
class Meta:
ordering = ("position", "type", "identifier", "id")
unique_together = ("organizer", "identifier")
def __str__(self):
return str(self.label)
@cached_property
def type_instance(self):
from ..channels import get_all_sales_channel_types
types = get_all_sales_channel_types()
return types[self.type]
@property
def icon(self):
return self.type_instance.icon
def allow_delete(self):
from . import Order
if self.type_instance.default_created:
return False
return not Order.objects.filter(sales_channel=self).exists()
+1 -5
View File
@@ -243,14 +243,10 @@ class Seat(models.Model):
qs_annotated = qs_annotated.annotate(has_closeby_taken=Exists(sq_closeby))
return qs_annotated
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None,
sales_channel='web',
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, sales_channel='web',
ignore_distancing=False, distance_ignore_cart_id=None):
from .orders import Order
from .organizer import SalesChannel
if isinstance(sales_channel, SalesChannel):
sales_channel = sales_channel.identifier
if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
return False
opqs = self.orderposition_set.filter(
+5 -6
View File
@@ -370,11 +370,10 @@ class Voucher(LoggedModel):
'redeemed': redeemed
}
)
if data.get('min_usages') is not None:
if data.get('max_usages', 1) < data.get('min_usages', 1):
raise ValidationError(
_('The maximum number of usages may not be lower than the minimum number of usages.'),
)
if data.get('max_usages', 1) < data.get('min_usages', 1):
raise ValidationError(
_('The maximum number of usages may not be lower than the minimum number of usages.'),
)
@staticmethod
def clean_subevent(data, event):
@@ -460,7 +459,7 @@ class Voucher(LoggedModel):
new_quotas = set(
Quota.objects.filter(
pk__in=Quota.variations.through.objects.filter(
itemvariation__item=item,
itemvariation__item=old_instance.item,
quota__subevent=data.get('subevent'),
).values('quota_id')
)
+4 -3
View File
@@ -56,6 +56,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import I18nMarkdownTextarea, PlaceholderValidator
from pretix.base.models import (
CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
@@ -416,8 +417,8 @@ class BasePaymentProvider:
forms.MultipleChoiceField(
label=_('Restrict to specific sales channels'),
choices=(
(c.identifier, c.label) for c in self.event.organizer.sales_channels.all()
if c.type_instance.payment_restrictions_supported
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
if c.payment_restrictions_supported
),
initial=['web'],
widget=forms.CheckboxSelectMultiple,
@@ -852,7 +853,7 @@ class BasePaymentProvider:
if str(ia.country) != '' and str(ia.country) not in restricted_countries:
return False
if order.sales_channel.identifier not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']):
if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']):
return False
return self._is_available_by_time(order=order)
+84 -144
View File
@@ -62,7 +62,8 @@ from django.utils.html import conditional_escape
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext
from i18nfield.strings import LazyI18nString
from pypdf import PdfReader, PdfWriter
from pypdf import PdfReader, PdfWriter, Transformation
from pypdf.generic import RectangleObject
from reportlab.graphics import renderPDF
from reportlab.graphics.barcode.qr import QrCodeWidget
from reportlab.graphics.shapes import Drawing
@@ -407,30 +408,6 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT"
)
}),
("purchase_date", {
"label": _("Purchase date"),
"editor_sample": _("2017-05-31"),
"evaluate": lambda op, order, ev: date_format(
order.datetime.astimezone(ev.timezone),
"SHORT_DATE_FORMAT"
)
}),
("purchase_datetime", {
"label": _("Purchase date and time"),
"editor_sample": _("2017-05-31 19:00"),
"evaluate": lambda op, order, ev: date_format(
order.datetime.astimezone(ev.timezone),
"SHORT_DATETIME_FORMAT"
)
}),
("purchase_time", {
"label": _("Purchase time"),
"editor_sample": _("19:00"),
"evaluate": lambda op, order, ev: date_format(
order.datetime.astimezone(ev.timezone),
"TIME_FORMAT"
)
}),
("valid_from_date", {
"label": _("Validity start date"),
"editor_sample": _("2017-05-31"),
@@ -1068,81 +1045,56 @@ class Renderer:
canvas.showPage()
def render_background(self, buffer, title=_('Ticket')):
buffer.seek(0)
fg_pdf = PdfReader(buffer)
if settings.PDFTK:
buffer.seek(0)
with tempfile.TemporaryDirectory() as d:
fg_filename = os.path.join(d, 'fg.pdf')
bg_filename = os.path.join(d, 'bg.pdf')
out_filename = os.path.join(d, 'out.pdf')
buffer.seek(0)
with open(fg_filename, 'wb') as f:
with open(os.path.join(d, 'back.pdf'), 'wb') as f:
f.write(self.bg_bytes)
with open(os.path.join(d, 'front.pdf'), 'wb') as f:
f.write(buffer.read())
# pdf_header is a string like "%pdf-X.X"
if float(self.bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
# To fix issues with pdftk and background-PDF using pdf-version greater
# than foreground-PDF, we stamp front onto back instead.
# Just changing PDF-version in fg.pdf to match the version of
# bg.pdf as we do with pypdf, does not work with pdftk.
#
# Make sure that bg.pdf matches the number of pages of fg.pdf
# note: self.bg_pdf is a PdfReader(), not a PdfWriter()
fg_num_pages = fg_pdf.get_num_pages()
bg_num_pages = self.bg_pdf.get_num_pages()
bg_pdf_to_merge = PdfWriter()
bg_pdf_to_merge.append(
self.bg_pdf,
pages=(0, min(bg_num_pages, fg_num_pages)),
import_outline=False,
excluded_fields=("/Annots", "/B")
)
if fg_num_pages > bg_num_pages:
# repeat last page in bg_pdf to match fg_pdf
bg_pdf_to_merge.append(
bg_pdf_to_merge,
pages=[bg_num_pages - 1] * (fg_num_pages - bg_num_pages),
import_outline=False,
excluded_fields=("/Annots", "/B")
)
bg_pdf_to_merge.write(bg_filename)
pdftk_cmd = [
settings.PDFTK,
bg_filename,
'multistamp',
fg_filename
]
else:
with open(bg_filename, 'wb') as f:
f.write(self.bg_bytes)
pdftk_cmd = [
settings.PDFTK,
fg_filename,
'multibackground',
bg_filename
]
pdftk_cmd.extend(('output', out_filename, 'compress'))
subprocess.run(pdftk_cmd, check=True)
with open(out_filename, 'rb') as f:
subprocess.run([
settings.PDFTK,
os.path.join(d, 'front.pdf'),
'multibackground',
os.path.join(d, 'back.pdf'),
'output',
os.path.join(d, 'out.pdf'),
'compress'
], check=True)
with open(os.path.join(d, 'out.pdf'), 'rb') as f:
return BytesIO(f.read())
else:
buffer.seek(0)
new_pdf = PdfReader(buffer)
output = PdfWriter()
for i, page in enumerate(fg_pdf.pages):
bg_page = self.bg_pdf.pages[i]
if bg_page.rotation != 0:
bg_page.transfer_rotation_to_content()
page.merge_page(bg_page, over=False)
output.add_page(page)
# pdf_header is a string like "%pdf-X.X"
if float(self.bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
output.pdf_header = self.bg_pdf.pdf_header
for i, page in enumerate(new_pdf.pages):
bg_page = copy.deepcopy(self.bg_pdf.pages[i])
bg_rotation = bg_page.get('/Rotate')
if bg_rotation:
# /Rotate is clockwise, transformation.rotate is counter-clockwise
t = Transformation().rotate(bg_rotation)
w = float(page.mediabox.getWidth())
h = float(page.mediabox.getHeight())
if bg_rotation in (90, 270):
# offset due to rotation base
if bg_rotation == 90:
t = t.translate(h, 0)
else:
t = t.translate(0, w)
# rotate mediabox as well
page.mediabox = RectangleObject((
page.mediabox.left.as_numeric(),
page.mediabox.bottom.as_numeric(),
page.mediabox.top.as_numeric(),
page.mediabox.right.as_numeric(),
))
page.trimbox = page.mediabox
elif bg_rotation == 180:
t = t.translate(w, h)
page.add_transformation(t)
bg_page.merge_page(page)
output.add_page(bg_page)
output.add_metadata({
'/Title': str(title),
@@ -1154,66 +1106,54 @@ class Renderer:
return outbuffer
def merge_background(fg_pdf: PdfWriter, bg_pdf: PdfWriter, out_file, compress):
def merge_background(fg_pdf, bg_pdf, out_file, compress):
if settings.PDFTK:
with tempfile.TemporaryDirectory() as d:
fg_filename = os.path.join(d, 'fg.pdf')
bg_filename = os.path.join(d, 'bg.pdf')
# pdf_header is a string like "%pdf-X.X"
if float(bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
# To fix issues with pdftk and background-PDF using pdf-version greater
# than foreground-PDF, we stamp front onto back instead.
# Just changing PDF-version in fg.pdf to match the version of
# bg.pdf as we do with pypdf, does not work with pdftk.
# Make sure that bg.pdf matches the number of pages of fg.pdf
fg_num_pages = fg_pdf.get_num_pages()
bg_num_pages = bg_pdf.get_num_pages()
if fg_num_pages > bg_num_pages:
# repeat last page in bg_pdf to match fg_pdf
bg_pdf.append(
bg_pdf,
pages=[bg_num_pages - 1] * (fg_num_pages - bg_num_pages),
import_outline=False,
excluded_fields=("/Annots", "/B")
)
bg_pdf.write(bg_filename)
pdftk_cmd = [
settings.PDFTK,
bg_filename,
'multistamp',
fg_filename,
]
else:
pdftk_cmd = [
settings.PDFTK,
fg_filename,
'multibackground',
bg_filename
]
pdftk_cmd.extend(('output', '-'))
if compress:
pdftk_cmd.append('compress')
fg_pdf.write(fg_filename)
bg_pdf.write(bg_filename)
pdftk_cmd = [
settings.PDFTK,
fg_filename,
'multibackground',
bg_filename,
'output',
'-',
]
if compress:
pdftk_cmd.append('compress')
subprocess.run(pdftk_cmd, check=True, stdout=out_file)
else:
output = PdfWriter()
for i, page in enumerate(fg_pdf.pages):
bg_page = bg_pdf.pages[i]
if bg_page.rotation != 0:
bg_page.transfer_rotation_to_content()
page.merge_page(bg_page, over=False)
# pdf_header is a string like "%pdf-X.X"
if float(bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
fg_pdf.pdf_header = bg_pdf.pdf_header
fg_pdf.write(out_file)
bg_page = copy.deepcopy(bg_pdf.pages[i])
bg_rotation = bg_page.get('/Rotate')
if bg_rotation:
# /Rotate is clockwise, transformation.rotate is counter-clockwise
t = Transformation().rotate(bg_rotation)
w = float(page.mediabox.getWidth())
h = float(page.mediabox.getHeight())
if bg_rotation in (90, 270):
# offset due to rotation base
if bg_rotation == 90:
t = t.translate(h, 0)
else:
t = t.translate(0, w)
# rotate mediabox as well
page.mediabox = RectangleObject((
page.mediabox.left.as_numeric(),
page.mediabox.bottom.as_numeric(),
page.mediabox.top.as_numeric(),
page.mediabox.right.as_numeric(),
))
page.trimbox = page.mediabox
elif bg_rotation == 180:
t = t.translate(w, h)
page.add_transformation(t)
bg_page.merge_page(page)
output.add_page(bg_page)
output.write(out_file)
@deconstructible
+10 -42
View File
@@ -52,11 +52,12 @@ from django.utils.translation import (
)
from django_scopes import scopes_disabled
from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemVariation, SalesChannel,
Seat, SeatCategoryMapping, Voucher,
CartPosition, Event, InvoiceAddress, Item, ItemVariation, Seat,
SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
@@ -274,8 +275,8 @@ class CartManager:
AddOperation: 30
}
def __init__(self, event: Event, cart_id: str, sales_channel: SalesChannel,
invoice_address: InvoiceAddress=None, widget_data=None):
def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None, widget_data=None,
sales_channel='web'):
self.event = event
self.cart_id = cart_id
self.real_now_dt = now()
@@ -383,7 +384,7 @@ class CartManager:
})
def _check_max_cart_size(self):
if not self._sales_channel.type_instance.unlimited_items_per_order:
if not get_all_sales_channels()[self._sales_channel].unlimited_items_per_order:
cartsize = self.positions.filter(addon_to__isnull=True).count()
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
@@ -421,13 +422,8 @@ class CartManager:
elif op.item.media_policy == Item.MEDIA_POLICY_REUSE:
raise CartError(error_messages['media_usage_not_implemented'])
if not op.item.all_sales_channels:
if self._sales_channel.identifier not in (s.identifier for s in op.item.limit_sales_channels.all()):
raise CartError(error_messages['unavailable'])
if op.variation and not op.variation.all_sales_channels:
if self._sales_channel.identifier not in (s.identifier for s in op.variation.limit_sales_channels.all()):
raise CartError(error_messages['unavailable'])
if self._sales_channel not in op.item.sales_channels or (op.variation and self._sales_channel not in op.variation.sales_channels):
raise CartError(error_messages['unavailable'])
if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
raise CartError(error_messages['not_for_sale'])
@@ -461,14 +457,7 @@ class CartManager:
raise CartError(error_messages['ended'])
seated = self._is_seated(op.item, op.subevent)
if (
seated and (
not op.seat or (
op.seat.blocked and
self._sales_channel.identifier not in self.event.settings.seating_allow_blocked_seats_for_channel
)
)
):
if seated and (not op.seat or (op.seat.blocked and self._sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel)):
raise CartError(error_messages['seat_invalid'])
elif op.seat and not seated:
raise CartError(error_messages['seat_forbidden'])
@@ -1382,7 +1371,7 @@ class CartManager:
discount_results = apply_discounts(
self.event,
self._sales_channel.identifier,
self._sales_channel,
[
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in positions
@@ -1516,11 +1505,6 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
except InvoiceAddress.DoesNotExist:
pass
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data,
@@ -1542,10 +1526,6 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
:param session: Session ID of a guest
"""
with language(locale), time_machine_now_assigned(override_now_dt):
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
@@ -1566,10 +1546,6 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
:param session: Session ID of a guest
"""
with language(locale), time_machine_now_assigned(override_now_dt):
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
@@ -1589,10 +1565,6 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
:param session: Session ID of a guest
"""
with language(locale), time_machine_now_assigned(override_now_dt):
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
@@ -1621,10 +1593,6 @@ def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, l
ia = InvoiceAddress.objects.get(pk=invoice_address)
except InvoiceAddress.DoesNotExist:
pass
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
+1 -1
View File
@@ -1159,7 +1159,7 @@ def order_placed(sender, **kwargs):
order = kwargs['order']
event = sender
cls = list(event.checkin_lists.filter(auto_checkin_sales_channels=order.sales_channel).prefetch_related(
cls = list(event.checkin_lists.filter(auto_checkin_sales_channels__contains=order.sales_channel).prefetch_related(
'limit_products'))
if not cls:
return
+3 -6
View File
@@ -420,7 +420,7 @@ def invoice_pdf_task(invoice: int):
def invoice_qualified(order: Order):
if order.total == Decimal('0.00') or order.require_approval or \
order.sales_channel.identifier not in order.event.settings.get('invoice_generate_sales_channels'):
order.sales_channel not in order.event.settings.get('invoice_generate_sales_channels'):
return False
return True
@@ -443,11 +443,8 @@ def build_preview_invoice_pdf(event):
locale = event.settings.locale
with rolledback_transaction(), language(locale, event.settings.region):
order = event.orders.create(
status=Order.STATUS_PENDING, datetime=timezone.now(),
expires=timezone.now(), code="PREVIEW", total=100 * event.tax_rules.count(),
sales_channel=event.organizer.sales_channels.get(identifier="web"),
)
order = event.orders.create(status=Order.STATUS_PENDING, datetime=timezone.now(),
expires=timezone.now(), code="PREVIEW", total=100 * event.tax_rules.count())
invoice = Invoice(
order=order, event=event, invoice_no="PREVIEW",
date=timezone.now().date(), locale=locale, organizer=event.organizer
-2
View File
@@ -213,8 +213,6 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
voucher = Voucher(event=event)
vouchers.append(voucher)
if not record.get("code"):
raise ValidationError(_('A voucher cannot be created without a code.'))
Voucher.clean_item_properties(
record,
event,
+27 -47
View File
@@ -62,6 +62,7 @@ from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy
from django_scopes import scopes_disabled
from pretix.api.models import OAuthApplication
from pretix.base.channels import get_all_sales_channels
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
@@ -75,7 +76,7 @@ from pretix.base.models.orders import (
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
generate_secret,
)
from pretix.base.models.organizer import SalesChannel, TeamAPIToken
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.payment import GiftCardPayment, PaymentException
from pretix.base.reldate import RelativeDateWrapper
@@ -98,8 +99,8 @@ from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
from pretix.base.signals import (
order_approved, order_canceled, order_changed, order_denied, order_expired,
order_fee_calculation, order_paid, order_placed, order_reactivated,
order_split, order_valid_if_pending, periodic_task, validate_order,
order_fee_calculation, order_paid, order_placed, order_split,
order_valid_if_pending, periodic_task, validate_order,
)
from pretix.base.timemachine import time_machine_now, time_machine_now_assigned
from pretix.celery_app import app
@@ -253,7 +254,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
else:
raise OrderError(is_available)
order_reactivated.send(order.event, order=order)
order_approved.send(order.event, order=order)
if order.status == Order.STATUS_PAID:
order_paid.send(order.event, order=order)
@@ -468,10 +469,10 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
order_denied.send(order.event, order=order)
if send_mail:
email_template = order.event.settings.mail_text_order_denied
email_subject = order.event.settings.mail_subject_order_denied
email_context = get_email_context(event=order.event, order=order, comment=comment)
with language(order.locale, order.event.settings.region):
email_template = order.event.settings.mail_text_order_denied
email_subject = order.event.settings.mail_subject_order_denied
email_context = get_email_context(event=order.event, order=order, comment=comment)
try:
order.send_mail(
email_subject, email_template, email_context,
@@ -649,7 +650,8 @@ def _check_date(event: Event, now_dt: datetime):
def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: datetime, positions: List[CartPosition],
sales_channel: SalesChannel, address: InvoiceAddress=None, customer=None):
address: InvoiceAddress = None,
sales_channel='web', customer=None):
err = None
_check_date(event, time_machine_now_dt)
@@ -773,7 +775,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
if cp.seat:
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every
# time, since we absolutely can not overbook a seat.
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel.identifier):
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel):
err = err or error_messages['seat_unavailable']
delete(cp)
continue
@@ -871,7 +873,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
discount_results = apply_discounts(
event,
sales_channel.identifier,
sales_channel,
[
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in sorted_positions
@@ -957,11 +959,12 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
return fees
def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime,
payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None,
address: InvoiceAddress=None, meta_info: dict=None, shown_total=None,
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
payment_requests: List[dict], locale: str=None, address: InvoiceAddress=None,
meta_info: dict=None, sales_channel: str='web', shown_total=None,
customer=None, valid_if_pending=False):
payments = []
sales_channel = get_all_sales_channels()[sales_channel]
try:
validate_memberships_in_order(customer, positions, event, lock=True, testmode=event.testmode)
@@ -983,10 +986,10 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
datetime=now_dt,
locale=get_language_without_region(locale),
total=total,
testmode=True if sales_channel.type_instance.testmode_supported and event.testmode else False,
testmode=True if sales_channel.testmode_supported and event.testmode else False,
meta_info=json.dumps(meta_info or {}),
require_approval=require_approval,
sales_channel=sales_channel,
sales_channel=sales_channel.identifier,
customer=customer,
valid_if_pending=valid_if_pending,
)
@@ -1105,11 +1108,6 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
if customer:
customer = event.organizer.customers.get(pk=customer)
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise OrderError("Invalid sales channel.")
if email == settings.PRETIX_EMAIL_NONE_VALUE:
email = None
@@ -1188,20 +1186,9 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
if 'sleep-after-quota-check' in debugflags_var.get():
sleep(2)
order, payment_objs = _create_order(
event,
email=email,
positions=positions,
now_dt=real_now_dt,
payment_requests=payment_requests,
locale=locale,
address=addr,
meta_info=meta_info,
sales_channel=sales_channel,
shown_total=shown_total,
customer=customer,
valid_if_pending=valid_if_pending
)
order, payment_objs = _create_order(event, email, positions, real_now_dt, payment_requests,
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
shown_total=shown_total, customer=customer, valid_if_pending=valid_if_pending)
try:
for p in payment_objs:
@@ -1285,7 +1272,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
email_attendees_template = event.settings.mail_text_order_placed_attendee
subject_attendees_template = event.settings.mail_subject_order_placed_attendee
if sales_channel.identifier in event.settings.mail_sales_channel_placed_paid:
if sales_channel in event.settings.mail_sales_channel_placed_paid:
_order_placed_email(event, order, email_template, subject_template, log_entry, invoice, payment_objs,
is_free=free_order_flow)
if email_attendees:
@@ -1437,7 +1424,7 @@ def send_download_reminders(sender, **kwargs):
if days is None:
continue
if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder:
if o.sales_channel not in event.settings.mail_sales_channel_download_reminder:
continue
reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
@@ -1939,13 +1926,9 @@ class OrderChangeManager:
if not item.is_available() or (variation and not variation.is_available()):
raise OrderError(error_messages['unavailable'])
if not item.all_sales_channels:
if self.order.sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()):
raise OrderError(error_messages['unavailable'])
if variation and not variation.all_sales_channels:
if self.order.sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()):
raise OrderError(error_messages['unavailable'])
if self.order.sales_channel not in item.sales_channels or (
variation and self.order.sales_channel not in variation.sales_channels):
raise OrderError(error_messages['unavailable'])
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available():
raise OrderError(error_messages['not_for_sale'])
@@ -2044,10 +2027,7 @@ class OrderChangeManager:
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
or (a.variation and self.order.sales_channel not in a.variation.sales_channels)
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or (
not item.all_sales_channels and
not item.limit_sales_channels.contains(self.order.sales_channel)
)
or self.order.sales_channel not in item.sales_channels
)
if is_unavailable:
continue
-34
View File
@@ -337,40 +337,6 @@ def base_placeholders(sender, **kwargs):
}
),
),
SimpleFunctionalTextPlaceholder(
'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri(
event,
'presale:event.order.position.modify', kwargs={
'order': position.order.code,
'secret': position.web_secret,
'position': position.positionid
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.position.modify', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'position': '123',
}
),
),
SimpleFunctionalTextPlaceholder(
'url_products_change', ['position', 'event'], lambda position, event: build_absolute_uri(
event,
'presale:event.order.position.change', kwargs={
'order': position.order.code,
'secret': position.web_secret,
'position': position.positionid
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.position.change', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'position': '123'
}
),
),
SimpleFunctionalTextPlaceholder(
'order_modification_deadline_date_and_time', ['order', 'event'],
lambda order, event:
+2 -5
View File
@@ -28,8 +28,7 @@ from django.db.models import Q
from pretix.base.decimal import round_decimal
from pretix.base.models import (
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
SalesChannel, Voucher,
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, Voucher,
)
from pretix.base.models.event import Event, SubEvent
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
@@ -165,14 +164,12 @@ def apply_discounts(event: Event, sales_channel: str,
:param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
:return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input
"""
if isinstance(sales_channel, SalesChannel):
sales_channel = sales_channel.identifier
new_prices = {}
discount_qs = event.discounts.filter(
Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()),
Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()),
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=sales_channel),
sales_channels__contains=sales_channel,
active=True,
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs:
+1 -4
View File
@@ -62,10 +62,7 @@ class VATIDTemporaryError(VATIDError):
def _validate_vat_id_NO(vat_id, country_code):
# Inspired by vat_moss library
try:
vat_id = vat_moss.id.normalize(vat_id)
except ValueError:
raise VATIDFinalError(error_messages['invalid'])
vat_id = vat_moss.id.normalize(vat_id)
if not vat_id or len(vat_id) < 3 or not re.match('^\\d{9}MVA$', vat_id[2:]):
raise VATIDFinalError(error_messages['invalid'])
+20 -30
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>.
#
import sys
from collections import defaultdict
from datetime import timedelta
from django.db import transaction
@@ -50,28 +49,19 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
quota_cache = {}
gone = set()
_seats_available_cache = {}
seats_used = defaultdict(int)
seats_available = {}
seated_product_set = set(
SeatCategoryMapping.objects.filter(event=event).values_list('product_id', 'subevent_id')
)
def _seats_available(item, subevent):
for m in SeatCategoryMapping.objects.filter(event=event).select_related('subevent'):
# See comment in WaitingListEntry.send_voucher() for rationale
subevent_id = subevent.pk if subevent else None
if (item.pk, subevent_id) not in _seats_available_cache:
num_free_seats_for_product = (subevent or event).free_seats().filter(product_id=item.pk).count()
num_valid_vouchers_for_product = event.vouchers.filter(
Q(valid_until__isnull=True) | Q(valid_until__gte=now()),
block_quota=True,
item_id=item.pk,
subevent_id=subevent_id,
waitinglistentries__isnull=False
).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0
_seats_available_cache[item.pk, subevent_id] = num_free_seats_for_product - num_valid_vouchers_for_product
return _seats_available_cache[item.pk, subevent_id] - seats_used[item.pk, subevent_id]
num_free_seets_for_product = (m.subevent or event).free_seats().filter(product_id=m.product_id).count()
num_valid_vouchers_for_product = event.vouchers.filter(
Q(valid_until__isnull=True) | Q(valid_until__gte=now()),
block_quota=True,
item_id=m.product_id,
subevent_id=m.subevent_id,
waitinglistentries__isnull=False
).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0
seats_available[(m.product_id, m.subevent_id)] = num_free_seets_for_product - num_valid_vouchers_for_product
prefetch_related_objects(
[event.organizer],
@@ -113,7 +103,7 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
lock_objects(quotas, shared_lock_objects=[event])
for wle in qs:
if (wle.item_id, wle.variation_id, wle.subevent_id) in gone:
if (wle.item, wle.variation, wle.subevent) in gone:
continue
ev = (wle.subevent or event)
if not ev.presale_is_running or (wle.subevent and not wle.subevent.active):
@@ -121,15 +111,15 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
if wle.subevent and not wle.subevent.presale_is_running:
continue
if event.settings.waiting_list_auto_disable and event.settings.waiting_list_auto_disable.datetime(wle.subevent or event) <= now():
gone.add((wle.item_id, wle.variation_id, wle.subevent_id))
gone.add((wle.item, wle.variation, wle.subevent))
continue
if not wle.item.is_available():
gone.add((wle.item_id, wle.variation_id, wle.subevent_id))
gone.add((wle.item, wle.variation, wle.subevent))
continue
if (wle.item_id, wle.subevent_id) in seated_product_set:
if _seats_available(wle.item, wle.subevent) < 1:
gone.add((wle.item_id, wle.variation_id, wle.subevent_id))
if (wle.item_id, wle.subevent_id) in seats_available:
if seats_available[wle.item_id, wle.subevent_id] < 1:
gone.add((wle.item, wle.variation, wle.subevent))
continue
availability = (
@@ -151,10 +141,10 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
quota_cache[q.pk][1] - 1 if quota_cache[q.pk][1] is not None else sys.maxsize
)
if (wle.item_id, wle.subevent_id) in seated_product_set:
seats_used[wle.item_id, wle.subevent_id] += 1
if (wle.item_id, wle.subevent_id) in seats_available:
seats_available[wle.item_id, wle.subevent_id] -= 1
else:
gone.add((wle.item_id, wle.variation_id, wle.subevent_id))
gone.add((wle.item, wle.variation, wle.subevent))
return sent
+21 -26
View File
@@ -971,8 +971,7 @@ DEFAULTS = {
},
'payment_giftcard__enabled': {
'default': 'True',
'type': bool,
'serializer_class': serializers.BooleanField,
'type': bool
},
'payment_giftcard_public_name': {
'default': LazyI18nString.from_gettext(gettext_noop('Gift card')),
@@ -1654,28 +1653,6 @@ DEFAULTS = {
"calendar.")
)
},
'allow_modifications': {
'default': 'order',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'serializer_kwargs': dict(
choices=(
('no', _('No modifications after order was submitted')),
('order', _('Only the person who ordered can make changes')),
('attendee', _('Both the attendee and the person who ordered can make changes')),
)
),
'form_kwargs': dict(
label=_("Allow customers to modify their information"),
widget=forms.RadioSelect,
choices=(
('no', _('No modifications after order was submitted')),
('order', _('Only the person who ordered can make changes')),
('attendee', _('Both the attendee and the person who ordered can make changes')),
)
),
},
'allow_modifications_after_checkin': {
'default': 'False',
'type': bool,
@@ -1683,8 +1660,6 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Allow customers to modify their information after they checked in."),
help_text=_("By default, no more modifications are possible for an order as soon as one of the tickets "
"in the order has been checked in.")
)
},
'last_order_modification_date': {
@@ -2851,6 +2826,22 @@ Your {organizer} team""")) # noqa: W291
**primary_font_kwargs()
),
},
'presale_css_file': {
'default': None,
'type': str
},
'presale_css_checksum': {
'default': None,
'type': str
},
'presale_widget_css_file': {
'default': None,
'type': str
},
'presale_widget_css_checksum': {
'default': None,
'type': str
},
'logo_image': {
'default': None,
'type': File,
@@ -3380,6 +3371,10 @@ Your {organizer} team""")) # noqa: W291
'type': str,
}
}
SETTINGS_AFFECTING_CSS = {
'primary_color', 'theme_color_success', 'theme_color_danger', 'primary_font',
'theme_color_background', 'theme_round_borders'
}
PERSON_NAME_TITLE_GROUPS = OrderedDict([
('english_common', (_('Most common English titles'), (
'Mr',
+2 -5
View File
@@ -294,16 +294,13 @@ This signal is sent out when a notification is sent.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_sales_channel_types = django.dispatch.Signal()
register_sales_channels = django.dispatch.Signal()
"""
This signal is sent out to get all known sales channels types. Receivers should return an
instance of a subclass of ``pretix.base.channels.SalesChannelType`` or a list of such
instance of a subclass of ``pretix.base.channels.SalesChannel`` or a list of such
instances.
"""
register_sales_channels = DeprecatedSignal() # TODO: remove me
register_data_exporters = EventPluginSignal()
"""
This signal is sent out to get all known data exporters. Receivers should return a
@@ -1,19 +0,0 @@
{% load rich_text %}
{% load static %}
{% load i18n %}
{% if widget.wrap_label %}<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>{% endif %}
{% include "django/forms/widgets/input.html" %}
{% if widget.wrap_label %}
{% if "." in widget.value.instance.type_instance.icon %}
<img class="fa-like-image" src="{% static widget.value.instance.type_instance.icon %}" alt="">
{% else %}
<span class="fa fa-fw fa-{{ widget.value.instance.type_instance.icon }} text-muted"></span>
{% endif %}
{% if widget.plugin_missing %}
<del>
{% endif %}
{{ widget.label }}{% if widget.plugin_missing %}</del>
<span class="fa fa-info-circle" data-toggle="tooltip" title="{% trans "This sales channel cannot be used properly since the respective plugin is not active for this event." %}"></span>
{% endif %}
</label>
{% endif %}
+1 -2
View File
@@ -81,6 +81,5 @@ def has_time_machine_permission(request, event):
) or (
getattr(request, 'event_access_user', None) and
request.event_access_user.is_authenticated and
request.event_access_user.has_event_permission(request.organizer, request.event, permission,
session_key=request.event_access_parent_session_key)
request.event_access_user.has_event_permission(request.organizer, request.event, permission, request=request)
)
-17
View File
@@ -438,20 +438,3 @@ class ButtonGroupRadioSelect(forms.RadioSelect):
attrs['icon'] = self.option_icons[value]
opt = super().create_option(name, value, label, selected, index, subindex, attrs)
return opt
class SalesChannelCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
option_template_name = 'pretixbase/forms/widgets/checkbox_sales_channel_option.html'
def __init__(self, event, attrs=None, choices=()):
self.event = event
super().__init__(attrs, choices)
def create_option(
self, name, value, label, selected, index, subindex=None, attrs=None
):
plugin = value.instance.type_instance.required_event_plugin
return {
**super().create_option(name, value, label, selected, index, subindex, attrs),
"plugin_missing": plugin and plugin not in self.event.get_plugins(),
}
+11 -7
View File
@@ -30,12 +30,11 @@ from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
)
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import Gate
from pretix.base.models.checkin import Checkin, CheckinList
from pretix.control.forms import (
ItemMultipleChoiceField, SalesChannelCheckboxSelectMultiple,
)
from pretix.control.forms import ItemMultipleChoiceField
from pretix.control.forms.widgets import Select2
@@ -67,9 +66,14 @@ class CheckinListForm(forms.ModelForm):
kwargs.pop('locales', None)
super().__init__(**kwargs)
self.fields['limit_products'].queryset = self.event.items.all()
self.fields['auto_checkin_sales_channels'].queryset = self.event.organizer.sales_channels.all()
self.fields['auto_checkin_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(
self.event, choices=self.fields['auto_checkin_sales_channels'].widget.choices
self.fields['auto_checkin_sales_channels'] = forms.MultipleChoiceField(
label=self.fields['auto_checkin_sales_channels'].label,
help_text=self.fields['auto_checkin_sales_channels'].help_text,
required=self.fields['auto_checkin_sales_channels'].required,
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
),
widget=forms.CheckboxSelectMultiple
)
if not self.event.organizer.gates.exists():
@@ -119,13 +123,13 @@ class CheckinListForm(forms.ModelForm):
'gates': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
'auto_checkin_sales_channels': forms.CheckboxSelectMultiple(),
'exit_all_at': NextTimeInput(attrs={'class': 'timepickerfield'}),
}
field_classes = {
'limit_products': ItemMultipleChoiceField,
'gates': SafeModelMultipleChoiceField,
'subevent': SafeModelChoiceField,
'auto_checkin_sales_channels': SafeModelMultipleChoiceField,
'exit_all_at': NextTimeField,
}
+12 -14
View File
@@ -22,16 +22,13 @@
from decimal import Decimal
from django import forms
from django_scopes.forms import SafeModelMultipleChoiceField
from django.utils.translation import gettext_lazy as _
from pretix.base.channels import get_all_sales_channel_types
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import I18nModelForm
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import Discount
from pretix.control.forms import (
ItemMultipleChoiceField, SalesChannelCheckboxSelectMultiple,
SplitDateTimeField,
)
from pretix.control.forms import ItemMultipleChoiceField, SplitDateTimeField
class DiscountForm(I18nModelForm):
@@ -41,8 +38,7 @@ class DiscountForm(I18nModelForm):
fields = [
'active',
'internal_name',
'all_sales_channels',
'limit_sales_channels',
'sales_channels',
'available_from',
'available_until',
'subevent_mode',
@@ -64,7 +60,6 @@ class DiscountForm(I18nModelForm):
'available_until': SplitDateTimeField,
'condition_limit_products': ItemMultipleChoiceField,
'benefit_limit_products': ItemMultipleChoiceField,
'limit_sales_channels': SafeModelMultipleChoiceField,
}
widgets = {
'subevent_mode': forms.RadioSelect,
@@ -88,12 +83,15 @@ class DiscountForm(I18nModelForm):
self.event = kwargs['event']
super().__init__(*args, **kwargs)
self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.filter(
type__in=[k for k, v in get_all_sales_channel_types().items() if v.discounts_supported]
self.fields['sales_channels'] = forms.MultipleChoiceField(
label=_('Sales channels'),
required=True,
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
if c.discounts_supported
),
widget=forms.CheckboxSelectMultiple,
)
self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={
'data-inverse-dependency': '<[name$=all_sales_channels]',
}, choices=self.fields['limit_sales_channels'].widget.choices)
self.fields['condition_limit_products'].queryset = self.event.items.all()
self.fields['benefit_limit_products'].queryset = self.event.items.all()
self.fields['condition_min_count'].required = False
+21 -21
View File
@@ -44,7 +44,9 @@ from django.conf import settings
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
from django.core.validators import MaxValueValidator
from django.db.models import Prefetch, Q, prefetch_related_objects
from django.forms import formset_factory, inlineformset_factory
from django.forms import (
CheckboxSelectMultiple, formset_factory, inlineformset_factory,
)
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape, format_html
@@ -52,12 +54,12 @@ from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone_name
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
from django_countries.fields import LazyTypedChoiceField
from django_scopes.forms import SafeModelMultipleChoiceField
from i18nfield.forms import (
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput,
)
from pytz import common_timezones
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import (
I18nMarkdownTextarea, I18nModelForm, PlaceholderValidator, SettingsForm,
)
@@ -71,8 +73,8 @@ from pretix.base.settings import (
)
from pretix.base.validators import multimail_validate
from pretix.control.forms import (
MultipleLanguagesWidget, SalesChannelCheckboxSelectMultiple, SlugWidget,
SplitDateTimeField, SplitDateTimePickerWidget,
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
SplitDateTimePickerWidget,
)
from pretix.control.forms.widgets import Select2
from pretix.helpers.countries import CachedCountries
@@ -376,10 +378,16 @@ class EventUpdateForm(I18nModelForm):
required=False,
help_text=_('You need to configure the custom domain in the webserver beforehand.')
)
self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all()
self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={
'data-inverse-dependency': '<[name$=all_sales_channels]',
}, choices=self.fields['limit_sales_channels'].widget.choices)
self.fields['sales_channels'] = forms.MultipleChoiceField(
label=self.fields['sales_channels'].label,
help_text=self.fields['sales_channels'].help_text,
required=self.fields['sales_channels'].required,
initial=self.fields['sales_channels'].initial,
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
),
widget=forms.CheckboxSelectMultiple
)
def clean_domain(self):
d = self.cleaned_data['domain']
@@ -436,8 +444,7 @@ class EventUpdateForm(I18nModelForm):
'location',
'geo_lat',
'geo_lon',
'all_sales_channels',
'limit_sales_channels',
'sales_channels'
]
field_classes = {
'date_from': SplitDateTimeField,
@@ -445,7 +452,6 @@ class EventUpdateForm(I18nModelForm):
'date_admission': SplitDateTimeField,
'presale_start': SplitDateTimeField,
'presale_end': SplitDateTimeField,
'limit_sales_channels': SafeModelMultipleChoiceField,
}
widgets = {
'date_from': SplitDateTimePickerWidget(),
@@ -453,6 +459,7 @@ class EventUpdateForm(I18nModelForm):
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
'presale_start': SplitDateTimePickerWidget(),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
'sales_channels': CheckboxSelectMultiple(),
}
@@ -573,7 +580,6 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'banner_text',
'banner_text_bottom',
'order_email_asked_twice',
'allow_modifications',
'last_order_modification_date',
'allow_modifications_after_checkin',
'checkout_show_copy_answers_button',
@@ -908,7 +914,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
locale_names = dict(settings.LANGUAGES)
self.fields['invoice_language'].choices = [('__user__', _('The user\'s language'))] + [(a, locale_names[a]) for a in event.settings.locales]
self.fields['invoice_generate_sales_channels'].choices = (
(c.identifier, c.label) for c in event.organizer.sales_channels.all()
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
)
self.fields['invoice_numbers_counter_length'].validators.append(MaxValueValidator(15))
@@ -954,7 +960,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
]
mail_sales_channel_placed_paid = forms.MultipleChoiceField(
choices=[],
choices=lambda: [(ident, sc.verbose_name) for ident, sc in get_all_sales_channels().items()],
label=_('Sales channels for checkout emails'),
help_text=_('The order placed and paid emails will only be send to orders from these sales channels. '
'The online shop must be enabled.'),
@@ -965,7 +971,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
)
mail_sales_channel_download_reminder = forms.MultipleChoiceField(
choices=[],
choices=lambda: [(ident, sc.verbose_name) for ident, sc in get_all_sales_channels().items()],
label=_('Sales channels'),
help_text=_('This email will only be send to orders from these sales channels. The online shop must be enabled.'),
widget=forms.CheckboxSelectMultiple(
@@ -1360,12 +1366,6 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
self.fields['mail_html_renderer'].choices = [
(r.identifier, r.verbose_name) for r in event.get_html_mail_renderers().values()
]
self.fields['mail_sales_channel_placed_paid'].choices = (
(c.identifier, c.label) for c in event.organizer.sales_channels.all()
)
self.fields['mail_sales_channel_download_reminder'].choices = (
(c.identifier, c.label) for c in event.organizer.sales_channels.all()
)
prefetch_related_objects([self.event.organizer], Prefetch('meta_properties'))
self.event.meta_values_cached = self.event.meta_values.select_related('property').all()
+8 -7
View File
@@ -50,14 +50,15 @@ from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.widgets import (
DatePickerWidget, SplitDateTimePickerWidget, TimePickerWidget,
)
from pretix.base.models import (
Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue,
Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
OrderRefund, Organizer, Question, QuestionAnswer, Quota, SalesChannel,
SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher,
OrderRefund, Organizer, Question, QuestionAnswer, Quota, SubEvent,
SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher,
)
from pretix.base.signals import register_payment_providers
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
@@ -578,11 +579,9 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
required=False,
label=_('Maximal sum of payments and refunds'),
)
sales_channel = SafeModelChoiceField(
sales_channel = forms.ChoiceField(
label=_('Sales channel'),
required=False,
queryset=SalesChannel.objects.none(),
to_field_name="identifier",
)
has_checkin = forms.NullBooleanField(
required=False,
@@ -605,7 +604,9 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
del self.fields['subevents_from']
del self.fields['subevents_to']
self.fields['sales_channel'].queryset = self.event.organizer.sales_channels.all()
self.fields['sales_channel'].choices = [('', '')] + [
(k, v.verbose_name) for k, v in get_all_sales_channels().items()
]
locale_names = dict(settings.LANGUAGES)
self.fields['locale'].choices = [('', '')] + [(a, locale_names[a]) for a in self.event.settings.locales]
@@ -718,7 +719,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
if fdata.get('comment'):
qs = qs.filter(comment__icontains=fdata.get('comment'))
if fdata.get('sales_channel'):
qs = qs.filter(sales_channel__identifier=fdata.get('sales_channel').identifier)
qs = qs.filter(sales_channel=fdata.get('sales_channel'))
if fdata.get('total'):
qs = qs.filter(total=fdata.get('total'))
if fdata.get('email_known_to_work') is not None:
+28 -35
View File
@@ -55,6 +55,7 @@ from django_scopes.forms import (
)
from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import (
@@ -63,8 +64,7 @@ from pretix.base.models import (
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data
from pretix.control.forms import (
ButtonGroupRadioSelect, ItemMultipleChoiceField,
SalesChannelCheckboxSelectMultiple, SizeValidationMixin,
ButtonGroupRadioSelect, ItemMultipleChoiceField, SizeValidationMixin,
SplitDateTimeField, SplitDateTimePickerWidget,
)
from pretix.control.forms.widgets import Select2, Select2ItemVarMulti
@@ -413,7 +413,7 @@ class ItemCreateForm(I18nModelForm):
'checkin_text',
'free_price',
'original_price',
'all_sales_channels',
'sales_channels',
'issue_giftcard',
'require_approval',
'allow_waitinglist',
@@ -443,6 +443,9 @@ class ItemCreateForm(I18nModelForm):
if src.picture:
self.instance.picture.save(os.path.basename(src.picture.name), src.picture)
else:
# Add to all sales channels by default
self.instance.sales_channels = list(get_all_sales_channels().keys())
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
if not self.instance.admission:
@@ -471,8 +474,6 @@ class ItemCreateForm(I18nModelForm):
})
if self.cleaned_data.get('copy_from'):
if not self.instance.all_sales_channels:
self.instance.limit_sales_channels.set(self.cleaned_data['copy_from'].limit_sales_channels.all())
self.instance.require_membership_types.set(
self.cleaned_data['copy_from'].require_membership_types.all()
)
@@ -573,10 +574,14 @@ class ItemUpdateForm(I18nModelForm):
if self.event.tax_rules.exists():
self.fields['tax_rule'].required = True
self.fields['description'].widget.attrs['rows'] = '4'
self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all()
self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={
'data-inverse-dependency': '<[name$=all_sales_channels]',
}, choices=self.fields['limit_sales_channels'].widget.choices)
self.fields['sales_channels'] = forms.MultipleChoiceField(
label=_('Sales channels'),
required=False,
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
),
widget=forms.CheckboxSelectMultiple
)
change_decimal_field(self.fields['default_price'], self.event.currency)
self.fields['available_from_mode'].widget = ButtonGroupRadioSelect(
@@ -693,14 +698,6 @@ class ItemUpdateForm(I18nModelForm):
'tax_rule',
_("Gift card products should use a tax rule with a rate of 0 percent since sales tax will be applied when the gift card is redeemed.")
)
if d.get('validity_mode'):
self.add_error(
'validity_mode',
_(
"Do not set a specific validity for gift card products as it will not restrict the validity "
"of the gift card. A validity of gift cards can be set in your organizer settings."
)
)
if d.get('admission'):
self.add_error(
'admission',
@@ -739,14 +736,6 @@ class ItemUpdateForm(I18nModelForm):
_("The start of validity must be before the end of validity.")
)
if d.get('validity_mode') == Item.VALIDITY_MODE_DYNAMIC:
if not any(d.get(f'validity_dynamic_duration_{k}') for k in ('months', 'days', 'hours', 'minutes')):
self.add_error(
'validity_dynamic_duration_months',
_("You have selected dynamic validity but have not entered a time period. This would render "
"the tickets unusable.")
)
Item.clean_media_settings(self.event, d.get('media_policy'), d.get('media_type'), d.get('issue_giftcard'))
return d
@@ -767,8 +756,7 @@ class ItemUpdateForm(I18nModelForm):
'name',
'internal_name',
'active',
'all_sales_channels',
'limit_sales_channels',
'sales_channels',
'admission',
'personalized',
'description',
@@ -825,7 +813,6 @@ class ItemUpdateForm(I18nModelForm):
'hidden_if_item_available': SafeModelChoiceField,
'grant_membership_type': SafeModelChoiceField,
'require_membership_types': SafeModelMultipleChoiceField,
'limit_sales_channels': SafeModelMultipleChoiceField,
}
widgets = {
'available_from': SplitDateTimePickerWidget(),
@@ -897,10 +884,18 @@ class ItemVariationForm(I18nModelForm):
qs = kwargs.pop('membership_types')
super().__init__(*args, **kwargs)
change_decimal_field(self.fields['default_price'], self.event.currency)
self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all()
self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={
'data-inverse-dependency': '<[name$=all_sales_channels]',
}, choices=self.fields['limit_sales_channels'].widget.choices)
self.fields['sales_channels'] = forms.MultipleChoiceField(
label=_('Sales channels'),
required=False,
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
),
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
'selected here but not on product level, the variation will not be available.'),
widget=forms.CheckboxSelectMultiple
)
if not self.instance.pk:
self.initial.setdefault('sales_channels', list(get_all_sales_channels().keys()))
self.fields['description'].widget.attrs['rows'] = 3
if qs:
@@ -972,14 +967,12 @@ class ItemVariationForm(I18nModelForm):
'available_from_mode',
'available_until',
'available_until_mode',
'all_sales_channels',
'limit_sales_channels',
'sales_channels',
'hide_without_voucher',
]
field_classes = {
'available_from': SplitDateTimeField,
'available_until': SplitDateTimeField,
'limit_sales_channels': SafeModelMultipleChoiceField,
}
widgets = {
'available_from': SplitDateTimePickerWidget(),
+1 -40
View File
@@ -50,7 +50,6 @@ from django_scopes.forms import SafeModelChoiceField
from i18nfield.forms import (
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput,
)
from i18nfield.strings import LazyI18nString
from phonenumber_field.formfields import PhoneNumberField
from pytz import common_timezones
@@ -69,8 +68,7 @@ from pretix.base.forms.widgets import (
)
from pretix.base.models import (
Customer, Device, EventMetaProperty, Gate, GiftCard, GiftCardAcceptance,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
SalesChannel, Team,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium, Team,
)
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.organizer import OrganizerFooterLink
@@ -1092,40 +1090,3 @@ class GiftCardAcceptanceInviteForm(forms.Form):
if self.organizer.gift_card_acceptor_acceptance.filter(acceptor=acceptor).exists():
raise ValidationError(_('The selected organizer has already been invited.'))
return acceptor
class SalesChannelForm(I18nModelForm):
class Meta:
model = SalesChannel
fields = ['label', 'identifier']
widgets = {
'default': forms.TextInput(),
}
def __init__(self, *args, **kwargs):
self.type = kwargs.pop("type")
super().__init__(*args, **kwargs)
if not self.type.multiple_allowed or (self.instance and self.instance.pk):
self.fields["identifier"].initial = self.type.identifier
self.fields["identifier"].disabled = True
self.fields["label"].initial = LazyI18nString.from_gettext(self.type.verbose_name)
def clean(self):
d = super().clean()
if self.instance.pk:
d["identifier"] = self.instance.identifier
elif self.type.multiple_allowed:
d["identifier"] = self.type.identifier + "." + d["identifier"]
else:
d["identifier"] = self.type.identifier
if not self.instance.pk:
# self.event is actually the organizer, sorry I18nModelForm!
if self.event.sales_channels.filter(identifier=d["identifier"]).exists():
raise ValidationError(
_("A sales channel with the same identifier already exists.")
)
return d
+8 -8
View File
@@ -90,10 +90,8 @@ class VoucherForm(I18nModelForm):
def __init__(self, *args, **kwargs):
instance = kwargs.get('instance')
initial = kwargs.get('initial')
self.initial_instance_data = None
if instance:
if instance.pk:
self.initial_instance_data = modelcopy(instance)
self.initial_instance_data = modelcopy(instance)
try:
if instance.variation:
initial['itemvar'] = '%d-%d' % (instance.item.pk, instance.variation.pk)
@@ -103,6 +101,8 @@ class VoucherForm(I18nModelForm):
initial['itemvar'] = 'q-%d' % instance.quota.pk
except Item.DoesNotExist:
pass
else:
self.initial_instance_data = None
super().__init__(*args, **kwargs)
if instance.event.has_subevents:
@@ -234,8 +234,8 @@ class VoucherForm(I18nModelForm):
)
if check_quota:
Voucher.clean_quota_check(
data, cnt, self.initial_instance_data,
self.instance.event, self.instance.quota, self.instance.item, self.instance.variation
data, cnt, self.initial_instance_data, self.instance.event,
self.instance.quota, self.instance.item, self.instance.variation
)
Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk)
if 'seat' in self.fields and data.get('seat'):
@@ -366,14 +366,14 @@ class VoucherBulkForm(VoucherForm):
raise ValidationError(_('CSV input contains an unknown field with the header "{header}".').format(header=unknown_fields[0]))
for i, row in enumerate(reader):
try:
EmailValidator()(row['email'].strip())
EmailValidator()(row['email'])
except ValidationError as err:
raise ValidationError(_('{value} is not a valid email address.').format(value=row['email'].strip())) from err
raise ValidationError(_('{value} is not a valid email address.').format(value=row['email'])) from err
try:
res.append(self.Recipient(
name=row.get('name', ''),
email=row['email'].strip(),
number=int(row.get('number', 1) or ""),
number=int(row.get('number', 1)),
tag=row.get('tag', None)
))
except ValueError as err:
-3
View File
@@ -357,9 +357,6 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.membershiptype.created': _('The membership type has been created.'),
'pretix.membershiptype.changed': _('The membership type has been changed.'),
'pretix.membershiptype.deleted': _('The membership type has been deleted.'),
'pretix.saleschannel.created': _('The sales channel has been created.'),
'pretix.saleschannel.changed': _('The sales channel has been changed.'),
'pretix.saleschannel.deleted': _('The sales channel has been deleted.'),
'pretix.customer.created': _('The account has been created.'),
'pretix.customer.changed': _('The account has been changed.'),
'pretix.customer.membership.created': _('A membership for this account has been added.'),
-12
View File
@@ -444,11 +444,6 @@ def get_global_navigation(request):
'url': reverse('control:global.license'),
'active': (url.url_name == 'global.license'),
},
{
'label': _('System report'),
'url': reverse('control:global.sysreport'),
'active': (url.url_name == 'global.sysreport'),
},
]
})
@@ -502,13 +497,6 @@ def get_organizer_navigation(request):
}),
'active': url.url_name == 'organizer.settings.mail',
},
{
'label': _('Sales channels'),
'url': reverse('control:organizer.channels', kwargs={
'organizer': request.organizer.slug
}),
'active': url.url_name.startswith('organizer.channel'),
},
{
'label': _('Webhooks'),
'url': reverse('control:organizer.webhooks', kwargs={
-307
View File
@@ -1,307 +0,0 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
import os
import platform
import sys
import zoneinfo
from datetime import datetime, timedelta
from django.conf import settings
from django.db.models import Count, Exists, F, Min, OuterRef, Q, Sum
from django.utils.formats import date_format
from django.utils.timezone import now
from reportlab.lib import pagesizes
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, Spacer, Table, TableStyle
from pretix import __version__
from pretix.base.models import Order, OrderPayment, Transaction
from pretix.base.plugins import get_all_plugins
from pretix.base.templatetags.money import money_filter
from pretix.plugins.reports.exporters import ReportlabExportMixin
from pretix.settings import DATA_DIR
class SysReport(ReportlabExportMixin):
@property
def pagesize(self):
return pagesizes.portrait(pagesizes.A4)
def __init__(self, start_month, tzname):
self.tzname = tzname
self.tz = zoneinfo.ZoneInfo(tzname)
self.start_month = start_month
def page_header(self, canvas, doc):
pass
def page_footer(self, canvas, doc):
from reportlab.lib.units import mm
canvas.setFont("OpenSans", 8)
canvas.drawString(15 * mm, 10 * mm, "Page %d" % doc.page)
canvas.drawRightString(
self.pagesize[0] - doc.rightMargin,
10 * mm,
"Created: %s"
% date_format(now().astimezone(self.tz), "SHORT_DATETIME_FORMAT"),
)
def render(self):
return "sysreport.pdf", "application/pdf", self.create({})
def get_story(self, doc, form_data):
headlinestyle = self.get_style()
headlinestyle.fontSize = 15
subheadlinestyle = self.get_style()
subheadlinestyle.fontSize = 13
style_small = self.get_style()
style_small.fontSize = 6
story = [
Paragraph("System report", headlinestyle),
Spacer(1, 5 * mm),
Paragraph("Usage", subheadlinestyle),
Spacer(1, 5 * mm),
self._usage_table(),
Spacer(1, 5 * mm),
Paragraph("Installed versions", subheadlinestyle),
Spacer(1, 5 * mm),
self._tech_table(),
Spacer(1, 5 * mm),
Paragraph("Plugins", subheadlinestyle),
Spacer(1, 5 * mm),
Paragraph(self._get_plugin_versions(), style_small),
Spacer(1, 5 * mm),
Paragraph("Custom templates", subheadlinestyle),
Spacer(1, 5 * mm),
Paragraph(self._get_custom_templates(), style_small),
Spacer(1, 5 * mm),
]
return story
def _tech_table(self):
style = self.get_style()
style.fontSize = 8
style_small = self.get_style()
style_small.fontSize = 6
w = self.pagesize[0] - 30 * mm
colwidths = [
a * w
for a in (
0.2,
0.8,
)
]
tstyledata = [
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (0, -1), 0),
("RIGHTPADDING", (-1, 0), (-1, -1), 0),
]
tdata = [
[Paragraph("Site URL:", style), Paragraph(settings.SITE_URL, style)],
[Paragraph("pretix version:", style), Paragraph(__version__, style)],
[Paragraph("Python version:", style), Paragraph(sys.version, style)],
[Paragraph("Platform:", style), Paragraph(platform.platform(), style)],
[
Paragraph("Database engine:", style),
Paragraph(settings.DATABASES["default"]["ENGINE"], style),
],
]
table = Table(tdata, colWidths=colwidths, repeatRows=0)
table.setStyle(TableStyle(tstyledata))
return table
def _usage_table(self):
style = self.get_style()
style.fontSize = 8
style_small = self.get_style()
style_small.fontSize = 6
style_small.leading = 8
style_small.alignment = TA_CENTER
style_small_head = self.get_style()
style_small_head.fontSize = 6
style_small_head.leading = 8
style_small_head.alignment = TA_CENTER
style_small_head.fontName = "OpenSansBd"
w = self.pagesize[0] - 30 * mm
successful = (
Q(status=Order.STATUS_PAID)
| Q(valid_if_pending=True, status=Order.STATUS_PENDING)
| Q(
Exists(
OrderPayment.objects.filter(
order_id=OuterRef("pk"),
state__in=(
OrderPayment.PAYMENT_STATE_CONFIRMED,
OrderPayment.PAYMENT_STATE_REFUNDED,
),
)
),
)
)
orders_q = Order.objects.filter(
successful,
testmode=False,
)
orders_testmode_q = Order.objects.filter(
testmode=True,
)
orders_unconfirmed_q = Order.objects.filter(
~successful,
testmode=False,
)
revenue_q = Transaction.objects.filter(
Exists(
OrderPayment.objects.filter(
order_id=OuterRef("order_id"),
state__in=(
OrderPayment.PAYMENT_STATE_CONFIRMED,
OrderPayment.PAYMENT_STATE_REFUNDED,
),
)
),
order__testmode=False,
)
currencies = sorted(
list(
set(
Transaction.objects.annotate(c=F("order__event__currency"))
.values_list("c", flat=True)
.distinct()
)
)
)
year_first = orders_q.aggregate(m=Min("datetime__year"))["m"]
if not year_first:
year_first = now().year
elif datetime.now().month - 1 <= self.start_month:
year_first -= 1
year_last = now().year
tdata = [
[
Paragraph(l, style_small_head)
for l in (
"Time frame",
"Currency",
"Successful orders",
"Net revenue",
"Testmode orders",
"Unsucessful orders",
"Positions",
"Gross revenue",
)
]
]
for year in range(year_first, year_last + 1):
for i, c in enumerate(currencies):
first_day = datetime(
year, self.start_month, 1, 0, 0, 0, 0, tzinfo=self.tz
)
after_day = datetime(
year + 1, self.start_month, 1, 0, 0, 0, 0, tzinfo=self.tz
)
orders_count = (
orders_q.filter(
datetime__gte=first_day, datetime__lt=after_day
).aggregate(c=Count("*"))["c"]
or 0
)
testmode_count = (
orders_testmode_q.filter(
datetime__gte=first_day, datetime__lt=after_day
).aggregate(c=Count("*"))["c"]
or 0
)
unconfirmed_count = (
orders_unconfirmed_q.filter(
datetime__gte=first_day, datetime__lt=after_day
).aggregate(c=Count("*"))["c"]
or 0
)
revenue_data = revenue_q.filter(
datetime__gte=first_day, datetime__lt=after_day, order__event__currency=c
).aggregate(
c=Sum("count"),
s_net=Sum(F("price") - F("tax_value")),
s_gross=Sum(F("price")),
)
tdata.append(
(
Paragraph(
date_format(first_day, "M Y")
+ " "
+ date_format(after_day - timedelta(days=1), "M Y"),
style_small,
),
Paragraph(c, style_small),
Paragraph(str(orders_count), style_small) if i == 0 else "",
Paragraph(money_filter(revenue_data.get("s_net") or 0, c), style_small),
Paragraph(str(testmode_count), style_small) if i == 0 else "",
Paragraph(str(unconfirmed_count), style_small) if i == 0 else "",
Paragraph(str(revenue_data.get("c") or 0), style_small),
Paragraph(money_filter(revenue_data.get("s_gross") or 0, c), style_small),
)
)
colwidths = [a * w for a in (0.18,) + (0.82 / 7,) * 7]
tstyledata = [
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (0, -1), 0),
("RIGHTPADDING", (-1, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 1),
]
table = Table(tdata, colWidths=colwidths, repeatRows=0)
table.setStyle(TableStyle(tstyledata))
return table
def _get_plugin_versions(self):
lines = []
for p in get_all_plugins():
lines.append(f"{p.name} {p.version}")
return ", ".join(lines)
def _get_custom_templates(self):
lines = []
for dirpath, dirnames, filenames in os.walk(
os.path.join(DATA_DIR, "templates")
):
for f in filenames:
lines.append(f"{dirpath}/{f}")
d = "<br/>".join(lines[:50])
if len(lines) > 50:
d += "<br/>..."
if not d:
return ""
return d
@@ -327,7 +327,8 @@
{% endblocktrans %}
{% blocktrans trimmed %}
Internet Explorer is an old browser that does not support lots of recent web-based
technologies and is no longer supported by this website.
technologies. While some features might already not work properly, we plan on no longer
supporting Internet Explorer in our administrative backend in the next months.
{% endblocktrans %}
{% blocktrans trimmed %}
We kindly ask you to move to one of our supported browsers, such as Microsoft Edge,
@@ -1,7 +1,6 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load static %}
{% load urlreplace %}
{% block title %}{% trans "Check-in lists" %}{% endblock %}
{% block inside %}
@@ -138,14 +137,9 @@
{% endif %}
{% endif %}
<td>
{% for channel in cl.auto_checkin_sales_channels.all %}
{% if "." in channel.icon %}
<img src="{% static channel.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ channel.label }}">
{% else %}
<span class="fa fa-{{ channel.icon }} text-muted"
data-toggle="tooltip" title="{{ channel.label }}"></span>
{% endif %}
{% for channel in cl.auto_checkin_sales_channels %}
<span class="fa fa-{{ channel.icon }} text-muted"
data-toggle="tooltip" title="{% trans channel.verbose_name %}"></span>
{% endfor %}
</td>
<td>
@@ -1,6 +1,5 @@
{% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %}
{% load static %}
{% load bootstrap3 %}
{% block inside %}
<h1>{% trans "Payment settings" %}</h1>
@@ -31,13 +30,8 @@
</td>
<td class="iconcol">
{% for channel in provider.sales_channels %}
{% if "." in channel.icon %}
<img src="{% static channel.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ channel.label }}">
{% else %}
<span class="fa fa-{{ channel.icon }} text-muted"
data-toggle="tooltip" title="{{ channel.label }}"></span>
{% endif %}
<span class="fa fa-{{ channel.icon }} text-muted"
data-toggle="tooltip" title="{% trans channel.verbose_name %}"></span>
{% endfor %}
</td>
<td class="text-right flip">
@@ -32,8 +32,7 @@
{% bootstrap_field sform.contact_mail layout="control" %}
{% bootstrap_field sform.imprint_url layout="control" %}
{% bootstrap_field form.is_public layout="control" %}
{% bootstrap_field form.all_sales_channels layout="control" %}
{% bootstrap_field form.limit_sales_channels layout="control" %}
{% bootstrap_field form.sales_channels layout="control" %}
{% if meta_forms %}
<div class="form-group metadata-group">
@@ -114,17 +113,10 @@
</div>
{% bootstrap_field sform.attendee_data_explanation_text layout="control" %}
<h4>{% trans "Form settings" %}</h4>
<h4>{% trans "Other settings" %}</h4>
{% bootstrap_field sform.name_scheme layout="control" %}
{% bootstrap_field sform.name_scheme_titles layout="control" %}
{% bootstrap_field sform.checkout_show_copy_answers_button layout="control" %}
<h4>{% trans "Changes to existing orders" %}</h4>
{% bootstrap_field sform.allow_modifications layout="control" %}
<div data-display-dependency='#id_settings-allow_modifications_0' data-inverse>
{% bootstrap_field sform.last_order_modification_date layout="control" %}
{% bootstrap_field sform.allow_modifications_after_checkin layout="control" %}
</div>
</fieldset>
<fieldset>
<legend>{% trans "Texts" %}</legend>
@@ -233,47 +225,21 @@
{% bootstrap_field sform.presale_start_show_date layout="control" %}
{% bootstrap_field form.presale_end layout="control" %}
{% bootstrap_field sform.show_items_outside_presale_period layout="control" %}
{% bootstrap_field sform.last_order_modification_date layout="control" %}
{% bootstrap_field sform.allow_modifications_after_checkin layout="control" %}
{% bootstrap_field sform.show_checkin_number_user layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Display" %}</legend>
<h4>{% trans "Date and time" %}</h4>
{% bootstrap_field sform.show_dates_on_frontpage layout="control" %}
{% bootstrap_field sform.show_date_to layout="control" %}
{% bootstrap_field sform.show_times layout="control" %}
<h4>{% trans "Product list" %}</h4>
{% bootstrap_field sform.show_quota_left layout="control" %}
{% bootstrap_field sform.display_net_prices layout="control" %}
{% bootstrap_field sform.hide_prices_from_attendees layout="control" %}
{% bootstrap_field sform.show_variations_expanded layout="control" %}
{% bootstrap_field sform.hide_sold_out layout="control" %}
<h4>{% trans "Calendar and list views" context "subevents" %}</h4>
{% if sform.frontpage_subevent_ordering %}
{% bootstrap_field sform.frontpage_subevent_ordering layout="control" %}
{% endif %}
{% if sform.event_list_type %}
{% bootstrap_field sform.event_list_type layout="control" %}
{% endif %}
{% if sform.event_list_available_only %}
{% bootstrap_field sform.event_list_available_only layout="control" %}
{% endif %}
{% if sform.event_list_filters %}
{% bootstrap_field sform.event_list_filters layout="control" %}
{% endif %}
{% if sform.event_calendar_future_only %}
{% bootstrap_field sform.event_calendar_future_only layout="control" %}
{% endif %}
{% bootstrap_field sform.low_availability_percentage layout="control" addon_after="%" %}
<h4>{% trans "Order details" %}</h4>
{% bootstrap_field sform.hide_prices_from_attendees layout="control" %}
{% bootstrap_field sform.show_checkin_number_user layout="control" %}
<h4>{% trans "Other settings" %}</h4>
{% url "control:organizer.edit" organizer=request.organizer.slug as org_url %}
{% propagated request.event org_url "meta_noindex" %}
{% bootstrap_field sform.meta_noindex layout="control" %}
{% endpropagated %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Footer links" %}<br>
@@ -340,6 +306,28 @@
</div>
</div>
</div>
{% if sform.frontpage_subevent_ordering %}
{% bootstrap_field sform.frontpage_subevent_ordering layout="control" %}
{% endif %}
{% if sform.event_list_type %}
{% bootstrap_field sform.event_list_type layout="control" %}
{% endif %}
{% if sform.event_list_available_only %}
{% bootstrap_field sform.event_list_available_only layout="control" %}
{% endif %}
{% if sform.event_list_filters %}
{% bootstrap_field sform.event_list_filters layout="control" %}
{% endif %}
{% if sform.event_calendar_future_only %}
{% bootstrap_field sform.event_calendar_future_only layout="control" %}
{% endif %}
{% bootstrap_field sform.low_availability_percentage layout="control" addon_after="%" %}
{% url "control:organizer.edit" organizer=request.organizer.slug as org_url %}
{% propagated request.event org_url "meta_noindex" %}
{% bootstrap_field sform.meta_noindex layout="control" %}
{% endpropagated %}
</fieldset>
<fieldset>
<legend>{% trans "Cart" %}</legend>
@@ -1,35 +0,0 @@
{% extends "pretixcontrol/global_settings_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<p>
{% trans "If you have a pretix Enterprise license, this report must be submitted to pretix support when your license renews. It may also be requested by pretix support to aid debugging of problems." %}
{% trans "It serves two purposes: Collecting useful information that might help with debugging problems in your pretix installation, and verifying that your usage of pretix is in compliance with the Enterprise license you purchased." %}
</p>
<form method="post">
{% csrf_token %}
<p>
<label>
{% trans "First month of license term:" %}
<select name="month" class="form-control">
<option value="1">{% trans "January" %}</option>
<option value="2">{% trans "February" %}</option>
<option value="3">{% trans "March" %}</option>
<option value="4">{% trans "April" %}</option>
<option value="5">{% trans "May" %}</option>
<option value="6">{% trans "June" %}</option>
<option value="7">{% trans "July" %}</option>
<option value="8">{% trans "August" %}</option>
<option value="9">{% trans "September" %}</option>
<option value="10">{% trans "October" %}</option>
<option value="11">{% trans "November" %}</option>
<option value="11">{% trans "December" %}</option>
</select>
</label>
</p>
<button type="submit" class="btn btn-primary btn-lg">
{% trans "Generate report" %}
</button>
</form>
{% endblock %}
@@ -1,7 +1,6 @@
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
{% load static %}
{% load getitem %}
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" id="item_variations">
{{ formset.management_form }}
@@ -41,14 +40,9 @@
title="{% trans "Require a valid membership" %}"></span>
</div>
<div class="col-md-2 col-xs-6">
{% for c in sales_channels %}
{% if "." in c.icon %}
<img src="{% static c.icon %}" class="fa-like-image variation-channel-{{ c.id }} variation-icon-hidden"
data-toggle="tooltip" title="{{ c.label }}">
{% else %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ c.id }} variation-icon-hidden"
data-toggle="tooltip" title="{{ c.label }}"></span>
{% endif %}
{% for k, c in sales_channels.items %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ k }} variation-icon-hidden"
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
{% endfor %}
</div>
<div class="col-md-1 col-xs-6 text-right flip variation-price">
@@ -103,8 +97,7 @@
{% endif %}
{% bootstrap_field form.available_from visibility_field=form.available_from_mode layout="control_with_visibility" %}
{% bootstrap_field form.available_until visibility_field=form.available_until_mode layout="control_with_visibility" %}
{% bootstrap_field form.all_sales_channels layout="control" %}
{% bootstrap_field form.limit_sales_channels layout="control" %}
{% bootstrap_field form.sales_channels layout="control" %}
{% bootstrap_field form.hide_without_voucher layout="control" %}
{% bootstrap_field form.require_approval layout="control" %}
{% if form.require_membership %}
@@ -155,14 +148,9 @@
title="{% trans "Require a valid membership" %}"></span>
</div>
<div class="col-md-2 col-xs-6">
{% for c in sales_channels %}
{% if "." in c.icon %}
<img src="{% static c.icon %}" class="fa-like-image variation-channel-{{ c.id }} variation-icon-hidden"
data-toggle="tooltip" title="{{ c.label }}">
{% else %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ c.id }} variation-icon-hidden"
data-toggle="tooltip" title="{{ c.label }}"></span>
{% endif %}
{% for k, c in sales_channels.items %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ k }} variation-icon-hidden"
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
{% endfor %}
</div>
<div class="col-md-1 col-xs-6 text-right flip variation-price">
@@ -209,8 +197,7 @@
{% bootstrap_field formset.empty_form.available_from visibility_field=formset.empty_form.available_from_mode layout="control_with_visibility" %}
{% bootstrap_field formset.empty_form.available_until visibility_field=formset.empty_form.available_until_mode layout="control_with_visibility" %}
{% bootstrap_field formset.empty_form.available_until layout="control" %}
{% bootstrap_field formset.empty_form.all_sales_channels layout="control" %}
{% bootstrap_field formset.empty_form.limit_sales_channels layout="control" %}
{% bootstrap_field formset.empty_form.sales_channels layout="control" %}
{% bootstrap_field formset.empty_form.hide_without_voucher layout="control" %}
{% bootstrap_field formset.empty_form.require_approval layout="control" %}
{% if formset.empty_form.require_membership %}
@@ -152,8 +152,9 @@
</fieldset>
<fieldset>
<legend>{% trans "Availability" %}</legend>
{% bootstrap_field form.all_sales_channels layout="control" horizontal_field_class="col-md-7" %}
{% bootstrap_field form.limit_sales_channels layout="control" horizontal_field_class="col-md-7" %}
{% bootstrap_field form.sales_channels layout="control" horizontal_field_class="col-md-7" %}
{% bootstrap_field form.available_from visibility_field=form.available_from_mode layout="control_with_visibility" %}
{% bootstrap_field form.available_until visibility_field=form.available_until_mode layout="control_with_visibility" %}
{% bootstrap_field form.max_per_order layout="control" horizontal_field_class="col-md-7" %}
@@ -32,6 +32,7 @@
<tr>
<th>{% trans "Product categories" %}</th>
<th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody data-dnd-url="{% url "control:event.items.categories.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
@@ -40,16 +41,18 @@
<td>
<strong><a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}">{{ c.internal_name|default:c.name }}</a></strong>
</td>
<td>
<button formaction="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button formaction="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
<span class="dnd-container"></span>
</td>
<td class="text-right flip">
<button title="{% trans "Move up" %}" formaction="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button title="{% trans "Move down" %}" formaction="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
<span class="dnd-container" title="{% trans "Click and drag this button to reorder. Double click to show buttons for reordering." %}"></span>
<a title="{% trans "Edit" %}" href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.categories.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ c.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
<a title="{% trans "Delete" %}" href="{% url "control:event.items.categories.delete" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
<a href="{% url "control:event.items.categories.delete" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
@@ -15,8 +15,7 @@
{% bootstrap_field form.internal_name layout="control" %}
{% bootstrap_field form.available_from layout="control" %}
{% bootstrap_field form.available_until layout="control" %}
{% bootstrap_field form.all_sales_channels layout="control" %}
{% bootstrap_field form.limit_sales_channels layout="control" %}
{% bootstrap_field form.sales_channels layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Condition" context "discount" %}</legend>
@@ -1,6 +1,5 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Automatic discounts" %}{% endblock %}
{% block inside %}
<h1>{% trans "Automatic discounts" %}</h1>
@@ -57,7 +56,8 @@
<th>{% trans "Internal name" %}</th>
<th></th>
<th></th>
<th colspan="2">{% trans "Products" %}</th>
<th>{% trans "Products" %}</th>
<th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr>
</thead>
@@ -79,15 +79,10 @@
{% endif %}
</td>
<td>
{% for c in sales_channels %}
{% if d.all_sales_channels or c in d.limit_sales_channels.all %}
{% if "." in c.icon %}
<img src="{% static c.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ c.label }}">
{% else %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted"
data-toggle="tooltip" title="{{ c.label }}"></span>
{% endif %}
{% for k, c in sales_channels.items %}
{% if k in d.sales_channels %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted"
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
{% else %}
{% endif %}
{% endfor %}
@@ -107,10 +102,9 @@
{% endif %}
{% endif %}
</td>
<td {% if d.benefit_same_products %}colspan="2"{% endif %}>
{% if not d.benefit_same_products %}{% trans "Condition:" %}{% endif %}
<td>
{% if d.condition_all_products %}
<ul><li><em>{% trans "All" %}</em></li></ul>
<em>{% trans "All" %}</em>
{% else %}
<ul>
{% for item in d.condition_limit_products.all %}
@@ -121,28 +115,18 @@
</ul>
{% endif %}
</td>
{% if not d.benefit_same_products %}
<td>
{% trans "Applies to:" %}
<ul>
{% for item in d.benefit_limit_products.all %}
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
</li>
{% endfor %}
</ul>
</td>
{% endif %}
<td class="text-right flip">
<td>
<button formaction="{% url "control:event.items.discounts.up" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm sortable-up" title="{% trans "Move up" %}"
class="btn btn-default btn-sm sortable-up"
{% if forloop.counter0 == 0 and not page_obj.has_previous %}
disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button formaction="{% url "control:event.items.discounts.down" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm sortable-down" title="{% trans "Move down" %}"
class="btn btn-default btn-sm sortable-down"
{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}>
<i class="fa fa-arrow-down"></i></button>
<span class="dnd-container" title="{% trans "Click and drag this button to reorder. Double click to show buttons for reordering." %}"></span>
<span class="dnd-container"></span>
</td>
<td class="text-right flip">
<a href="{% url "control:event.items.discounts.edit" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.discounts.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ d.id }}"

Some files were not shown because too many files have changed in this diff Show More