Compare commits

..
Author SHA1 Message Date
Raphael Michel 5f724fa780 REmove dead class 2024-11-11 15:39:42 +01:00
118f61292b Update src/pretix/api/signals.py
Co-authored-by: robbi5 <richt@rami.io>
2024-10-31 13:29:58 +01:00
Raphael Michel 458a22f6a3 Make API security profiles pluggable 2024-10-31 13:26:19 +01:00
372 changed files with 120852 additions and 176340 deletions
-8
View File
@@ -60,14 +60,6 @@ http {
deny all; deny all;
return 404; return 404;
} }
location /static/staticfiles.json {
deny all;
return 404;
}
location /static/CACHE/manifest.json {
deny all;
return 404;
}
location /static/ { location /static/ {
alias /pretix/src/pretix/static.dist/; alias /pretix/src/pretix/static.dist/;
access_log off; access_log off;
+14 -17
View File
@@ -54,23 +54,6 @@
</p> </p>
</div> </div>
</div> </div>
<div class="sectionbox">
<div class="icon">
<a href="storefrontapi/index.html">
<span class="fa fa-shopping-cart fa-fw"></span>
</a>
</div>
<div class="text">
<a href="storefrontapi/index.html">
<strong>Storefront API</strong>
</a>
<p>
Documentation and reference of the headless shopping API exposed by pretix for building a custom
storefront.
</p>
</div>
</div>
<div class="clearfix"></div>
<div class="sectionbox"> <div class="sectionbox">
<div class="icon"> <div class="icon">
<a href="development/index.html"> <a href="development/index.html">
@@ -85,6 +68,7 @@
pretix.</p> pretix.</p>
</div> </div>
</div> </div>
<div class="clearfix"></div>
<div class="sectionbox"> <div class="sectionbox">
<div class="icon"> <div class="icon">
<a href="plugins/index.html"> <a href="plugins/index.html">
@@ -98,6 +82,19 @@
<p>Documentation and details on plugins that ship with pretix or are officially supported.</p> <p>Documentation and details on plugins that ship with pretix or are officially supported.</p>
</div> </div>
</div> </div>
<div class="sectionbox">
<div class="icon">
<a href="contents.html">
<span class="fa fa-list fa-fw"></span>
</a>
</div>
<div class="text">
<a href="contents.html">
<strong>Table of contents</strong>
</a>
<p>Detailled overview of everything contained in this documentation.</p>
</div>
</div>
<div class="clearfix"></div> <div class="clearfix"></div>
<h2>Useful links</h2> <h2>Useful links</h2>
-5
View File
@@ -288,7 +288,6 @@ Example::
[django] [django]
secret=j1kjps5a5&4ilpn912s7a1!e2h!duz^i3&idu@_907s$wrz@x- secret=j1kjps5a5&4ilpn912s7a1!e2h!duz^i3&idu@_907s$wrz@x-
debug=off debug=off
passwords_argon2=on
``secret`` ``secret``
The secret to be used by Django for signing and verification purposes. If this The secret to be used by Django for signing and verification purposes. If this
@@ -304,10 +303,6 @@ Example::
.. WARNING:: Never set this to ``True`` in production! .. WARNING:: Never set this to ``True`` in production!
``passwords_argon``
Use the ``argon2`` algorithm for password hashing. Disable on systems with a small number of CPU cores (currently
less than 8).
``profile`` ``profile``
Enable code profiling for a random subset of requests. Disabled by default, see Enable code profiling for a random subset of requests. Disabled by default, see
:ref:`perf-monitoring` for details. :ref:`perf-monitoring` for details.
+3 -2
View File
@@ -231,10 +231,11 @@ The following snippet is an example on how to configure a nginx proxy for pretix
} }
} }
server { server {
listen 443 ssl default_server; listen 443 default_server;
listen [::]:443 ipv6only=on ssl default_server; listen [::]:443 ipv6only=on default_server;
server_name pretix.mydomain.com; server_name pretix.mydomain.com;
ssl on;
ssl_certificate /path/to/cert.chain.pem; ssl_certificate /path/to/cert.chain.pem;
ssl_certificate_key /path/to/key.pem; ssl_certificate_key /path/to/key.pem;
+3 -10
View File
@@ -216,10 +216,11 @@ The following snippet is an example on how to configure a nginx proxy for pretix
} }
} }
server { server {
listen 443 ssl default_server; listen 443 default_server;
listen [::]:443 ipv6only=on ssl default_server; listen [::]:443 ipv6only=on default_server;
server_name pretix.mydomain.com; server_name pretix.mydomain.com;
ssl on;
ssl_certificate /path/to/cert.chain.pem; ssl_certificate /path/to/cert.chain.pem;
ssl_certificate_key /path/to/key.pem; ssl_certificate_key /path/to/key.pem;
@@ -248,14 +249,6 @@ The following snippet is an example on how to configure a nginx proxy for pretix
return 404; return 404;
} }
location /static/staticfiles.json {
deny all;
return 404;
}
location /static/CACHE/manifest.json {
deny all;
return 404;
}
location /static/ { location /static/ {
alias /var/pretix/venv/lib/python3.11/site-packages/pretix/static.dist/; alias /var/pretix/venv/lib/python3.11/site-packages/pretix/static.dist/;
access_log off; access_log off;
-2
View File
@@ -156,8 +156,6 @@ Field specific input errors include the name of the offending fields as keys in
If you see errors of type ``429 Too Many Requests``, you should read our documentation on :ref:`rest-ratelimit`. If you see errors of type ``429 Too Many Requests``, you should read our documentation on :ref:`rest-ratelimit`.
.. _`rest-types`:
Data types Data types
---------- ----------
+23 -6
View File
@@ -31,6 +31,8 @@ subevent integer ID of the date
position_count integer Number of tickets that match this list (read-only). position_count integer Number of tickets that match this list (read-only).
checkin_count integer Number of check-ins performed on this list (read-only). checkin_count integer Number of check-ins performed on this list (read-only).
include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state. include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state.
auto_checkin_sales_channels list of strings All items on the check-in list will be automatically marked as checked-in when purchased through any of the listed sales channels.
**Deprecated, will be removed in pretix 2024.10.** Use :ref:`rest-autocheckinrules`: instead.
allow_multiple_entries boolean If ``true``, subsequent scans of a ticket on this list should not show a warning but instead be stored as an additional check-in. allow_multiple_entries boolean If ``true``, subsequent scans of a ticket on this list should not show a warning but instead be stored as an additional check-in.
allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan. allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan.
rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged. rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged.
@@ -89,7 +91,10 @@ Endpoints
"allow_entry_after_exit": true, "allow_entry_after_exit": true,
"exit_all_at": null, "exit_all_at": null,
"rules": {}, "rules": {},
"addon_match": false "addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
] ]
} }
@@ -141,7 +146,10 @@ Endpoints
"allow_entry_after_exit": true, "allow_entry_after_exit": true,
"exit_all_at": null, "exit_all_at": null,
"rules": {}, "rules": {},
"addon_match": false "addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -238,7 +246,10 @@ Endpoints
"subevent": null, "subevent": null,
"allow_multiple_entries": false, "allow_multiple_entries": false,
"allow_entry_after_exit": true, "allow_entry_after_exit": true,
"addon_match": false "addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
**Example response**: **Example response**:
@@ -260,7 +271,10 @@ Endpoints
"subevent": null, "subevent": null,
"allow_multiple_entries": false, "allow_multiple_entries": false,
"allow_entry_after_exit": true, "allow_entry_after_exit": true,
"addon_match": false "addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
:param organizer: The ``slug`` field of the organizer of the event/item to create a list for :param organizer: The ``slug`` field of the organizer of the event/item to create a list for
@@ -312,7 +326,10 @@ Endpoints
"subevent": null, "subevent": null,
"allow_multiple_entries": false, "allow_multiple_entries": false,
"allow_entry_after_exit": true, "allow_entry_after_exit": true,
"addon_match": false "addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify
@@ -325,7 +342,7 @@ Endpoints
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/checkinlist/(id)/ .. http:delete:: /api/v1/organizers/(organizer)/events/(event)/checkinlist/(id)/
Delete a check-in list. **Note that this also deletes the information on all check-ins performed via this list.** Delete a check-in list. Note that this also deletes the information on all check-ins performed via this list.
**Example request**: **Example request**:
+7 -14
View File
@@ -97,7 +97,6 @@ lines list of objects The actual invo
├ gross_value money (string) Price including taxes ├ gross_value money (string) Price including taxes
├ tax_value money (string) Tax amount included ├ tax_value money (string) Tax amount included
├ tax_name string Name of used tax rate (e.g. "VAT") ├ tax_name string Name of used tax rate (e.g. "VAT")
├ tax_code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`.
└ tax_rate decimal (string) Used tax rate └ tax_rate decimal (string) Used tax rate
foreign_currency_display string If the invoice should also show the total and tax foreign_currency_display string If the invoice should also show the total and tax
amount in a different currency, this contains the amount in a different currency, this contains the
@@ -127,10 +126,6 @@ internal_reference string Customer's refe
The ``event`` attribute has been added. The organizer-level endpoint has been added. The ``event`` attribute has been added. The organizer-level endpoint has been added.
.. versionchanged:: 2024.8
The ``tax_code`` attribute has been added.
List of all invoices List of all invoices
-------------------- --------------------
@@ -208,7 +203,6 @@ List of all invoices
"gross_value": "23.00", "gross_value": "23.00",
"tax_value": "0.00", "tax_value": "0.00",
"tax_name": "VAT", "tax_name": "VAT",
"tax_code": "S/standard",
"tax_rate": "0.00" "tax_rate": "0.00"
} }
], ],
@@ -348,7 +342,6 @@ Fetching individual invoices
"gross_value": "23.00", "gross_value": "23.00",
"tax_value": "0.00", "tax_value": "0.00",
"tax_name": "VAT", "tax_name": "VAT",
"tax_code": "S/standard",
"tax_rate": "0.00" "tax_rate": "0.00"
} }
], ],
@@ -359,12 +352,12 @@ Fetching individual invoices
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:param number: The ``number`` field of the invoice to fetch :param invoice_no: The ``invoice_no`` field of the invoice to fetch
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/download/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/download/
Download an invoice in PDF format. Download an invoice in PDF format.
@@ -391,7 +384,7 @@ Fetching individual invoices
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:param number: The ``number`` field of the invoice to fetch :param invoice_no: The ``invoice_no`` field of the invoice to fetch
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
@@ -404,7 +397,7 @@ Modifying invoices
Invoices cannot be edited directly, but the following actions can be triggered: Invoices cannot be edited directly, but the following actions can be triggered:
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/reissue/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/reissue/
Cancels the invoice and creates a new one. Cancels the invoice and creates a new one.
@@ -426,13 +419,13 @@ Invoices cannot be edited directly, but the following actions can be triggered:
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:param number: The ``number`` field of the invoice to reissue :param invoice_no: The ``invoice_no`` field of the invoice to reissue
:statuscode 200: no error :statuscode 200: no error
:statuscode 400: The invoice has already been canceled :statuscode 400: The invoice has already been canceled
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(number)/regenerate/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/invoices/(invoice_no)/regenerate/
Re-generates the invoice from order data. Re-generates the invoice from order data.
@@ -454,7 +447,7 @@ Invoices cannot be edited directly, but the following actions can be triggered:
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:param number: The ``number`` field of the invoice to regenerate :param invoice_no: The ``invoice_no`` field of the invoice to regenerate
:statuscode 200: no error :statuscode 200: no error
:statuscode 400: The invoice has already been canceled :statuscode 400: The invoice has already been canceled
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
+5 -28
View File
@@ -84,7 +84,6 @@ fees list of objects List of fees in
├ tax_rate decimal (string) VAT rate applied for this fee ├ tax_rate decimal (string) VAT rate applied for this fee
├ tax_value money (string) VAT included in this fee ├ tax_value money (string) VAT included in this fee
├ tax_rule integer The ID of the used tax rule (or ``null``) ├ tax_rule integer The ID of the used tax rule (or ``null``)
├ tax_code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`.
└ canceled boolean Whether or not this fee has been canceled. └ canceled boolean Whether or not this fee has been canceled.
downloads list of objects List of ticket download options for order-wise ticket downloads list of objects List of ticket download options for order-wise ticket
downloading. This might be a multi-page PDF or a ZIP downloading. This might be a multi-page PDF or a ZIP
@@ -105,10 +104,6 @@ url string The full URL to
payments list of objects List of payment processes (see below) payments list of objects List of payment processes (see below)
refunds list of objects List of refund processes (see below) refunds list of objects List of refund processes (see below)
last_modified datetime Last modification of this object last_modified datetime Last modification of this object
cancellation_date datetime Time of order cancellation (or ``null``). **Note**:
Will not be set for partial cancellations and is not
reliable for orders that have been cancelled,
reactivated and cancelled again.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -156,13 +151,6 @@ cancellation_date datetime Time of order c
The ``expires`` attribute can now be passed during order creation. The ``expires`` attribute can now be passed during order creation.
.. versionchanged:: 2024.11
The ``cancellation_date`` attribute has been added and can also be used as an ordering key.
.. versionchanged:: 2025.1
The ``tax_code`` attribute has been added.
.. _order-position-resource: .. _order-position-resource:
@@ -200,7 +188,6 @@ voucher_budget_use money (string) Amount of money
are changed *after* the order was created. Can be ``null``. are changed *after* the order was created. Can be ``null``.
tax_rate decimal (string) VAT rate applied for this position tax_rate decimal (string) VAT rate applied for this position
tax_value money (string) VAT included in this position tax_value money (string) VAT included in this position
tax_code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`.
tax_rule integer The ID of the used tax rule (or ``null``) tax_rule integer The ID of the used tax rule (or ``null``)
secret string Secret code printed on the tickets for validation secret string Secret code printed on the tickets for validation
addon_to integer Internal ID of the position this position is an add-on for (or ``null``) addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
@@ -261,10 +248,6 @@ pdf_data object Data object req
The attribute ``print_logs`` has been added. The attribute ``print_logs`` has been added.
.. versionchanged:: 2025.1
The ``tax_code`` attribute has been added.
.. _order-payment-resource: .. _order-payment-resource:
Order payment resource Order payment resource
@@ -416,7 +399,6 @@ List of all orders
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_value": "0.00", "tax_value": "0.00",
"tax_rule": null, "tax_rule": null,
"tax_code": null,
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
@@ -482,15 +464,14 @@ List of all orders
"provider": "banktransfer" "provider": "banktransfer"
} }
], ],
"refunds": [], "refunds": []
"cancellation_date": null
} }
] ]
} }
:query integer page: The page number in case of a multi-page result set, default is 1 :query integer page: The page number in case of a multi-page result set, default is 1
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``code``, :query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``code``,
``last_modified``, ``status`` and ``cancellation_date``. Default: ``datetime`` ``last_modified``, and ``status``. Default: ``datetime``
:query string code: Only return orders that match the given order code :query string code: Only return orders that match the given order code
:query string status: Only return orders in the given order status (see above) :query string status: Only return orders in the given order status (see above)
:query string search: Only return orders matching a given search query (matching for names, email addresses, and company names) :query string search: Only return orders matching a given search query (matching for names, email addresses, and company names)
@@ -656,7 +637,6 @@ Fetching individual orders
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": null, "tax_rule": null,
"tax_value": "0.00", "tax_value": "0.00",
"tax_code": null,
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
@@ -723,8 +703,7 @@ Fetching individual orders
"provider": "banktransfer" "provider": "banktransfer"
} }
], ],
"refunds": [], "refunds": []
"cancellation_date": null
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -855,7 +834,7 @@ Generating new secrets
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/regenerate_secrets/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/regenerate_secrets/
Triggers generation of new ``secret`` and ``ẁeb_secret`` attributes for both the order and all order positions. Triggers generation of new ``secret`` attributes for both the order and all order positions.
**Example request**: **Example request**:
@@ -886,7 +865,7 @@ Generating new secrets
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/regenerate_secrets/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/regenerate_secrets/
Triggers generation of a new ``secret`` and ``web_secret`` attribute for a single order position. Triggers generation of a new ``secret`` attribute for a single order position.
**Example request**: **Example request**:
@@ -1625,7 +1604,6 @@ List of all order positions
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": null, "tax_rule": null,
"tax_value": "0.00", "tax_value": "0.00",
"tax_code": null,
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"discount": null, "discount": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
@@ -1752,7 +1730,6 @@ Fetching individual positions
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": null, "tax_rule": null,
"tax_value": "0.00", "tax_value": "0.00",
"tax_code": null,
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
+1 -112
View File
@@ -249,7 +249,7 @@ Endpoints
"orderposition": null, "orderposition": null,
"cartposition": null, "cartposition": null,
"voucher": null "voucher": null
} },
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify :param event: The ``slug`` field of the event to modify
@@ -260,114 +260,3 @@ Endpoints
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource. :statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
:statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa. :statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/seats/bulk_block/
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/bulk_block/
Set the ``blocked`` attribute to ``true`` for a large number of seats at once.
You can pass either a list of ``id`` values or a list of ``seat_guid`` values.
You can pass up to 10,000 seats in one request.
The endpoint will return an error if you pass a seat ID that does not exist.
However, it will not return an error if one of the passed seats is already blocked or sold.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_block/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"ids": [12, 45, 56]
}
or
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_block/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"seat_guids": ["6c0e29e5-05d6-421f-99f3-afd01478ecad", "c2899340-e2e7-4d05-8100-000a4b6d7cf4"]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param subevent_id: The ``id`` field of the subevent to modify
:statuscode 200: no error
:statuscode 400: The seat could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
:statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/seats/bulk_unblock/
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/bulk_unblock/
Set the ``blocked`` attribute to ``false`` for a large number of seats at once.
You can pass either a list of ``id`` values or a list of ``seat_guid`` values.
You can pass up to 10,000 seats in one request.
The endpoint will return an error if you pass a seat ID that does not exist.
However, it will not return an error if one of the passed seat is already unblocked or is sold.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_unblock/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"ids": [12, 45, 56]
}
or
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_unblock/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"seat_guids": ["6c0e29e5-05d6-421f-99f3-afd01478ecad", "c2899340-e2e7-4d05-8100-000a4b6d7cf4"]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param subevent_id: The ``id`` field of the subevent to modify
:statuscode 200: no error
:statuscode 400: The seat could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
:statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
-47
View File
@@ -1,8 +1,3 @@
.. spelling:word-list::
EN16931
DSFinV-K
.. _rest-taxrules: .. _rest-taxrules:
Tax rules Tax rules
@@ -23,7 +18,6 @@ id integer Internal ID of
name multi-lingual string The tax rules' name name multi-lingual string The tax rules' name
internal_name string An optional name that is only used in the backend internal_name string An optional name that is only used in the backend
rate decimal (string) Tax rate in percent rate decimal (string) Tax rate in percent
code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`.
price_includes_tax boolean If ``true`` (default), tax is assumed to be included in price_includes_tax boolean If ``true`` (default), tax is assumed to be included in
the specified product price the specified product price
eu_reverse_charge boolean **DEPRECATED**. If ``true``, EU reverse charge rules eu_reverse_charge boolean **DEPRECATED**. If ``true``, EU reverse charge rules
@@ -48,42 +42,6 @@ custom_rules object Dynamic rules s
The ``custom_rules`` attribute has been added. The ``custom_rules`` attribute has been added.
.. versionchanged:: 2023.8
The ``code`` attribute has been added.
.. _rest-taxcodes:
Tax codes
---------
For integration with external systems, such as electronic invoicing or bookkeeping systems, the tax rate itself is often
not sufficient information. For example, there could be many different reasons why a sale has a tax rate of 0 %, but the
external handling of the transaction depends on which reason applies. Therefore, pretix allows to supply a codified
reason that allows us to understand what the specific legal situation is. These tax codes are modeled after a combination
of the code lists from the European standard EN16931 and the German standard DSFinV-K.
The following codes are supported:
- ``S/standard`` -- Standard VAT rate in the merchant country
- ``S/reduced`` -- Reduced VAT rate in the merchant country
- ``S/averaged`` -- Averaged VAT rate in the merchant country (known use case: agricultural businesses in Germany)
- ``AE`` -- Reverse charge
- ``O`` -- Services outside of scope of tax
- ``E`` -- Exempt from tax (no reason given)
- ``E/<reason>`` -- Exempt from tax, where ``<reason>`` is one of the codes listed in the `VATEX code list`_ version 5.0.
- ``Z`` -- Zero-rated goods
- ``G`` -- Free export item, VAT not charged
- ``K`` -- VAT exempt for EEA intra-community supply of goods and services
- ``L`` -- Canary Islands general indirect tax
- ``M`` -- Tax for production, services and importation in Ceuta and Melilla
- ``B`` -- Transferred (VAT), only in Italy
The code set in the ``code`` attribute of the tax rule is used by default. When ``eu_reverse_charge`` is active, the
code is replaced by ``AE`` for reverse charge sales and by ``O`` for non-EU sales. When configuring custom rules, you
should actively set a ``"code"`` key on each rule. Only for ``"action": "reverse"`` we automatically apply the code
``AE``, in all other cases the default ``code`` of the tax rule is selected.
Endpoints Endpoints
--------- ---------
@@ -116,7 +74,6 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "VAT"}, "name": {"en": "VAT"},
"internal_name": "VAT", "internal_name": "VAT",
"code": "S/standard",
"rate": "19.00", "rate": "19.00",
"price_includes_tax": true, "price_includes_tax": true,
"eu_reverse_charge": false, "eu_reverse_charge": false,
@@ -158,7 +115,6 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "VAT"}, "name": {"en": "VAT"},
"internal_name": "VAT", "internal_name": "VAT",
"code": "S/standard",
"rate": "19.00", "rate": "19.00",
"price_includes_tax": true, "price_includes_tax": true,
"eu_reverse_charge": false, "eu_reverse_charge": false,
@@ -208,7 +164,6 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "VAT"}, "name": {"en": "VAT"},
"internal_name": "VAT", "internal_name": "VAT",
"code": "S/standard",
"rate": "19.00", "rate": "19.00",
"price_includes_tax": true, "price_includes_tax": true,
"eu_reverse_charge": false, "eu_reverse_charge": false,
@@ -257,7 +212,6 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "VAT"}, "name": {"en": "VAT"},
"internal_name": "VAT", "internal_name": "VAT",
"code": "S/standard",
"rate": "20.00", "rate": "20.00",
"price_includes_tax": true, "price_includes_tax": true,
"eu_reverse_charge": false, "eu_reverse_charge": false,
@@ -304,4 +258,3 @@ Endpoints
:statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this tax rule cannot be deleted since it is currently in use. :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this tax rule cannot be deleted since it is currently in use.
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/tax-rules-custom.schema.json .. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/tax-rules-custom.schema.json
.. _VATEX code list: https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists
-1
View File
@@ -7,7 +7,6 @@ Table of contents
user/index user/index
admin/index admin/index
api/index api/index
storefrontapi/index
development/index development/index
plugins/index plugins/index
license/faq license/faq
-114
View File
@@ -1,114 +0,0 @@
Basic concepts
==============
This page describes basic concepts and definition that you need to know to interact
with our Storefront API, such as authentication, pagination and similar definitions.
.. _`storefront-auth`:
Authentication
--------------
The storefront API requires authentication with an API key. You receive two kinds of API keys for the storefront API:
Publishable keys and private keys. Publishable keys should be used when your website directly connects to the API.
Private keys should be used only on server-to-server connections.
Localization
------------
The storefront API will return localized and translated strings in many cases if you set an ``Accept-Language`` header.
The selected locale will only be respected if it is active for the organizer or event in question.
.. _`storefront-compat`:
Compatibility
-------------
.. note::
The storefront API is currently considered experimental and may change without notice.
Once we declare the API stable, the following compatibility policy will apply.
We try to avoid any breaking changes to our API to avoid hassle on your end. If possible, we'll
build new features in a way that keeps all pre-existing API usage unchanged. In some cases,
this might not be possible or only possible with restrictions. In these case, any
backwards-incompatible changes will be prominently noted in the "Changes to the REST API"
section of our release notes. If possible, we will announce them multiple releases in advance.
We treat the following types of changes as *backwards-compatible* so we ask you to make sure
that your clients can deal with them properly:
* Support of new API endpoints
* Support of new HTTP methods for a given API endpoint
* Support of new query parameters for a given API endpoint
* New fields contained in API responses
* New possible values of enumeration-like fields
* Response body structure or message texts on failed requests (``4xx``, ``5xx`` response codes)
We treat the following types of changes as *backwards-incompatible*:
* Type changes of fields in API responses
* New required input fields for an API endpoint
* New required type for input fields of an API endpoint
* Removal of endpoints, API methods or fields
Pagination
----------
Most lists of objects returned by pretix' API will be paginated. The response will take
the form of:
.. sourcecode:: javascript
{
"count": 117,
"next": "https://pretix.eu/api/v1/organizers/?page=2",
"previous": null,
"results": [],
}
As you can see, the response contains the total number of results in the field ``count``.
The fields ``next`` and ``previous`` contain links to the next and previous page of results,
respectively, or ``null`` if there is no such page. You can use those URLs to retrieve the
respective page.
The field ``results`` contains a list of objects representing the first results. For most
objects, every page contains 50 results. You can specify a lower pagination size using the
``page_size`` query parameter, but no more than 50.
Errors
------
Error responses (of type 400-499) are returned in one of the following forms, depending on
the type of error. General errors look like:
.. sourcecode:: http
HTTP/1.1 405 Method Not Allowed
Content-Type: application/json
Content-Length: 42
{"detail": "Method 'DELETE' not allowed."}
Field specific input errors include the name of the offending fields as keys in the response:
.. sourcecode:: http
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 94
{"amount": ["A valid integer is required."], "description": ["This field may not be blank."]}
If you see errors of type ``429 Too Many Requests``, you should read our documentation on :ref:`rest-ratelimit`.
Time Machine
------------
Just like our shop frontend, the API allows simulating responses at a different point in time using the
``X-Storefront-Time-Machine-Date`` header. This mechanism only works when the shop is in test mode.
Data types
----------
See :ref:`data types <rest-types>` of the REST API.
-17
View File
@@ -1,17 +0,0 @@
.. _`storefront-api`:
Storefront API
==============
This part of the documentation contains information about the headless e-commerce
API exposed by pretix that can be used to build a custom checkout experience.
.. note::
The storefront API is currently considered experimental and may change without notice.
.. toctree::
:maxdepth: 2
fundamentals
reference/index
-7
View File
@@ -1,7 +0,0 @@
API Reference
=============
.. toctree::
:maxdepth: 2
foo
+12 -11
View File
@@ -29,10 +29,10 @@ dependencies = [
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab "arabic-reshaper==3.0.0", # Support for Arabic in reportlab
"babel", "babel",
"BeautifulSoup4==4.12.*", "BeautifulSoup4==4.12.*",
"bleach==6.2.*", "bleach==5.0.*",
"celery==5.4.*", "celery==5.4.*",
"chardet==5.2.*", "chardet==5.2.*",
"cryptography>=44.0.0", "cryptography>=3.4.2",
"css-inline==0.14.*", "css-inline==0.14.*",
"defusedcsv>=1.1.0", "defusedcsv>=1.1.0",
"Django[argon2]==4.2.*,>=4.2.15", "Django[argon2]==4.2.*,>=4.2.15",
@@ -44,7 +44,7 @@ dependencies = [
"django-formtools==2.5.1", "django-formtools==2.5.1",
"django-hierarkey==1.2.*", "django-hierarkey==1.2.*",
"django-hijack==3.7.*", "django-hijack==3.7.*",
"django-i18nfield==1.10.*", "django-i18nfield==1.9.*,>=1.9.4",
"django-libsass==0.9", "django-libsass==0.9",
"django-localflavor==4.0", "django-localflavor==4.0",
"django-markup", "django-markup",
@@ -53,7 +53,7 @@ dependencies = [
"django-phonenumber-field==7.3.*", "django-phonenumber-field==7.3.*",
"django-redis==5.4.*", "django-redis==5.4.*",
"django-scopes==2.0.*", "django-scopes==2.0.*",
"django-statici18n==2.6.*", "django-statici18n==2.5.*",
"djangorestframework==3.15.*", "djangorestframework==3.15.*",
"dnspython==2.7.*", "dnspython==2.7.*",
"drf_ujson2==1.7.*", "drf_ujson2==1.7.*",
@@ -74,9 +74,9 @@ dependencies = [
"paypal-checkout-serversdk==1.0.*", "paypal-checkout-serversdk==1.0.*",
"PyJWT==2.9.*", "PyJWT==2.9.*",
"phonenumberslite==8.13.*", "phonenumberslite==8.13.*",
"Pillow==11.1.*", "Pillow==11.0.*",
"pretix-plugin-build", "pretix-plugin-build",
"protobuf==5.29.*", "protobuf==5.28.*",
"psycopg2-binary", "psycopg2-binary",
"pycountry", "pycountry",
"pycparser==2.22", "pycparser==2.22",
@@ -91,23 +91,24 @@ dependencies = [
"redis==5.2.*", "redis==5.2.*",
"reportlab==4.2.*", "reportlab==4.2.*",
"requests==2.31.*", "requests==2.31.*",
"sentry-sdk==2.18.*", "sentry-sdk==2.17.*",
"sepaxml==2.6.*", "sepaxml==2.6.*",
"slimit",
"stripe==7.9.*", "stripe==7.9.*",
"text-unidecode==1.*", "text-unidecode==1.*",
"tlds>=2020041600", "tlds>=2020041600",
"tqdm==4.*", "tqdm==4.*",
"ua-parser==1.0.*", "ua-parser==0.18.*",
"vat_moss_forked==2020.3.20.0.11.0", "vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*", "vobject==0.9.*",
"webauthn==2.4.*", "webauthn==2.2.*",
"zeep==4.3.*" "zeep==4.3.*"
] ]
[project.optional-dependencies] [project.optional-dependencies]
memcached = ["pylibmc"] memcached = ["pylibmc"]
dev = [ dev = [
"aiohttp==3.11.*", "aiohttp==3.10.*",
"coverage", "coverage",
"coveralls", "coveralls",
"fakeredis==2.26.*", "fakeredis==2.26.*",
@@ -116,7 +117,7 @@ dev = [
"isort==5.13.*", "isort==5.13.*",
"pep8-naming==0.14.*", "pep8-naming==0.14.*",
"potypo", "potypo",
"pytest-asyncio>=0.24", "pytest-asyncio",
"pytest-cache", "pytest-cache",
"pytest-cov", "pytest-cov",
"pytest-django==4.*", "pytest-django==4.*",
+1
View File
@@ -24,6 +24,7 @@ from pathlib import Path
import setuptools import setuptools
sys.path.append(str(Path.cwd() / 'src')) sys.path.append(str(Path.cwd() / 'src'))
+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 # 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/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2024.12.0.dev0" __version__ = "2024.11.0.dev0"
-1
View File
@@ -44,7 +44,6 @@ INSTALLED_APPS = [
'pretix.presale', 'pretix.presale',
'pretix.multidomain', 'pretix.multidomain',
'pretix.api', 'pretix.api',
'pretix.storefrontapi',
'pretix.helpers', 'pretix.helpers',
'rest_framework', 'rest_framework',
'djangoformsetjs', 'djangoformsetjs',
+1 -1
View File
@@ -103,7 +103,7 @@ class SalesChannelMigrationMixin:
] ]
}) })
if set(data["sales_channels"]) == all_channels: if data["sales_channels"] == all_channels:
data["all_sales_channels"] = True data["all_sales_channels"] = True
data["limit_sales_channels"] = [] data["limit_sales_channels"] = []
else: else:
+1 -1
View File
@@ -235,7 +235,7 @@ class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
return cid return cid
def create(self, validated_data): def create(self, validated_data):
validated_data.pop('sales_channel', None) validated_data.pop('sales_channel')
addons_data = validated_data.pop('addons', None) addons_data = validated_data.pop('addons', None)
bundled_data = validated_data.pop('bundled', None) bundled_data = validated_data.pop('bundled', None)
+11 -2
View File
@@ -26,22 +26,31 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Checkin, CheckinList from pretix.base.models import Checkin, CheckinList, SalesChannel
class CheckinListSerializer(I18nAwareModelSerializer): class CheckinListSerializer(I18nAwareModelSerializer):
checkin_count = serializers.IntegerField(read_only=True) checkin_count = serializers.IntegerField(read_only=True)
position_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: class Meta:
model = CheckinList model = CheckinList
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count', fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
'include_pending', 'allow_multiple_entries', 'allow_entry_after_exit', 'include_pending', 'auto_checkin_sales_channels', 'allow_multiple_entries', 'allow_entry_after_exit',
'rules', 'exit_all_at', 'addon_match', 'ignore_in_statistics', 'consider_tickets_used') 'rules', 'exit_all_at', 'addon_match', 'ignore_in_statistics', 'consider_tickets_used')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*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'): if 'subevent' in self.context['request'].query_params.getlist('expand'):
self.fields['subevent'] = SubEventSerializer(read_only=True) self.fields['subevent'] = SubEventSerializer(read_only=True)
+6 -43
View File
@@ -35,7 +35,7 @@
import logging import logging
from django.conf import settings from django.conf import settings
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction from django.db import transaction
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -43,7 +43,6 @@ from django.utils.translation import gettext as _
from django_countries.serializers import CountryFieldMixin from django_countries.serializers import CountryFieldMixin
from pytz import common_timezones from pytz import common_timezones
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.fields import ChoiceField, Field from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
@@ -437,8 +436,7 @@ class CloneEventSerializer(EventSerializer):
testmode = validated_data.pop('testmode', None) testmode = validated_data.pop('testmode', None)
has_subevents = validated_data.pop('has_subevents', None) has_subevents = validated_data.pop('has_subevents', None)
tz = validated_data.pop('timezone', None) tz = validated_data.pop('timezone', None)
all_sales_channels = validated_data.pop('all_sales_channels', None) sales_channels = validated_data.pop('sales_channels', None)
limit_sales_channels = validated_data.pop('limit_sales_channels', None)
date_admission = validated_data.pop('date_admission', None) date_admission = validated_data.pop('date_admission', None)
new_event = super().create({**validated_data, 'plugins': None}) new_event = super().create({**validated_data, 'plugins': None})
@@ -451,9 +449,8 @@ class CloneEventSerializer(EventSerializer):
new_event.is_public = is_public new_event.is_public = is_public
if testmode is not None: if testmode is not None:
new_event.testmode = testmode new_event.testmode = testmode
if all_sales_channels is not None or limit_sales_channels is not None: if sales_channels is not None:
new_event.all_sales_channels = all_sales_channels new_event.sales_channels = sales_channels
new_event.limit_sales_channels.set(limit_sales_channels)
if has_subevents is not None: if has_subevents is not None:
new_event.has_subevents = has_subevents new_event.has_subevents = has_subevents
if has_subevents is not None: if has_subevents is not None:
@@ -681,8 +678,8 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
class Meta: class Meta:
model = TaxRule model = TaxRule
fields = ('id', 'name', 'rate', 'code', 'price_includes_tax', 'eu_reverse_charge', 'home_country', fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name',
'internal_name', 'keep_gross_if_rate_changes', 'custom_rules') 'keep_gross_if_rate_changes', 'custom_rules')
class EventSettingsSerializer(SettingsSerializer): class EventSettingsSerializer(SettingsSerializer):
@@ -992,40 +989,6 @@ def prefetch_by_id(items, qs, id_attr, target_attr):
setattr(item, target_attr, result.get(getattr(item, id_attr))) setattr(item, target_attr, result.get(getattr(item, id_attr)))
class SeatBulkBlockInputSerializer(serializers.Serializer):
ids = serializers.ListField(child=serializers.IntegerField(), required=False, allow_empty=True)
seat_guids = serializers.ListField(child=serializers.CharField(), required=False, allow_empty=True)
def to_internal_value(self, data):
data = super().to_internal_value(data)
if data.get("seat_guids") and data.get("ids"):
raise ValidationError("Please pass either seat_guids or ids.")
if data.get("seat_guids"):
seat_ids = data["seat_guids"]
if len(seat_ids) > 10000:
raise ValidationError({"seat_guids": ["Please do not pass over 10000 seats."]})
seats = {s.seat_guid: s for s in self.context["queryset"].filter(seat_guid__in=seat_ids)}
for s in seat_ids:
if s not in seats:
raise ValidationError({"seat_guids": [f"The seat '{s}' does not exist."]})
elif data.get("ids"):
seat_ids = data["ids"]
if len(seat_ids) > 10000:
raise ValidationError({"ids": ["Please do not pass over 10000 seats."]})
seats = self.context["queryset"].in_bulk(seat_ids)
for s in seat_ids:
if s not in seats:
raise ValidationError({"ids": [f"The seat '{s}' does not exist."]})
else:
raise ValidationError("Please pass either seat_guids or ids.")
return {"seats": seats.values()}
class SeatSerializer(I18nAwareModelSerializer): class SeatSerializer(I18nAwareModelSerializer):
orderposition = serializers.IntegerField(source='orderposition_id') orderposition = serializers.IntegerField(source='orderposition_id')
cartposition = serializers.IntegerField(source='cartposition_id') cartposition = serializers.IntegerField(source='cartposition_id')
+50 -8
View File
@@ -19,8 +19,57 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
from django.conf import settings
from django.core.validators import URLValidator from django.core.validators import URLValidator
from i18nfield.rest_framework import I18nAwareModelSerializer, I18nField from i18nfield.fields import I18nCharField, I18nTextField
from i18nfield.strings import LazyI18nString
from rest_framework.exceptions import ValidationError
from rest_framework.fields import Field
from rest_framework.serializers import ModelSerializer
class I18nField(Field):
def __init__(self, **kwargs):
self.allow_blank = kwargs.pop('allow_blank', False)
self.trim_whitespace = kwargs.pop('trim_whitespace', True)
self.max_length = kwargs.pop('max_length', None)
self.min_length = kwargs.pop('min_length', None)
super().__init__(**kwargs)
def to_representation(self, value):
if hasattr(value, 'data'):
if isinstance(value.data, dict):
return value.data
elif value.data is None:
return None
else:
return {
settings.LANGUAGE_CODE: str(value.data)
}
elif value is None:
return None
else:
return {
settings.LANGUAGE_CODE: str(value)
}
def to_internal_value(self, data):
if isinstance(data, str):
return LazyI18nString(data)
elif isinstance(data, dict):
if any([k not in dict(settings.LANGUAGES) for k in data.keys()]):
raise ValidationError('Invalid languages included.')
return LazyI18nString(data)
else:
raise ValidationError('Invalid data type.')
class I18nAwareModelSerializer(ModelSerializer):
pass
I18nAwareModelSerializer.serializer_field_mapping[I18nCharField] = I18nField
I18nAwareModelSerializer.serializer_field_mapping[I18nTextField] = I18nField
class I18nURLField(I18nField): class I18nURLField(I18nField):
@@ -35,10 +84,3 @@ class I18nURLField(I18nField):
else: else:
URLValidator()(value.data) URLValidator()(value.data)
return value return value
__all__ = [
"I18nAwareModelSerializer", # for backwards compatibility
"I18nField", # for backwards compatibility
"I18nURLField",
]
+7 -9
View File
@@ -512,12 +512,11 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount', 'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled', 'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled',
'print_logs', 'downloads', 'answers', 'tax_rule', 'tax_code', 'pseudonymization_id', 'pdf_data', 'seat', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use')
'canceled', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use')
read_only_fields = ( read_only_fields = (
'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret', 'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret',
'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'tax_code', 'pseudonymization_id', 'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data',
'pdf_data', 'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use' 'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use'
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -643,8 +642,7 @@ class OrderPaymentDateField(serializers.DateField):
class OrderFeeSerializer(I18nAwareModelSerializer): class OrderFeeSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = OrderFee model = OrderFee
fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled')
'tax_code', 'canceled')
class PaymentURLField(serializers.URLField): class PaymentURLField(serializers.URLField):
@@ -755,12 +753,12 @@ class OrderSerializer(I18nAwareModelSerializer):
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date', 'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads', 'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date' 'url', 'customer', 'valid_if_pending', 'api_meta'
) )
read_only_fields = ( read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date', 'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer', 'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer',
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date' 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel'
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -1678,7 +1676,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = InvoiceLine model = InvoiceLine
fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from', fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from',
'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_code', 'tax_name', 'fee_type', 'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type',
'fee_internal_type', 'event_location') 'fee_internal_type', 'event_location')
+1 -3
View File
@@ -116,7 +116,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
if 'subevent' in self.request.query_params.getlist('expand'): if 'subevent' in self.request.query_params.getlist('expand'):
qs = qs.prefetch_related( qs = qs.prefetch_related(
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set', 'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
'subevent__seat_category_mappings', 'subevent__meta_values', 'subevent__seat_category_mappings', 'subevent__meta_values', 'auto_checkin_sales_channels'
) )
return qs return qs
@@ -143,9 +143,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
data=self.request.data data=self.request.data
) )
@transaction.atomic
def perform_destroy(self, instance): def perform_destroy(self, instance):
instance.checkins.all().delete()
instance.log_action( instance.log_action(
'pretix.event.checkinlist.deleted', 'pretix.event.checkinlist.deleted',
user=self.request.user, user=self.request.user,
+4 -27
View File
@@ -40,7 +40,6 @@ from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from rest_framework import serializers, views, viewsets from rest_framework import serializers, views, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ( from rest_framework.exceptions import (
NotFound, PermissionDenied, ValidationError, NotFound, PermissionDenied, ValidationError,
) )
@@ -51,9 +50,8 @@ from pretix.api.auth.permission import EventCRUDPermission
from pretix.api.pagination import TotalOrderingFilter from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.event import ( from pretix.api.serializers.event import (
CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer, CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer,
EventSettingsSerializer, ItemMetaPropertiesSerializer, EventSettingsSerializer, ItemMetaPropertiesSerializer, SeatSerializer,
SeatBulkBlockInputSerializer, SeatSerializer, SubEventSerializer, SubEventSerializer, TaxRuleSerializer,
TaxRuleSerializer,
) )
from pretix.api.views import ConditionalListView from pretix.api.views import ConditionalListView
from pretix.base.models import ( from pretix.base.models import (
@@ -239,9 +237,9 @@ class EventViewSet(viewsets.ModelViewSet):
disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value} disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value}
changed = merge_dicts(enabled, disabled) changed = merge_dicts(enabled, disabled)
for module, operation in changed.items(): for module, action in changed.items():
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.plugins.' + operation, 'pretix.event.plugins.' + action,
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data={'plugin': module} data={'plugin': module}
@@ -746,24 +744,3 @@ class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
auth=self.request.auth, auth=self.request.auth,
data={"seats": [serializer.instance.pk]}, data={"seats": [serializer.instance.pk]},
) )
def bulk_change_blocked(self, blocked):
s = SeatBulkBlockInputSerializer(
data=self.request.data,
context={"event": self.request.event, "queryset": self.get_queryset()},
)
s.is_valid(raise_exception=True)
seats = s.validated_data["seats"]
for seat in seats:
seat.blocked = blocked
Seat.objects.bulk_update(seats, ["blocked"], batch_size=1000)
return Response({})
@action(methods=["POST"], detail=False)
def bulk_block(self, request, *args, **kwargs):
return self.bulk_change_blocked(True)
@action(methods=["POST"], detail=False)
def bulk_unblock(self, request, *args, **kwargs):
return self.bulk_change_blocked(False)
+3 -7
View File
@@ -35,7 +35,6 @@ from django.db.models import (
from django.db.models.functions import Coalesce, Concat from django.db.models.functions import Coalesce, Concat
from django.http import FileResponse, HttpResponse from django.http import FileResponse, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import formats
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
@@ -68,7 +67,6 @@ from pretix.api.serializers.orderchange import (
OrderPositionInfoPatchSerializer, OrderPositionInfoPatchSerializer,
) )
from pretix.api.views import RichOrderingFilter from pretix.api.views import RichOrderingFilter
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue, CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue,
@@ -99,6 +97,7 @@ from pretix.base.services.tickets import generate
from pretix.base.signals import ( from pretix.base.signals import (
order_modified, order_paid, order_placed, register_ticket_outputs, order_modified, order_paid, order_placed, register_ticket_outputs,
) )
from pretix.base.templatetags.money import money_filter
from pretix.control.signals import order_search_filter_q from pretix.control.signals import order_search_filter_q
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
@@ -216,7 +215,7 @@ class OrderViewSetMixin:
queryset = Order.objects.none() queryset = Order.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter) filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
ordering = ('datetime',) ordering = ('datetime',)
ordering_fields = ('datetime', 'code', 'status', 'last_modified', 'cancellation_date') ordering_fields = ('datetime', 'code', 'status', 'last_modified')
filterset_class = OrderFilter filterset_class = OrderFilter
lookup_field = 'code' lookup_field = 'code'
@@ -647,8 +646,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
order = self.get_object() order = self.get_object()
order.secret = generate_secret() order.secret = generate_secret()
for op in order.all_positions.all(): for op in order.all_positions.all():
op.web_secret = generate_secret()
op.save(update_fields=["web_secret"])
assign_ticket_secret( assign_ticket_secret(
request.event, op, force_invalidate=True, save=True request.event, op, force_invalidate=True, save=True
) )
@@ -1231,10 +1228,9 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
price = get_price(**kwargs) price = get_price(**kwargs)
tr = kwargs.get('tax_rule', kwargs.get('item').tax_rule) tr = kwargs.get('tax_rule', kwargs.get('item').tax_rule)
with language(data.get('locale') or self.request.event.settings.locale, self.request.event.settings.region): with language(data.get('locale') or self.request.event.settings.locale, self.request.event.settings.region):
gross_formatted = formats.localize_input(round_decimal(price.gross, self.request.event.currency))
return Response({ return Response({
'gross': price.gross, 'gross': price.gross,
'gross_formatted': gross_formatted, 'gross_formatted': money_filter(price.gross, self.request.event.currency, hide_currency=True),
'net': price.net, 'net': price.net,
'rate': price.rate, 'rate': price.rate,
'name': str(price.name), 'name': str(price.name),
+1 -1
View File
@@ -152,7 +152,7 @@ class NativeAuthBackend(BaseAuthBackend):
to log in. to log in.
""" """
d = OrderedDict([ d = OrderedDict([
('email', forms.EmailField(label=_("Email"), max_length=254, ('email', forms.EmailField(label=_("E-mail"), max_length=254,
widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))), widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))),
('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput, ('password', forms.CharField(label=_("Password"), widget=forms.PasswordInput,
max_length=4096)), max_length=4096)),
+3 -7
View File
@@ -35,7 +35,6 @@ from django.utils.translation import get_language, gettext_lazy as _
from pretix.base.models import Event from pretix.base.models import Event
from pretix.base.signals import register_html_mail_renderers from pretix.base.signals import register_html_mail_renderers
from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.helpers.format import SafeFormatter, format_map
from pretix.base.services.placeholders import ( # noqa from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext get_available_placeholders, PlaceholderContext
@@ -69,7 +68,7 @@ def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
class BaseHTMLMailRenderer: class BaseHTMLMailRenderer:
""" """
This is the base class for all HTML email renderers. This is the base class for all HTML e-mail renderers.
""" """
def __init__(self, event: Event, organizer=None): def __init__(self, event: Event, organizer=None):
@@ -80,7 +79,7 @@ class BaseHTMLMailRenderer:
return self.identifier return self.identifier
def render(self, plain_body: str, plain_signature: str, subject: str, order=None, def render(self, plain_body: str, plain_signature: str, subject: str, order=None,
position=None, context=None) -> str: position=None) -> str:
""" """
This method should generate the HTML part of the email. This method should generate the HTML part of the email.
@@ -89,7 +88,6 @@ class BaseHTMLMailRenderer:
:param subject: The email subject. :param subject: The email subject.
:param order: The order if this email is connected to one, otherwise ``None``. :param order: The order if this email is connected to one, otherwise ``None``.
:param position: The order position if this email is connected to one, otherwise ``None``. :param position: The order position if this email is connected to one, otherwise ``None``.
:param context: Context to use to render placeholders in the plain body
:return: An HTML string :return: An HTML string
""" """
raise NotImplementedError() raise NotImplementedError()
@@ -136,10 +134,8 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def compile_markdown(self, plaintext): def compile_markdown(self, plaintext):
return markdown_compile_email(plaintext) return markdown_compile_email(plaintext)
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str: def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str:
body_md = self.compile_markdown(plain_body) body_md = self.compile_markdown(plain_body)
if context:
body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
htmlctx = { htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME, 'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL, 'site_url': settings.SITE_URL,
+1 -1
View File
@@ -64,7 +64,7 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
_('Customer ID'), _('Customer ID'),
_('SSO provider'), _('SSO provider'),
_('External identifier'), _('External identifier'),
_('Email'), _('E-mail'),
_('Phone number'), _('Phone number'),
_('Full name'), _('Full name'),
] ]
+2 -2
View File
@@ -199,7 +199,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Invoice number'), _('Invoice number'),
_('Date'), _('Date'),
_('Order code'), _('Order code'),
_('Email address'), _('E-mail address'),
_('Invoice type'), _('Invoice type'),
_('Cancellation of'), _('Cancellation of'),
_('Language'), _('Language'),
@@ -326,7 +326,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
_('Event start date'), _('Event start date'),
_('Date'), _('Date'),
_('Order code'), _('Order code'),
_('Email address'), _('E-mail address'),
_('Invoice type'), _('Invoice type'),
_('Cancellation of'), _('Cancellation of'),
_('Invoice sender:') + ' ' + _('Name'), _('Invoice sender:') + ' ' + _('Name'),
+2 -2
View File
@@ -284,7 +284,7 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Comment')) headers.append(_('Comment'))
headers.append(_('Follow-up date')) headers.append(_('Follow-up date'))
headers.append(_('Positions')) headers.append(_('Positions'))
headers.append(_('Email address verified')) headers.append(_('E-mail address verified'))
headers.append(_('External customer ID')) headers.append(_('External customer ID'))
headers.append(_('Payment providers')) headers.append(_('Payment providers'))
if form_data.get('include_payment_amounts'): if form_data.get('include_payment_amounts'):
@@ -655,7 +655,7 @@ class OrderListExporter(MultiSheetListExporter):
headers += [ headers += [
_('Sales channel'), _('Sales channel'),
_('Order locale'), _('Order locale'),
_('Email address verified'), _('E-mail address verified'),
_('External customer ID'), _('External customer ID'),
_('Check-in lists'), _('Check-in lists'),
_('Payment providers'), _('Payment providers'),
+4 -4
View File
@@ -100,7 +100,7 @@ class MarkdownTextarea(forms.Textarea):
class I18nMarkdownTextarea(i18nfield.forms.I18nTextarea): class I18nMarkdownTextarea(i18nfield.forms.I18nTextarea):
def format_output(self, rendered_widgets, id_) -> str: def format_output(self, rendered_widgets) -> str:
rendered_widgets = rendered_widgets + [ rendered_widgets = rendered_widgets + [
'<div class="i18n-field-markdown-note">%s</div>' % ( '<div class="i18n-field-markdown-note">%s</div>' % (
_("You can use {markup_name} in this field.").format( _("You can use {markup_name} in this field.").format(
@@ -108,11 +108,11 @@ class I18nMarkdownTextarea(i18nfield.forms.I18nTextarea):
) )
) )
] ]
return super().format_output(rendered_widgets, id_) return super().format_output(rendered_widgets)
class I18nMarkdownTextInput(i18nfield.forms.I18nTextInput): class I18nMarkdownTextInput(i18nfield.forms.I18nTextInput):
def format_output(self, rendered_widgets, id_) -> str: def format_output(self, rendered_widgets) -> str:
rendered_widgets = rendered_widgets + [ rendered_widgets = rendered_widgets + [
'<div class="i18n-field-markdown-note">%s</div>' % ( '<div class="i18n-field-markdown-note">%s</div>' % (
_("You can use {markup_name} in this field.").format( _("You can use {markup_name} in this field.").format(
@@ -120,7 +120,7 @@ class I18nMarkdownTextInput(i18nfield.forms.I18nTextInput):
) )
) )
] ]
return super().format_output(rendered_widgets, id_) return super().format_output(rendered_widgets)
SECRET_REDACTED = '*****' SECRET_REDACTED = '*****'
+1 -1
View File
@@ -254,7 +254,7 @@ class PasswordRecoverForm(forms.Form):
class PasswordForgotForm(forms.Form): class PasswordForgotForm(forms.Form):
email = forms.EmailField( email = forms.EmailField(
label=_('Email'), label=_('E-mail'),
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
+19 -50
View File
@@ -54,7 +54,6 @@ from django.core.validators import (
from django.db.models import QuerySet from django.db.models import QuerySet
from django.forms import Select, widgets from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.urls import reverse
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@@ -78,7 +77,7 @@ from pretix.base.i18n import (
get_babel_locale, get_language_without_region, language, get_babel_locale, get_language_without_region, language,
) )
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
from pretix.base.models.tax import ask_for_vat_id from pretix.base.models.tax import VAT_ID_COUNTRIES, ask_for_vat_id
from pretix.base.services.tax import ( from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, validate_vat_id, VATIDFinalError, VATIDTemporaryError, validate_vat_id,
) )
@@ -277,10 +276,6 @@ class NamePartsFormField(forms.MultiValueField):
return value return value
def name_parts_is_empty(name_parts_dict):
return not any(k != "_scheme" and v for k, v in name_parts_dict.items())
class WrappedPhonePrefixSelect(Select): class WrappedPhonePrefixSelect(Select):
initial = None initial = None
@@ -607,7 +602,6 @@ class BaseQuestionsForm(forms.Form):
questions = pos.item.questions_to_ask questions = pos.item.questions_to_ask
event = kwargs.pop('event') event = kwargs.pop('event')
self.all_optional = kwargs.pop('all_optional', False) self.all_optional = kwargs.pop('all_optional', False)
self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -682,7 +676,7 @@ class BaseQuestionsForm(forms.Form):
if item.ask_attendee_data and event.settings.attendee_addresses_asked: if item.ask_attendee_data and event.settings.attendee_addresses_asked:
add_fields['street'] = forms.CharField( add_fields['street'] = forms.CharField(
required=self.attendee_addresses_required, required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Address'), label=_('Address'),
widget=forms.Textarea(attrs={ widget=forms.Textarea(attrs={
'rows': 2, 'rows': 2,
@@ -692,7 +686,7 @@ class BaseQuestionsForm(forms.Form):
initial=(cartpos.street if cartpos else orderpos.street), initial=(cartpos.street if cartpos else orderpos.street),
) )
add_fields['zipcode'] = forms.CharField( add_fields['zipcode'] = forms.CharField(
required=False, required=event.settings.attendee_addresses_required and not self.all_optional,
max_length=30, max_length=30,
label=_('ZIP code'), label=_('ZIP code'),
initial=(cartpos.zipcode if cartpos else orderpos.zipcode), initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
@@ -701,7 +695,7 @@ class BaseQuestionsForm(forms.Form):
}), }),
) )
add_fields['city'] = forms.CharField( add_fields['city'] = forms.CharField(
required=False, required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('City'), label=_('City'),
max_length=255, max_length=255,
initial=(cartpos.city if cartpos else orderpos.city), initial=(cartpos.city if cartpos else orderpos.city),
@@ -713,12 +707,11 @@ class BaseQuestionsForm(forms.Form):
add_fields['country'] = CountryField( add_fields['country'] = CountryField(
countries=CachedCountries countries=CachedCountries
).formfield( ).formfield(
required=self.attendee_addresses_required, required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Country'), label=_('Country'),
initial=country, initial=country,
widget=forms.Select(attrs={ widget=forms.Select(attrs={
'autocomplete': 'country', 'autocomplete': 'country',
'data-country-information-url': reverse('js_helpers.states'),
}), }),
) )
c = [('', pgettext_lazy('address', 'Select state'))] c = [('', pgettext_lazy('address', 'Select state'))]
@@ -953,9 +946,9 @@ class BaseQuestionsForm(forms.Form):
d = super().clean() d = super().clean()
if self.address_validation: if self.address_validation:
self.cleaned_data = d = validate_address(d, all_optional=not self.attendee_addresses_required) self.cleaned_data = d = validate_address(d, True)
if d.get('street') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS: if d.get('city') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not d.get('state'): if not d.get('state'):
self.add_error('state', _('This field is required.')) self.add_error('state', _('This field is required.'))
@@ -1012,7 +1005,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'street': forms.Textarea(attrs={ 'street': forms.Textarea(attrs={
'rows': 2, 'rows': 2,
'placeholder': _('Street and Number'), 'placeholder': _('Street and Number'),
'autocomplete': 'street-address', 'autocomplete': 'street-address'
}), }),
'beneficiary': forms.Textarea(attrs={'rows': 3}), 'beneficiary': forms.Textarea(attrs={'rows': 3}),
'country': forms.Select(attrs={ 'country': forms.Select(attrs={
@@ -1028,25 +1021,13 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'data-display-dependency': '#id_is_business_1', 'data-display-dependency': '#id_is_business_1',
'autocomplete': 'organization', 'autocomplete': 'organization',
}), }),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}), 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-with-vat-id': ','.join(VAT_ID_COUNTRIES)}),
'internal_reference': forms.TextInput, 'internal_reference': forms.TextInput,
} }
labels = { labels = {
'is_business': '' 'is_business': ''
} }
@property
def ask_vat_id(self):
return self.event.settings.invoice_address_vatid
@property
def address_required(self):
return self.event.settings.invoice_address_required
@property
def company_required(self):
return self.event.settings.invoice_address_company_required
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.event = event = kwargs.pop('event') self.event = event = kwargs.pop('event')
self.request = kwargs.pop('request', None) self.request = kwargs.pop('request', None)
@@ -1058,11 +1039,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
kwargs['initial']['country'] = guess_country_from_request(self.request, self.event) kwargs['initial']['country'] = guess_country_from_request(self.request, self.event)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not event.settings.invoice_address_vatid:
self.fields["company"].widget.attrs["data-display-dependency"] = f'#id_{self.add_prefix("is_business")}_1'
self.fields["vat_id"].widget.attrs["data-display-dependency"] = f'#id_{self.add_prefix("is_business")}_1'
if not self.ask_vat_id:
del self.fields['vat_id'] del self.fields['vat_id']
elif self.validate_vat_id: elif self.validate_vat_id:
self.fields['vat_id'].help_text = '<br/>'.join([ self.fields['vat_id'].help_text = '<br/>'.join([
@@ -1078,7 +1055,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
]) ])
self.fields['country'].choices = CachedCountries() self.fields['country'].choices = CachedCountries()
self.fields['country'].widget.attrs['data-country-information-url'] = reverse('js_helpers.states')
c = [('', pgettext_lazy('address', 'Select state'))] c = [('', pgettext_lazy('address', 'Select state'))]
fprefix = self.prefix + '-' if self.prefix else '' fprefix = self.prefix + '-' if self.prefix else ''
@@ -1107,22 +1083,18 @@ class BaseInvoiceAddressForm(forms.ModelForm):
) )
self.fields['state'].widget.is_required = True self.fields['state'].widget.is_required = True
self.fields['street'].required = False
self.fields['zipcode'].required = False
self.fields['city'].required = False
# Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected. # Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected.
if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data: if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data:
self.data = self.data.copy() self.data = self.data.copy()
del self.data[fprefix + 'vat_id'] del self.data[fprefix + 'vat_id']
if not self.address_required or self.all_optional: if not event.settings.invoice_address_required or self.all_optional:
for k, f in self.fields.items(): for k, f in self.fields.items():
f.required = False f.required = False
f.widget.is_required = False f.widget.is_required = False
if 'required' in f.widget.attrs: if 'required' in f.widget.attrs:
del f.widget.attrs['required'] del f.widget.attrs['required']
elif self.company_required and not self.all_optional: elif event.settings.invoice_address_company_required and not self.all_optional:
self.initial['is_business'] = True self.initial['is_business'] = True
self.fields['is_business'].widget = BusinessBooleanRadio(require_business=True) self.fields['is_business'].widget = BusinessBooleanRadio(require_business=True)
@@ -1139,11 +1111,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
label=_('Name'), label=_('Name'),
initial=self.instance.name_parts, initial=self.instance.name_parts,
) )
if self.address_required and not self.company_required and not self.all_optional: if event.settings.invoice_address_required and not event.settings.invoice_address_company_required and not self.all_optional:
if not event.settings.invoice_name_required: if not event.settings.invoice_name_required:
self.fields['name_parts'].widget.attrs['data-required-if'] = f'#id_{self.add_prefix("is_business")}_0' self.fields['name_parts'].widget.attrs['data-required-if'] = '#id_is_business_0'
self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1' self.fields['name_parts'].widget.attrs['data-no-required-attr'] = '1'
self.fields['company'].widget.attrs['data-required-if'] = f'#id_{self.add_prefix("is_business")}_1' self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
if not event.settings.invoice_address_beneficiary: if not event.settings.invoice_address_beneficiary:
del self.fields['beneficiary'] del self.fields['beneficiary']
@@ -1163,19 +1135,16 @@ class BaseInvoiceAddressForm(forms.ModelForm):
validate_address # local import to prevent impact on startup time validate_address # local import to prevent impact on startup time
data = self.cleaned_data data = self.cleaned_data
if not data.get('is_business'): if not data.get('is_business'):
data['company'] = '' data['company'] = ''
data['vat_id'] = '' data['vat_id'] = ''
if data.get('is_business') and not ask_for_vat_id(data.get('country')): if data.get('is_business') and not ask_for_vat_id(data.get('country')):
data['vat_id'] = '' data['vat_id'] = ''
if self.address_validation and self.address_required and not self.all_optional: if self.event.settings.invoice_address_required:
if data.get('is_business') and not data.get('company'): if data.get('is_business') and not data.get('company'):
raise ValidationError({"company": _('You need to provide a company name.')}) raise ValidationError(_('You need to provide a company name.'))
if not data.get('is_business') and name_parts_is_empty(data.get('name_parts', {})): if not data.get('is_business') and not data.get('name_parts'):
raise ValidationError(_('You need to provide your name.')) raise ValidationError(_('You need to provide your name.'))
if not data.get('street') and not data.get('zipcode') and not data.get('city'):
raise ValidationError({"street": _('This field is required.')})
if 'vat_id' in self.changed_data or not data.get('vat_id'): if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False self.instance.vat_id_validated = False
@@ -1187,7 +1156,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if all( if all(
not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts') not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts')
) and name_parts_is_empty(data.get('name_parts', {})): ) and len(data.get('name_parts', {})) == 1:
# Do not save the country if it is the only field set -- we don't know the user even checked it! # Do not save the country if it is the only field set -- we don't know the user even checked it!
self.cleaned_data['country'] = '' self.cleaned_data['country'] = ''
+3 -3
View File
@@ -48,10 +48,10 @@ from pretix.control.forms import SingleLanguageWidget
class UserSettingsForm(forms.ModelForm): class UserSettingsForm(forms.ModelForm):
error_messages = { error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. " 'duplicate_identifier': _("There already is an account associated with this e-mail address. "
"Please choose a different one."), "Please choose a different one."),
'pw_current': _("Please enter your current password if you want to change your email address " 'pw_current': _("Please enter your current password if you want to change your e-mail "
"or password."), "address or password."),
'pw_current_wrong': _("The current password you entered was not correct."), 'pw_current_wrong': _("The current password you entered was not correct."),
'pw_mismatch': _("Please enter the same password twice"), 'pw_mismatch': _("Please enter the same password twice"),
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."), 'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
+2 -2
View File
@@ -289,7 +289,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
def _clean_text(self, text, tags=None): def _clean_text(self, text, tags=None):
return self._normalize(bleach.clean( return self._normalize(bleach.clean(
text, text,
tags=set(tags) if tags else set() tags=tags or []
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')) ).strip().replace('<br>', '<br />').replace('\n', '<br />\n'))
@@ -461,7 +461,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_event(self, canvas): def _draw_event(self, canvas):
def shorten(txt): def shorten(txt):
txt = str(txt) txt = str(txt)
txt = bleach.clean(txt, tags=set()).strip() txt = bleach.clean(txt, tags=[]).strip()
p = Paragraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal']) p = Paragraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height) p_size = p.wrap(self.event_width, self.event_height)
@@ -81,7 +81,7 @@ class Command(BaseCommand):
try: try:
r = receiver(signal=periodic_task, sender=self) r = receiver(signal=periodic_task, sender=self)
except Exception as err: except Exception as err:
if isinstance(err, KeyboardInterrupt): if isinstance(Exception, KeyboardInterrupt):
raise err raise err
if settings.SENTRY_ENABLED: if settings.SENTRY_ENABLED:
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
-13
View File
@@ -37,16 +37,6 @@ class BaseMediaType:
def verbose_name(self): def verbose_name(self):
raise NotImplementedError() raise NotImplementedError()
@property
def icon(self):
"""
This can be:
- The name of a Font Awesome icon to represent this channel type.
- The name of a SVG icon file that is resolvable through the static file system. We recommend to design for a size of 18x14 pixels.
"""
return "circle"
def generate_identifier(self, organizer): def generate_identifier(self, organizer):
if self.medium_created_by_server: if self.medium_created_by_server:
raise NotImplementedError() raise NotImplementedError()
@@ -69,7 +59,6 @@ class BaseMediaType:
class BarcodePlainMediaType(BaseMediaType): class BarcodePlainMediaType(BaseMediaType):
identifier = 'barcode' identifier = 'barcode'
verbose_name = _('Barcode / QR-Code') verbose_name = _('Barcode / QR-Code')
icon = 'qrcode'
medium_created_by_server = True medium_created_by_server = True
supports_giftcard = False supports_giftcard = False
supports_orderposition = True supports_orderposition = True
@@ -86,7 +75,6 @@ class BarcodePlainMediaType(BaseMediaType):
class NfcUidMediaType(BaseMediaType): class NfcUidMediaType(BaseMediaType):
identifier = 'nfc_uid' identifier = 'nfc_uid'
verbose_name = _('NFC UID-based') verbose_name = _('NFC UID-based')
icon = 'pretixbase/img/media/nfc_uid.svg'
medium_created_by_server = False medium_created_by_server = False
supports_giftcard = True supports_giftcard = True
supports_orderposition = False supports_orderposition = False
@@ -126,7 +114,6 @@ class NfcUidMediaType(BaseMediaType):
class NfcMf0aesMediaType(BaseMediaType): class NfcMf0aesMediaType(BaseMediaType):
identifier = 'nfc_mf0aes' identifier = 'nfc_mf0aes'
verbose_name = 'NFC Mifare Ultralight AES' verbose_name = 'NFC Mifare Ultralight AES'
icon = 'pretixbase/img/media/nfc_secure.svg'
medium_created_by_server = False medium_created_by_server = False
supports_giftcard = True supports_giftcard = True
supports_orderposition = False supports_orderposition = False
+1 -1
View File
@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
('password', models.CharField(verbose_name='password', max_length=128)), ('password', models.CharField(verbose_name='password', max_length=128)),
('last_login', models.DateTimeField(verbose_name='last login', blank=True, null=True)), ('last_login', models.DateTimeField(verbose_name='last login', blank=True, null=True)),
('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')), ('is_superuser', models.BooleanField(verbose_name='superuser status', default=False, help_text='Designates that this user has all permissions without explicitly assigning them.')),
('email', models.EmailField(max_length=191, blank=True, unique=True, verbose_name='Email', null=True, ('email', models.EmailField(max_length=191, blank=True, unique=True, verbose_name='E-mail', null=True,
db_index=True)), db_index=True)),
('givenname', models.CharField(verbose_name='Given name', max_length=255, blank=True, null=True)), ('givenname', models.CharField(verbose_name='Given name', max_length=255, blank=True, null=True)),
('familyname', models.CharField(verbose_name='Family name', max_length=255, blank=True, null=True)), ('familyname', models.CharField(verbose_name='Family name', max_length=255, blank=True, null=True)),
@@ -9,7 +9,6 @@ from decimal import Decimal
import django.core.validators import django.core.validators
import django.db.models.deletion import django.db.models.deletion
import i18nfield.fields import i18nfield.fields
from argon2.exceptions import HashingError
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.db import migrations, models from django.db import migrations, models
@@ -26,14 +25,7 @@ def initial_user(apps, schema_editor):
user = User(email='admin@localhost') user = User(email='admin@localhost')
user.is_staff = True user.is_staff = True
user.is_superuser = True user.is_superuser = True
try: user.password = make_password('admin')
user.password = make_password('admin')
except HashingError:
raise Exception(
"Could not hash password of initial user with argon2id. If this is a system with less than 8 CPU cores, "
"you might need to disable argon2id by setting `passwords_argon2=off` in the `[django]` section of the "
"pretix.cfg configuration file."
)
user.save() user.save()
@@ -56,7 +48,7 @@ class Migration(migrations.Migration):
('password', models.CharField(max_length=128, verbose_name='password')), ('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, unique=True, verbose_name='Email')), ('email', models.EmailField(blank=True, db_index=True, max_length=254, null=True, unique=True, verbose_name='E-mail')),
('givenname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Given name')), ('givenname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Given name')),
('familyname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Family name')), ('familyname', models.CharField(blank=True, max_length=255, null=True, verbose_name='Family name')),
('is_active', models.BooleanField(default=True, verbose_name='Is active')), ('is_active', models.BooleanField(default=True, verbose_name='Is active')),
@@ -240,7 +232,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=16, verbose_name='Order code')), ('code', models.CharField(max_length=16, verbose_name='Order code')),
('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')), ('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')), ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail')),
('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')), ('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')),
('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)), ('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)),
('datetime', models.DateTimeField(verbose_name='Date')), ('datetime', models.DateTimeField(verbose_name='Date')),
@@ -187,7 +187,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=16, verbose_name='Order code')), ('code', models.CharField(max_length=16, verbose_name='Order code')),
('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')), ('status', models.CharField(choices=[('n', 'pending'), ('p', 'paid'), ('e', 'expired'), ('c', 'cancelled'), ('r', 'refunded')], max_length=3, verbose_name='Status')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')), ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='E-mail')),
('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')), ('locale', models.CharField(blank=True, max_length=32, null=True, verbose_name='Locale')),
('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)), ('secret', models.CharField(default=pretix.base.models.orders.generate_secret, max_length=32)),
('datetime', models.DateTimeField(verbose_name='Date')), ('datetime', models.DateTimeField(verbose_name='Date')),
@@ -20,7 +20,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')), ('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')),
('email', models.EmailField(max_length=254, verbose_name='Email address')), ('email', models.EmailField(max_length=254, verbose_name='E-mail address')),
('locale', models.CharField(default='en', max_length=190)), ('locale', models.CharField(default='en', max_length=190)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')), ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')),
('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')), ('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')),
@@ -35,7 +35,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')), ('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')),
('email', models.EmailField(max_length=254, verbose_name='Email address')), ('email', models.EmailField(max_length=254, verbose_name='E-mail address')),
('locale', models.CharField(default='en', max_length=190)), ('locale', models.CharField(default='en', max_length=190)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')), ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')),
('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')), ('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')),
@@ -163,7 +163,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action_type', models.CharField(max_length=255)), ('action_type', models.CharField(max_length=255)),
('method', models.CharField(choices=[('mail', 'Email')], max_length=255)), ('method', models.CharField(choices=[('mail', 'E-mail')], max_length=255)),
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
to='pretixbase.Event')), to='pretixbase.Event')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
@@ -21,7 +21,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action_type', models.CharField(max_length=255)), ('action_type', models.CharField(max_length=255)),
('method', models.CharField(choices=[('mail', 'Email')], max_length=255)), ('method', models.CharField(choices=[('mail', 'E-mail')], max_length=255)),
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')), ('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('enabled', models.BooleanField(default=True)), ('enabled', models.BooleanField(default=True)),
@@ -1,48 +0,0 @@
# Generated by Django 4.2.16 on 2024-10-29 15:03
from django.db import migrations
def migrate_autocheckin(apps, schema_editor):
CheckinList = apps.get_model("pretixbase", "CheckinList")
AutoCheckinRule = apps.get_model("autocheckin", "AutoCheckinRule")
for cl in CheckinList.objects.filter(auto_checkin_sales_channels__isnull=False).select_related("event", "event__organizer"):
sales_channels = cl.auto_checkin_sales_channels.all()
all_sales_channels = cl.event.organizer.sales_channels.all()
if "pretix.plugins.autocheckin" not in cl.event.plugins:
cl.event.plugins = cl.event.plugins + ",pretix.plugins.autocheckin"
cl.event.save()
r = AutoCheckinRule.objects.get_or_create(
list=cl,
event=cl.event,
all_products=True,
all_payment_methods=True,
defaults=dict(
mode="placed",
all_sales_channels=len(sales_channels) == len(all_sales_channels),
)
)[0]
if len(sales_channels) != len(all_sales_channels):
r.limit_sales_channels.set(sales_channels)
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0272_printlog"),
("autocheckin", "0001_initial"),
]
operations = [
migrations.RunPython(
migrate_autocheckin,
migrations.RunPython.noop,
),
migrations.RemoveField(
model_name="checkinlist",
name="auto_checkin_sales_channels",
),
]
@@ -1,41 +0,0 @@
# Generated by Django 4.2.8 on 2024-07-02 10:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"pretixbase",
"0273_remove_checkinlist_auto_checkin_sales_channels",
),
]
operations = [
migrations.AddField(
model_name="invoiceline",
name="tax_code",
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name="orderfee",
name="tax_code",
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name="orderposition",
name="tax_code",
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name="taxrule",
name="code",
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name="transaction",
name="tax_code",
field=models.CharField(max_length=190, null=True),
),
]
@@ -1,62 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-01 20:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0274_tax_codes"),
]
operations = [
migrations.CreateModel(
name="CheckoutSession",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("cart_id", models.CharField(max_length=255, unique=True)),
("created", models.DateTimeField(auto_now_add=True)),
("testmode", models.BooleanField(default=False)),
("session_data", models.JSONField(default=dict)),
(
"customer",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="checkout_sessions",
to="pretixbase.customer",
),
),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.event",
),
),
(
"sales_channel",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.saleschannel",
),
),
],
),
migrations.AddField(
model_name="invoiceaddress",
name="checkout_session",
field=models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="invoice_address",
to="pretixbase.checkoutsession",
),
),
]
-3
View File
@@ -256,9 +256,6 @@ class SubeventColumnMixin:
] ]
def clean(self, value, previous_values): def clean(self, value, previous_values):
if not value:
return None
if value in self._subevent_cache: if value in self._subevent_cache:
return self._subevent_cache[value] return self._subevent_cache[value]
+4 -5
View File
@@ -56,7 +56,7 @@ from pretix.base.signals import order_import_columns
class EmailColumn(ImportColumn): class EmailColumn(ImportColumn):
identifier = 'email' identifier = 'email'
verbose_name = gettext_lazy('Email address') verbose_name = gettext_lazy('E-mail address')
def clean(self, value, previous_values): def clean(self, value, previous_values):
if value: if value:
@@ -322,7 +322,7 @@ class AttendeeNamePart(ImportColumn):
class AttendeeEmail(ImportColumn): class AttendeeEmail(ImportColumn):
identifier = 'attendee_email' identifier = 'attendee_email'
verbose_name = gettext_lazy('Attendee email address') verbose_name = gettext_lazy('Attendee e-mail address')
def clean(self, value, previous_values): def clean(self, value, previous_values):
if value: if value:
@@ -441,7 +441,6 @@ class Price(DecimalColumnMixin, ImportColumn):
position.price = p.gross position.price = p.gross
position.tax_rule = position.item.tax_rule position.tax_rule = position.item.tax_rule
position.tax_rate = p.rate position.tax_rate = p.rate
position.tax_code = p.code
position.tax_value = p.tax position.tax_value = p.tax
@@ -585,7 +584,7 @@ class SeatColumn(ImportColumn):
raise ValidationError(_('Multiple matching seats were found.')) raise ValidationError(_('Multiple matching seats were found.'))
except Seat.DoesNotExist: except Seat.DoesNotExist:
raise ValidationError(_('No matching seat was found.')) raise ValidationError(_('No matching seat was found.'))
if not value.is_available(sales_channel=previous_values.get('sales_channel')) or value in self._cached: if not value.is_available() or value in self._cached:
raise ValidationError( raise ValidationError(
_('The seat you selected has already been taken. Please select a different seat.')) _('The seat you selected has already been taken. Please select a different seat.'))
self._cached.add(value) self._cached.add(value)
@@ -754,11 +753,11 @@ def get_order_import_columns(event):
AttendeeState(event), AttendeeState(event),
Price(event), Price(event),
Secret(event), Secret(event),
Saleschannel(event),
SeatColumn(event), SeatColumn(event),
ValidFrom(event), ValidFrom(event),
ValidUntil(event), ValidUntil(event),
Locale(event), Locale(event),
Saleschannel(event),
CheckinAttentionColumn(event), CheckinAttentionColumn(event),
CheckinTextColumn(event), CheckinTextColumn(event),
Expires(event), Expires(event),
+1 -1
View File
@@ -241,7 +241,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
REQUIRED_FIELDS = [] REQUIRED_FIELDS = []
email = models.EmailField(unique=True, db_index=True, null=True, blank=True, email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
verbose_name=_('Email'), max_length=190) verbose_name=_('E-mail'), max_length=190)
fullname = models.CharField(max_length=255, blank=True, null=True, fullname = models.CharField(max_length=255, blank=True, null=True,
verbose_name=_('Full name')) verbose_name=_('Full name'))
is_active = models.BooleanField(default=True, is_active = models.BooleanField(default=True,
+11 -7
View File
@@ -99,6 +99,14 @@ class CheckinList(LoggedModel):
verbose_name=_('Automatically check out everyone at'), verbose_name=_('Automatically check out everyone at'),
null=True, blank=True null=True, blank=True
) )
auto_checkin_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_('Sales channels to automatically check in'),
help_text=_('This option is deprecated and will be removed in the next months. As a replacement, our new plugin '
'"Auto check-in" can be used. When we remove this option, we will automatically migrate your event '
'to use the new plugin.'),
blank=True,
)
rules = models.JSONField(default=dict, blank=True) rules = models.JSONField(default=dict, blank=True)
objects = ScopedManager(organizer='event__organizer') objects = ScopedManager(organizer='event__organizer')
@@ -133,7 +141,7 @@ class CheckinList(LoggedModel):
return self.positions_query(ignore_status=False) return self.positions_query(ignore_status=False)
@scopes_disabled() @scopes_disabled()
def _filter_positions_inside(self, qs, at_time=None): def positions_inside_query(self, ignore_status=False, at_time=None):
if at_time is None: if at_time is None:
c_q = [] c_q = []
else: else:
@@ -141,7 +149,7 @@ class CheckinList(LoggedModel):
if "postgresql" not in settings.DATABASES["default"]["ENGINE"]: if "postgresql" not in settings.DATABASES["default"]["ENGINE"]:
# Use a simple approach that works on all databases # Use a simple approach that works on all databases
qs = qs.annotate( qs = self.positions_query(ignore_status=ignore_status).annotate(
last_entry=Subquery( last_entry=Subquery(
Checkin.objects.filter( Checkin.objects.filter(
*c_q, *c_q,
@@ -194,7 +202,7 @@ class CheckinList(LoggedModel):
.values("position_id", "type", "datetime", "cnt_exists_after") .values("position_id", "type", "datetime", "cnt_exists_after")
.query.sql_with_params() .query.sql_with_params()
) )
return qs.filter( return self.positions_query(ignore_status=ignore_status).filter(
pk__in=RawSQL( pk__in=RawSQL(
f""" f"""
SELECT "position_id" SELECT "position_id"
@@ -206,10 +214,6 @@ class CheckinList(LoggedModel):
) )
) )
@scopes_disabled()
def positions_inside_query(self, ignore_status=False, at_time=None):
return self._filter_positions_inside(self.positions_query(ignore_status=ignore_status), at_time=at_time)
@property @property
def positions_inside(self): def positions_inside(self):
return self.positions_inside_query(None) return self.positions_inside_query(None)
+2 -2
View File
@@ -91,7 +91,7 @@ class Customer(LoggedModel):
), ),
], ],
) )
email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('Email'), max_length=190) email = models.EmailField(db_index=True, null=True, blank=False, verbose_name=_('E-mail'), max_length=190)
phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number')) phone = PhoneNumberField(null=True, blank=True, verbose_name=_('Phone number'))
password = models.CharField(verbose_name=_('Password'), max_length=128) password = models.CharField(verbose_name=_('Password'), max_length=128)
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True) name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
@@ -392,7 +392,7 @@ class CustomerSSOClient(LoggedModel):
SCOPE_CHOICES = ( SCOPE_CHOICES = (
('openid', _('OpenID Connect access (required)')), ('openid', _('OpenID Connect access (required)')),
('profile', _('Profile data (name, addresses)')), ('profile', _('Profile data (name, addresses)')),
('email', _('Email address')), ('email', _('E-mail address')),
('phone', _('Phone number')), ('phone', _('Phone number')),
) )
+4 -4
View File
@@ -823,9 +823,6 @@ class Event(EventMixin, LoggedModel):
self.save() self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk}) self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
if hasattr(other, 'alternative_domain_assignment'):
other.alternative_domain_assignment.domain.event_assignments.create(event=self)
if not self.all_sales_channels: if not self.all_sales_channels:
self.limit_sales_channels.set( self.limit_sales_channels.set(
self.organizer.sales_channels.filter( self.organizer.sales_channels.filter(
@@ -1027,9 +1024,10 @@ class Event(EventMixin, LoggedModel):
checkin_list_map = {} checkin_list_map = {}
for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related( for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related(
'limit_products' 'limit_products', 'auto_checkin_sales_channels'
): ):
items = list(cl.limit_products.all()) items = list(cl.limit_products.all())
auto_checkin_sales_channels = list(cl.auto_checkin_sales_channels.all())
checkin_list_map[cl.pk] = cl checkin_list_map[cl.pk] = cl
cl.pk = None cl.pk = None
cl._prefetched_objects_cache = {} cl._prefetched_objects_cache = {}
@@ -1041,6 +1039,8 @@ class Event(EventMixin, LoggedModel):
cl.log_action('pretix.object.cloned') cl.log_action('pretix.object.cloned')
for i in items: for i in items:
cl.limit_products.add(item_map[i.pk]) 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:
if other.seating_plan.organizer_id == self.organizer_id: if other.seating_plan.organizer_id == self.organizer_id:
-1
View File
@@ -362,7 +362,6 @@ class InvoiceLine(models.Model):
tax_value = models.DecimalField(max_digits=13, decimal_places=2, default=Decimal('0.00')) tax_value = models.DecimalField(max_digits=13, decimal_places=2, default=Decimal('0.00'))
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00')) tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
tax_name = models.CharField(max_length=190) tax_name = models.CharField(max_length=190)
tax_code = models.CharField(max_length=190, null=True, blank=True)
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT) subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
event_date_from = models.DateTimeField(null=True) event_date_from = models.DateTimeField(null=True)
event_date_to = models.DateTimeField(null=True) event_date_to = models.DateTimeField(null=True)
+2 -4
View File
@@ -837,7 +837,7 @@ class Item(LoggedModel):
if not self.tax_rule: if not self.tax_rule:
t = TaxedPrice(gross=price - bundled_sum, net=price - bundled_sum, tax=Decimal('0.00'), t = TaxedPrice(gross=price - bundled_sum, net=price - bundled_sum, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='', code=None) rate=Decimal('0.00'), name='')
else: else:
t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address, t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address,
override_tax_rate=override_tax_rate, currency=currency or self.event.currency, override_tax_rate=override_tax_rate, currency=currency or self.event.currency,
@@ -845,7 +845,6 @@ class Item(LoggedModel):
if bundled_sum: if bundled_sum:
t.name = "MIXED!" t.name = "MIXED!"
t.code = None
t.gross += bundled_sum t.gross += bundled_sum
t.net += bundled_sum_net t.net += bundled_sum_net
t.tax += bundled_sum_tax t.tax += bundled_sum_tax
@@ -1259,7 +1258,7 @@ class ItemVariation(models.Model):
if not self.item.tax_rule: if not self.item.tax_rule:
t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'),
rate=Decimal('0.00'), name='', code=None) rate=Decimal('0.00'), name='')
else: else:
t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency, t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency,
override_tax_rate=override_tax_rate, override_tax_rate=override_tax_rate,
@@ -1281,7 +1280,6 @@ class ItemVariation(models.Model):
t.net += bprice.net - compare_price.net t.net += bprice.net - compare_price.net
t.tax += bprice.tax - compare_price.tax t.tax += bprice.tax - compare_price.tax
t.name = "MIXED!" t.name = "MIXED!"
t.code = None
return t return t
-14
View File
@@ -159,24 +159,10 @@ class Membership(models.Model):
de = date_format(self.date_end, 'SHORT_DATE_FORMAT') de = date_format(self.date_end, 'SHORT_DATE_FORMAT')
return f'{self.membership_type.name}: {self.attendee_name} ({ds} {de})' return f'{self.membership_type.name}: {self.attendee_name} ({ds} {de})'
@property
def percentage_used(self):
if self.membership_type.max_usages and self.usages:
return int(self.usages / self.membership_type.max_usages * 100)
return 0
@property @property
def attendee_name(self): def attendee_name(self):
return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme) return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme)
@property
def expired(self):
return time_machine_now() > self.date_end
@property
def not_yet_valid(self):
return time_machine_now() < self.date_start
def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False): def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False):
if valid_from_not_chosen: if valid_from_not_chosen:
return not self.canceled and self.date_end >= time_machine_now() return not self.canceled and self.date_end >= time_machine_now()
+1 -1
View File
@@ -43,7 +43,7 @@ class NotificationSetting(models.Model):
:type enabled: bool :type enabled: bool
""" """
CHANNELS = ( CHANNELS = (
('mail', _('Email')), ('mail', _('E-mail')),
) )
user = models.ForeignKey('User', on_delete=models.CASCADE, user = models.ForeignKey('User', on_delete=models.CASCADE,
related_name='notification_settings') related_name='notification_settings')
+11 -128
View File
@@ -55,17 +55,16 @@ from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ( from django.db.models import (
Case, Exists, F, Max, OuterRef, Prefetch, Q, Subquery, Sum, Value, When, Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
) )
from django.db.models.functions import Coalesce, Greatest from django.db.models.functions import Coalesce, Greatest
from django.db.models.signals import post_delete from django.db.models.signals import post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils.crypto import get_random_string, salted_hmac from django.utils.crypto import get_random_string, salted_hmac
from django.utils.encoding import escape_uri_path, force_str from django.utils.encoding import escape_uri_path
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.hashable import make_hashable
from django.utils.timezone import get_current_timezone, make_aware, now from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries.fields import Country from django_countries.fields import Country
@@ -243,7 +242,7 @@ class Order(LockModel, LoggedModel):
) )
email = models.EmailField( email = models.EmailField(
null=True, blank=True, null=True, blank=True,
verbose_name=_('Email') verbose_name=_('E-mail')
) )
phone = PhoneNumberField( phone = PhoneNumberField(
null=True, blank=True, null=True, blank=True,
@@ -318,7 +317,7 @@ class Order(LockModel, LoggedModel):
) )
email_known_to_work = models.BooleanField( email_known_to_work = models.BooleanField(
default=False, default=False,
verbose_name=_('Email address verified') verbose_name=_('E-mail address verified')
) )
invoice_dirty = models.BooleanField( invoice_dirty = models.BooleanField(
# Invoice needs to be re-issued when the order is paid again # Invoice needs to be re-issued when the order is paid again
@@ -1257,7 +1256,7 @@ class Order(LockModel, LoggedModel):
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys()) keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
create = [] create = []
for k in keys: for k in keys:
positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype, taxcode = k positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype = k
d = target_transaction_count[k] - current_transaction_count[k] d = target_transaction_count[k] - current_transaction_count[k]
if d: if d:
create.append(Transaction( create.append(Transaction(
@@ -1273,7 +1272,6 @@ class Order(LockModel, LoggedModel):
tax_rate=taxrate, tax_rate=taxrate,
tax_rule_id=taxruleid, tax_rule_id=taxruleid,
tax_value=taxvalue, tax_value=taxvalue,
tax_code=taxcode,
fee_type=feetype, fee_type=feetype,
internal_type=internaltype, internal_type=internaltype,
)) ))
@@ -2277,7 +2275,6 @@ class OrderFee(models.Model):
FEE_TYPE_SERVICE = "service" FEE_TYPE_SERVICE = "service"
FEE_TYPE_CANCELLATION = "cancellation" FEE_TYPE_CANCELLATION = "cancellation"
FEE_TYPE_INSURANCE = "insurance" FEE_TYPE_INSURANCE = "insurance"
FEE_TYPE_LATE = "late"
FEE_TYPE_OTHER = "other" FEE_TYPE_OTHER = "other"
FEE_TYPE_GIFTCARD = "giftcard" FEE_TYPE_GIFTCARD = "giftcard"
FEE_TYPES = ( FEE_TYPES = (
@@ -2286,7 +2283,6 @@ class OrderFee(models.Model):
(FEE_TYPE_SERVICE, _("Service fee")), (FEE_TYPE_SERVICE, _("Service fee")),
(FEE_TYPE_CANCELLATION, _("Cancellation fee")), (FEE_TYPE_CANCELLATION, _("Cancellation fee")),
(FEE_TYPE_INSURANCE, _("Insurance fee")), (FEE_TYPE_INSURANCE, _("Insurance fee")),
(FEE_TYPE_LATE, _("Late fee")),
(FEE_TYPE_OTHER, _("Other fees")), (FEE_TYPE_OTHER, _("Other fees")),
(FEE_TYPE_GIFTCARD, _("Gift card")), (FEE_TYPE_GIFTCARD, _("Gift card")),
) )
@@ -2315,10 +2311,6 @@ class OrderFee(models.Model):
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, blank=True null=True, blank=True
) )
tax_code = models.CharField(
max_length=190,
null=True, blank=True,
)
tax_value = models.DecimalField( tax_value = models.DecimalField(
max_digits=13, decimal_places=2, max_digits=13, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
@@ -2346,16 +2338,6 @@ class OrderFee(models.Model):
self._transaction_key_reset() self._transaction_key_reset()
return super().refresh_from_db(using, fields) return super().refresh_from_db(using, fields)
def get_tax_code_display(self):
from pretix.base.models.tax import get_tax_code_labels
if self.tax_code:
choices_dict = get_tax_code_labels()
return force_str(
choices_dict.get(make_hashable(self.tax_code), self.tax_code), strings_only=True
)
return ""
def _transaction_key_reset(self): def _transaction_key_reset(self):
self.__initial_transaction_key = Transaction.key(self) self.__initial_transaction_key = Transaction.key(self)
self.__initial_canceled = self.canceled self.__initial_canceled = self.canceled
@@ -2386,11 +2368,9 @@ class OrderFee(models.Model):
if self.tax_rule: if self.tax_rule:
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True) tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
self.tax_rate = tax.rate self.tax_rate = tax.rate
self.tax_code = tax.code
self.tax_value = tax.tax self.tax_value = tax.tax
else: else:
self.tax_value = Decimal('0.00') self.tax_value = Decimal('0.00')
self.tax_code = None
self.tax_rate = Decimal('0.00') self.tax_rate = Decimal('0.00')
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@@ -2399,7 +2379,6 @@ class OrderFee(models.Model):
if self.tax_rate is None: if self.tax_rate is None:
self._calculate_tax() self._calculate_tax()
self.order.touch() self.order.touch()
if not self.get_deferred_fields(): if not self.get_deferred_fields():
@@ -2487,10 +2466,6 @@ class OrderPosition(AbstractPosition):
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, blank=True null=True, blank=True
) )
tax_code = models.CharField(
max_length=190,
null=True, blank=True,
)
tax_value = models.DecimalField( tax_value = models.DecimalField(
max_digits=13, decimal_places=2, max_digits=13, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
@@ -2548,16 +2523,6 @@ class OrderPosition(AbstractPosition):
models.UniqueConstraint("organizer", "secret", name="orderposition_organizer_secret_uniq") models.UniqueConstraint("organizer", "secret", name="orderposition_organizer_secret_uniq")
] ]
def get_tax_code_display(self):
from pretix.base.models.tax import get_tax_code_labels
if self.tax_code:
choices_dict = get_tax_code_labels()
return force_str(
choices_dict.get(make_hashable(self.tax_code), self.tax_code), strings_only=True
)
return ""
@cached_property @cached_property
def sort_key(self): def sort_key(self):
return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0, self.positionid return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0, self.positionid
@@ -2730,13 +2695,11 @@ class OrderPosition(AbstractPosition):
if self.tax_rule: if self.tax_rule:
tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross', force_fixed_gross_price=True) tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross', force_fixed_gross_price=True)
self.tax_rate = tax.rate self.tax_rate = tax.rate
self.tax_code = tax.code
self.tax_value = tax.tax self.tax_value = tax.tax
if tax.gross != self.price: if tax.gross != self.price:
raise ValueError('Invalid tax calculation') raise ValueError('Invalid tax calculation')
else: else:
self.tax_value = Decimal('0.00') self.tax_value = Decimal('0.00')
self.tax_code = None
self.tax_rate = Decimal('0.00') self.tax_rate = Decimal('0.00')
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@@ -3007,10 +2970,6 @@ class Transaction(models.Model):
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, blank=True null=True, blank=True
) )
tax_code = models.CharField(
max_length=190,
null=True, blank=True,
)
tax_value = models.DecimalField( tax_value = models.DecimalField(
max_digits=13, decimal_places=2, max_digits=13, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
@@ -3031,27 +2990,17 @@ class Transaction(models.Model):
raise ValidationError('Should set either item or fee type') raise ValidationError('Should set either item or fee type')
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def get_tax_code_display(self):
from pretix.base.models.tax import get_tax_code_labels
if self.tax_code:
choices_dict = get_tax_code_labels()
return force_str(
choices_dict.get(make_hashable(self.tax_code), self.tax_code), strings_only=True
)
return ""
@staticmethod @staticmethod
def key(obj): def key(obj):
if isinstance(obj, Transaction): if isinstance(obj, Transaction):
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate, return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code) obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type)
elif isinstance(obj, OrderPosition): elif isinstance(obj, OrderPosition):
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate, return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
obj.tax_rule_id, obj.tax_value, None, None, obj.tax_code) obj.tax_rule_id, obj.tax_value, None, None)
elif isinstance(obj, OrderFee): elif isinstance(obj, OrderFee):
return (None, None, None, None, obj.value, obj.tax_rate, return (None, None, None, None, obj.value, obj.tax_rate,
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code) obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type)
raise ValueError('invalid state') # noqa raise ValueError('invalid state') # noqa
@property @property
@@ -3063,64 +3012,6 @@ class Transaction(models.Model):
return self.tax_value * self.count return self.tax_value * self.count
class CheckoutSession(models.Model):
"""
A checkout session optionally bundles cart positions with additional information. This is historically
not required in pretix and currently only used in the Storefront API.
"""
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
related_name="checkout_sessions",
on_delete=models.CASCADE,
)
cart_id = models.CharField(
max_length=255, unique=True,
verbose_name=_("Cart ID (e.g. session key)"),
)
created = models.DateTimeField(
verbose_name=_("Date"),
auto_now_add=True,
)
customer = models.ForeignKey(
Customer,
related_name='checkout_sessions',
null=True, blank=True,
on_delete=models.SET_NULL,
)
sales_channel = models.ForeignKey(
"SalesChannel",
on_delete=models.CASCADE,
)
testmode = models.BooleanField(default=False)
session_data = models.JSONField(default=dict)
def get_cart_positions(self, prefetch_questions=False):
qs = CartPosition.objects.filter(event=self.event, cart_id=self.cart_id).select_related(
"item", "variation", "subevent",
)
if prefetch_questions:
qqs = self.event.questions.filter(ask_during_checkin=False, hidden=False)
qs = qs.prefetch_related(
Prefetch("answers",
QuestionAnswer.objects.prefetch_related("options"),
to_attr="answerlist"),
Prefetch("item__questions",
qqs.prefetch_related(
Prefetch("options", QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
"question",
Question.objects.none(),
to_attr="dummy"
)))
).select_related("dependency_question"),
to_attr="questions_to_ask")
)
return qs
class CartPosition(AbstractPosition): class CartPosition(AbstractPosition):
""" """
A cart position is similar to an order line, except that it is not A cart position is similar to an order line, except that it is not
@@ -3273,7 +3164,6 @@ class CartPosition(AbstractPosition):
if line_price.gross != self.line_price_gross or line_price.rate != self.tax_rate: if line_price.gross != self.line_price_gross or line_price.rate != self.tax_rate:
self.line_price_gross = line_price.gross self.line_price_gross = line_price.gross
self.tax_rate = line_price.rate self.tax_rate = line_price.rate
self.tax_code = line_price.code
self.save(update_fields=['line_price_gross', 'tax_rate']) self.save(update_fields=['line_price_gross', 'tax_rate'])
@property @property
@@ -3303,13 +3193,6 @@ class CartPosition(AbstractPosition):
class InvoiceAddress(models.Model): class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True) last_modified = models.DateTimeField(auto_now=True)
checkout_session = models.OneToOneField(
CheckoutSession,
null=True,
blank=True,
related_name='invoice_address',
on_delete=models.CASCADE
)
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE) order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
customer = models.ForeignKey( customer = models.ForeignKey(
Customer, Customer,
@@ -3321,9 +3204,9 @@ class InvoiceAddress(models.Model):
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name')) company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True) name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
name_parts = models.JSONField(default=dict) name_parts = models.JSONField(default=dict)
street = models.TextField(verbose_name=_('Address'), blank=True) street = models.TextField(verbose_name=_('Address'), blank=False)
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True) zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False)
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True) city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False) country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'), country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
countries=CachedCountries) countries=CachedCountries)
+12 -220
View File
@@ -21,7 +21,6 @@
# #
import json import json
from decimal import Decimal from decimal import Decimal
from typing import Optional
import jsonschema import jsonschema
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
@@ -31,9 +30,8 @@ from django.db import models
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from django.utils.formats import localize from django.utils.formats import localize
from django.utils.functional import lazy from django.utils.functional import lazy
from django.utils.hashable import make_hashable
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext
from i18nfield.fields import I18nCharField from i18nfield.fields import I18nCharField
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
@@ -44,7 +42,7 @@ from pretix.helpers.countries import FastCountryField
class TaxedPrice: class TaxedPrice:
def __init__(self, *, gross: Decimal, net: Decimal, tax: Decimal, rate: Decimal, name: str, code: Optional[str]): def __init__(self, *, gross: Decimal, net: Decimal, tax: Decimal, rate: Decimal, name: str):
if net + tax != gross: if net + tax != gross:
raise ValueError('Net value and tax value need to add to the gross value') raise ValueError('Net value and tax value need to add to the gross value')
self.gross = gross self.gross = gross
@@ -52,7 +50,6 @@ class TaxedPrice:
self.tax = tax self.tax = tax
self.rate = rate self.rate = rate
self.name = name self.name = name
self.code = code
def __repr__(self): def __repr__(self):
return '{} + {}% = {}'.format(localize(self.net), localize(self.rate), localize(self.gross)) return '{} + {}% = {}'.format(localize(self.net), localize(self.rate), localize(self.gross))
@@ -75,7 +72,6 @@ class TaxedPrice:
tax=newgross - newnet, tax=newgross - newnet,
rate=self.rate, rate=self.rate,
name=self.name, name=self.name,
code=self.code,
) )
def __mul__(self, other): def __mul__(self, other):
@@ -89,7 +85,6 @@ class TaxedPrice:
tax=newgross - newnet, tax=newgross - newnet,
rate=self.rate, rate=self.rate,
name=self.name, name=self.name,
code=self.code,
) )
def __eq__(self, other): def __eq__(self, other):
@@ -98,8 +93,7 @@ class TaxedPrice:
self.net == other.net and self.net == other.net and
self.tax == other.tax and self.tax == other.tax and
self.rate == other.rate and self.rate == other.rate and
self.name == other.name and self.name == other.name
self.code == other.code
) )
@@ -108,8 +102,7 @@ TAXED_ZERO = TaxedPrice(
net=Decimal('0.00'), net=Decimal('0.00'),
tax=Decimal('0.00'), tax=Decimal('0.00'),
rate=Decimal('0.00'), rate=Decimal('0.00'),
name='', name=''
code=None,
) )
EU_COUNTRIES = { EU_COUNTRIES = {
@@ -132,152 +125,6 @@ VAT_ID_COUNTRIES = EU_COUNTRIES | {'CH', 'NO'}
format_html_lazy = lazy(format_html, str) format_html_lazy = lazy(format_html, str)
TAX_CODE_LISTS = (
# Sources:
# https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists#RegistryofsupportingartefactstoimplementEN16931-Codelists
# https://docs.peppol.eu/poacc/billing/3.0/codelist/vatex/
# https://docs.peppol.eu/poacc/billing/3.0/codelist/UNCL5305/
# https://www.bzst.de/DE/Unternehmen/Aussenpruefungen/DigitaleSchnittstelleFinV/digitaleschnittstellefinv_node.html#js-toc-entry2
#
# !! When changed, also update tax-rules-custom.schema.json and doc/api/resources/taxrules.rst !!
(
_("Standard rates"),
(
# Standard rate in any country, such as 19% in Germany or 20% in Austria
# DSFinV-K mapping: 1
("S/standard", pgettext_lazy("tax_code", "Standard rate")),
# Reduced rate in any country, such as 7% in Germany or both 10% and 13% in Austria
# DSFinV-K mapping: 2
("S/reduced", pgettext_lazy("tax_code", "Reduced rate")),
# Averaged rate, for example Germany § 24 (1) Nr. 3 UStG "für die übrigen Umsätze" in agricultural and silvicultural businesses
# DSFinV-K mapping: 3
("S/averaged", pgettext_lazy("tax_code", "Averaged rate (other revenue in a agricultural and silvicultural business)")),
# We ignore the German special case of the actual silvicultural products as they won't be sold through pretix (DSFinV-K mapping: 4)
)
),
(
_("Reverse charge"),
(
("AE", pgettext_lazy("tax_code", "Reverse charge")),
)
),
(
_("Tax free"),
(
# DSFinV-K mapping: 5
("O", pgettext_lazy("tax_code", "Services outside of scope of tax")),
# DSFinV-K mapping: 6
("E", pgettext_lazy("tax_code", "Exempt from tax (no reason given)")),
# DSFinV-K mapping: 6
("Z", pgettext_lazy("tax_code", "Zero-rated goods")),
# DSFinV-K mapping: 5
("G", pgettext_lazy("tax_code", "Free export item, VAT not charged")),
# DSFinV-K mapping: 6?
("K", pgettext_lazy("tax_code", "VAT exempt for EEA intra-community supply of goods and services")),
)
),
(
_("Special cases"),
(
("L", pgettext_lazy("tax_code", "Canary Islands general indirect tax")),
("M", pgettext_lazy("tax_code", "Tax for production, services and importation in Ceuta and Melilla")),
("B", pgettext_lazy("tax_code", "Transferred (VAT), only in Italy")),
)
),
(
_("Exempt with specific reason"),
(
("E/VATEX-EU-79-C",
pgettext_lazy("tax_code", "Exempt based on article 79, point c of Council Directive 2006/112/EC")),
*[
(
f"E/VATEX-EU-132-1{letter.upper()}",
lazy(
lambda let: pgettext(
"tax_code",
"Exempt based on article {article}, section {section} ({letter}) of Council "
"Directive 2006/112/EC"
).format(article="132", section="1", letter=let),
str
)(letter)
) for letter in ("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q")
],
*[
(
f"E/VATEX-EU-143-1{letter.upper()}",
lazy(
lambda let: pgettext(
"tax_code",
"Exempt based on article {article}, section {section} ({letter}) of Council "
"Directive 2006/112/EC"
).format(article="143", section="1", letter=let),
str
)(letter)
) for letter in ("a", "b", "c", "d", "e", "f", "fa", "g", "h", "i", "j", "k", "l")
],
*[
(
f"E/VATEX-EU-148-{letter.upper()}",
lazy(
lambda let: pgettext(
"tax_code",
"Exempt based on article {article}, section ({letter}) of Council "
"Directive 2006/112/EC"
).format(article="148", letter=let),
str
)(letter)
) for letter in ("a", "b", "c", "d", "e", "f", "g")
],
*[
(
f"E/VATEX-EU-151-1{letter.upper()}",
lazy(
lambda let: pgettext(
"tax_code",
"Exempt based on article {article}, section {section} ({letter}) of Council "
"Directive 2006/112/EC"
).format(article="151", section="1", letter=let),
str
)(letter)
) for letter in ("a", "aa", "b", "c", "d", "e")
],
("E/VATEX-EU-309",
pgettext_lazy("tax_code", "Exempt based on article 309 of Council Directive 2006/112/EC")),
("E/VATEX-EU-D",
pgettext_lazy("tax_code", "Intra-Community acquisition from second hand means of transport")),
("E/VATEX-EU-F",
pgettext_lazy("tax_code", "Intra-Community acquisition of second hand goods")),
("E/VATEX-EU-I",
pgettext_lazy("tax_code", "Intra-Community acquisition of works of art")),
("E/VATEX-EU-J",
pgettext_lazy("tax_code", "Intra-Community acquisition of collectors items and antiques")),
("E/VATEX-FR-FRANCHISE",
pgettext_lazy("tax_code", "France domestic VAT franchise in base")),
("E/VATEX-FR-CNWVAT",
pgettext_lazy("tax_code", "France domestic Credit Notes without VAT, due to supplier forfeit of VAT for discount")),
)
),
)
def get_tax_code_labels():
flat = []
for choice, value in TAX_CODE_LISTS:
if isinstance(value, (list, tuple)):
flat.extend(value)
else:
flat.append((choice, value))
return dict(make_hashable(flat))
def is_eu_country(cc): def is_eu_country(cc):
cc = str(cc) cc = str(cc)
return cc in EU_COUNTRIES return cc in EU_COUNTRIES
@@ -326,14 +173,6 @@ class TaxRule(LoggedModel):
help_text=_('Should be short, e.g. "VAT"'), help_text=_('Should be short, e.g. "VAT"'),
max_length=190, max_length=190,
) )
code = models.CharField(
verbose_name=_('Tax code'),
help_text=_('If you help us understand what this tax rules legally is, we can use this information for '
'eInvoices, exporting to accounting system, etc.'),
null=True, blank=True,
max_length=190,
choices=TAX_CODE_LISTS,
)
rate = models.DecimalField( rate = models.DecimalField(
max_digits=10, max_digits=10,
decimal_places=2, decimal_places=2,
@@ -411,16 +250,6 @@ class TaxRule(LoggedModel):
if self.eu_reverse_charge and not self.home_country: if self.eu_reverse_charge and not self.home_country:
raise ValidationError(_('You need to set your home country to use the reverse charge feature.')) raise ValidationError(_('You need to set your home country to use the reverse charge feature.'))
if self.rate != Decimal("0.00") and self.code and (self.code.split("/")[0] in ("O", "E", "Z", "G", "K", "AE")):
raise ValidationError({
"code": _("A combination of this tax code with a non-zero tax rate does not make sense.")
})
if self.rate == Decimal("0.00") and self.code and (self.code.split("/")[0] in ("S", "L", "M", "B")):
raise ValidationError({
"code": _("A combination of this tax code with a zero tax rate does not make sense.")
})
def __str__(self): def __str__(self):
if self.price_includes_tax: if self.price_includes_tax:
s = _('incl. {rate}% {name}').format(rate=self.rate, name=self.name) s = _('incl. {rate}% {name}').format(rate=self.rate, name=self.name)
@@ -447,9 +276,8 @@ class TaxRule(LoggedModel):
return Decimal(rule.get('rate')) return Decimal(rule.get('rate'))
return Decimal(self.rate) return Decimal(self.rate)
def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, override_tax_code=None, def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, invoice_address=None,
invoice_address=None, subtract_from_gross=Decimal('0.00'), gross_price_is_tax_rate: Decimal = None, subtract_from_gross=Decimal('0.00'), gross_price_is_tax_rate: Decimal = None, force_fixed_gross_price=False):
force_fixed_gross_price=False):
from .event import Event from .event import Event
try: try:
currency = currency or self.event.currency currency = currency or self.event.currency
@@ -457,13 +285,6 @@ class TaxRule(LoggedModel):
pass pass
rate = Decimal(self.rate) rate = Decimal(self.rate)
code = self.code
if override_tax_code is not None:
code = override_tax_code
elif invoice_address:
code = self.tax_code_for(invoice_address)
if override_tax_rate is not None: if override_tax_rate is not None:
rate = override_tax_rate rate = override_tax_rate
elif invoice_address: elif invoice_address:
@@ -496,8 +317,11 @@ class TaxRule(LoggedModel):
if rate == Decimal('0.00'): if rate == Decimal('0.00'):
gross = _limit_subtract(base_price, subtract_from_gross) gross = _limit_subtract(base_price, subtract_from_gross)
return TaxedPrice( return TaxedPrice(
net=gross, gross=gross, tax=Decimal('0.00'), net=gross,
rate=rate, name=self.name, code=code, gross=gross,
tax=Decimal('0.00'),
rate=rate,
name=self.name,
) )
if base_price_is == 'auto': if base_price_is == 'auto':
@@ -522,7 +346,7 @@ class TaxRule(LoggedModel):
return TaxedPrice( return TaxedPrice(
net=net, gross=gross, tax=gross - net, net=net, gross=gross, tax=gross - net,
rate=rate, name=self.name, code=code, rate=rate, name=self.name
) )
@property @property
@@ -603,38 +427,6 @@ class TaxRule(LoggedModel):
return True return True
return False return False
def tax_code_for(self, invoice_address):
if self._custom_rules:
rule = self.get_matching_rule(invoice_address)
if rule.get("code"):
return rule["code"]
if rule.get("action", "vat") == "reverse":
return "AE"
return self.code
if not self.eu_reverse_charge:
# No reverse charge rules? Always apply VAT!
return self.code
if not invoice_address or not invoice_address.country:
# No country specified? Always apply VAT!
return self.code
if not is_eu_country(invoice_address.country):
# Non-EU country? "Non-taxable" since not in scope
return "O"
if invoice_address.country == self.home_country:
# Within same EU country? Always apply VAT!
return self.code
if invoice_address.is_business and invoice_address.vat_id and invoice_address.vat_id_validated:
# Reverse charge case
return "AE"
# Consumer in different EU country / invalid VAT
return self.code
def _tax_applicable(self, invoice_address): def _tax_applicable(self, invoice_address):
if self._custom_rules: if self._custom_rules:
rule = self.get_matching_rule(invoice_address) rule = self.get_matching_rule(invoice_address)
+1 -1
View File
@@ -73,7 +73,7 @@ class WaitingListEntry(LoggedModel):
blank=True, default=dict blank=True, default=dict
) )
email = models.EmailField( email = models.EmailField(
verbose_name=_("Email address") verbose_name=_("E-mail address")
) )
phone = PhoneNumberField( phone = PhoneNumberField(
null=True, blank=True, null=True, blank=True,
+33 -60
View File
@@ -722,10 +722,6 @@ class BasePaymentProvider:
""" """
return "" return ""
def storefrontapi_prepare(self, session_data, total, info):
# TODO: docstring
return True
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]: def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]:
""" """
Will be called after the user selects this provider as their payment method. Will be called after the user selects this provider as their payment method.
@@ -1423,73 +1419,50 @@ class GiftCardPayment(BasePaymentProvider):
def payment_refund_supported(self, payment: OrderPayment) -> bool: def payment_refund_supported(self, payment: OrderPayment) -> bool:
return True return True
def _add_giftcard_to_cart(self, cs, gc):
from pretix.base.services.cart import add_payment_to_cart_session
if gc.currency != self.event.currency:
raise ValidationError(_("This gift card does not support this currency."))
if gc.testmode and not self.event.testmode:
raise ValidationError(_("This gift card can only be used in test mode."))
if not gc.testmode and self.event.testmode:
raise ValidationError(_("Only test gift cards can be used in test mode."))
if gc.expires and gc.expires < time_machine_now():
raise ValidationError(_("This gift card is no longer valid."))
if gc.value <= Decimal("0.00"):
raise ValidationError(_("All credit on this gift card has been used."))
for p in cs.get('payments', []):
if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk:
raise ValidationError(_("This gift card is already used for your payment."))
add_payment_to_cart_session(
cs,
self,
max_value=gc.value,
info_data={
'gift_card': gc.pk,
'gift_card_secret': gc.secret,
}
)
def storefrontapi_prepare(self, session_data, total, info):
# todo: validate gift card not paid with gift card
try:
gc = self.event.organizer.accepted_gift_cards.get(
secret=info.get("giftcard").strip()
)
try:
self._add_giftcard_to_cart(session_data, gc)
return True
except ValidationError as e:
raise PaymentException(str(e.message))
except GiftCard.DoesNotExist:
if self.event.vouchers.filter(code__iexact=info.get("giftcard")).exists():
raise PaymentException(
_("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
"the product selection.")
)
else:
raise PaymentException(_("This gift card is not known."))
except GiftCard.MultipleObjectsReturned:
raise PaymentException(_("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event."))
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]: def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]:
from pretix.base.services.cart import add_payment_to_cart
for p in get_cart(request): for p in get_cart(request):
if p.item.issue_giftcard: if p.item.issue_giftcard:
messages.error(request, _("You cannot pay with gift cards when buying a gift card.")) messages.error(request, _("You cannot pay with gift cards when buying a gift card."))
return return
cs = cart_session(request)
try: try:
gc = self.event.organizer.accepted_gift_cards.get( gc = self.event.organizer.accepted_gift_cards.get(
secret=request.POST.get("giftcard").strip() secret=request.POST.get("giftcard").strip()
) )
cs = cart_session(request) if gc.currency != self.event.currency:
try: messages.error(request, _("This gift card does not support this currency."))
self._add_giftcard_to_cart(cs, gc)
return True
except ValidationError as e:
messages.error(request, str(e.message))
return return
if gc.testmode and not self.event.testmode:
messages.error(request, _("This gift card can only be used in test mode."))
return
if not gc.testmode and self.event.testmode:
messages.error(request, _("Only test gift cards can be used in test mode."))
return
if gc.expires and gc.expires < time_machine_now():
messages.error(request, _("This gift card is no longer valid."))
return
if gc.value <= Decimal("0.00"):
messages.error(request, _("All credit on this gift card has been used."))
return
for p in cs.get('payments', []):
if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk:
messages.error(request, _("This gift card is already used for your payment."))
return
add_payment_to_cart(
request,
self,
max_value=gc.value,
info_data={
'gift_card': gc.pk,
'gift_card_secret': gc.secret,
}
)
return True
except GiftCard.DoesNotExist: except GiftCard.DoesNotExist:
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists(): if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists():
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below " messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
+10 -27
View File
@@ -343,13 +343,11 @@ class CartManager:
err = error_messages['some_subevent_not_started'] err = error_messages['some_subevent_not_started']
cp.addons.all().delete() cp.addons.all().delete()
cp.delete() cp.delete()
continue
if cp.subevent and cp.subevent.presale_end and time_machine_now(self.real_now_dt) > cp.subevent.presale_end: if cp.subevent and cp.subevent.presale_end and time_machine_now(self.real_now_dt) > cp.subevent.presale_end:
err = error_messages['some_subevent_ended'] err = error_messages['some_subevent_ended']
cp.addons.all().delete() cp.addons.all().delete()
cp.delete() cp.delete()
continue
if cp.subevent: if cp.subevent:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
@@ -362,7 +360,6 @@ class CartManager:
err = error_messages['some_subevent_ended'] err = error_messages['some_subevent_ended']
cp.addons.all().delete() cp.addons.all().delete()
cp.delete() cp.delete()
continue
return err return err
def _update_subevents_cache(self, se_ids: List[int]): def _update_subevents_cache(self, se_ids: List[int]):
@@ -1426,28 +1423,6 @@ class CartManager:
raise CartError(err) raise CartError(err)
def add_payment_to_cart_session(cart_session, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
"""
:param cart_session: The current cart session.
:param provider: The instance of your payment provider.
:param min_value: The minimum value this payment instrument supports, or ``None`` for unlimited.
:param max_value: The maximum value this payment instrument supports, or ``None`` for unlimited. Highly discouraged
to use for payment providers which charge a payment fee, as this can be very user-unfriendly if
users need a second payment method just for the payment fee of the first method.
:param info_data: A dictionary of information that will be passed through to the ``OrderPayment.info_data`` attribute.
:return:
"""
cart_session.setdefault('payments', [])
cart_session['payments'].append({
'id': str(uuid.uuid4()),
'provider': provider.identifier,
'multi_use_supported': provider.multi_use_supported,
'min_value': str(min_value) if min_value is not None else None,
'max_value': str(max_value) if max_value is not None else None,
'info_data': info_data or {},
})
def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None): def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
""" """
:param request: The current HTTP request context. :param request: The current HTTP request context.
@@ -1462,7 +1437,16 @@ def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: D
from pretix.presale.views.cart import cart_session from pretix.presale.views.cart import cart_session
cs = cart_session(request) cs = cart_session(request)
add_payment_to_cart_session(cs, provider, min_value, max_value, info_data) cs.setdefault('payments', [])
cs['payments'].append({
'id': str(uuid.uuid4()),
'provider': provider.identifier,
'multi_use_supported': provider.multi_use_supported,
'min_value': str(min_value) if min_value is not None else None,
'max_value': str(max_value) if max_value is not None else None,
'info_data': info_data or {},
})
def get_fees(event, request, total, invoice_address, payments, positions): def get_fees(event, request, total, invoice_address, payments, positions):
@@ -1513,7 +1497,6 @@ def get_fees(event, request, total, invoice_address, payments, positions):
value=payment_fee, value=payment_fee,
tax_rate=payment_fee_tax.rate, tax_rate=payment_fee_tax.rate,
tax_value=payment_fee_tax.tax, tax_value=payment_fee_tax.tax,
tax_code=payment_fee_tax.code,
tax_rule=payment_fee_tax_rule tax_rule=payment_fee_tax_rule
)) ))
+18 -1
View File
@@ -57,7 +57,7 @@ from pretix.base.models import (
Checkin, CheckinList, Device, Event, Gate, Item, ItemVariation, Order, Checkin, CheckinList, Device, Event, Gate, Item, ItemVariation, Order,
OrderPosition, QuestionOption, OrderPosition, QuestionOption,
) )
from pretix.base.signals import checkin_created, periodic_task from pretix.base.signals import checkin_created, order_placed, periodic_task
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.jsonlogic import Logic from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
@@ -1154,6 +1154,23 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
) )
@receiver(order_placed, dispatch_uid="legacy_autocheckin_order_placed")
def order_placed(sender, **kwargs):
order = kwargs['order']
event = sender
cls = list(event.checkin_lists.filter(auto_checkin_sales_channels=order.sales_channel).prefetch_related(
'limit_products'))
if not cls:
return
for op in order.positions.all():
for cl in cls:
if cl.all_products or op.item_id in {i.pk for i in cl.limit_products.all()}:
if not cl.subevent_id or cl.subevent_id == op.subevent_id:
ci = Checkin.objects.create(position=op, list=cl, auto_checked_in=True, type=Checkin.TYPE_ENTRY)
checkin_created.send(event, checkin=ci)
@receiver(periodic_task, dispatch_uid="autocheckout_exit_all") @receiver(periodic_task, dispatch_uid="autocheckout_exit_all")
@scopes_disabled() @scopes_disabled()
def process_exit_all(sender, **kwargs): def process_exit_all(sender, **kwargs):
-6
View File
@@ -23,7 +23,6 @@ from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.db.models import Exists, OuterRef
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
@@ -33,7 +32,6 @@ from pretix.base.models.customers import CustomerSSOGrant
from ..models import CachedFile, CartPosition, InvoiceAddress from ..models import CachedFile, CartPosition, InvoiceAddress
from ..models.auth import UserKnownLoginSource from ..models.auth import UserKnownLoginSource
from ..models.orders import CheckoutSession
from ..signals import periodic_task from ..signals import periodic_task
@@ -44,10 +42,6 @@ def clean_cart_positions(sender, **kwargs):
cp.delete() cp.delete()
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True): for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
cp.delete() cp.delete()
for cs in CheckoutSession.objects.filter(created__lt=now() - timedelta(days=14)).exclude(
Exists(CartPosition.objects.filter(cart_id=OuterRef("cart_id")))
):
cs.delete()
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)): for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
ia.delete() ia.delete()
+2 -2
View File
@@ -29,7 +29,7 @@ from typing import List
from django.utils.functional import cached_property from django.utils.functional import cached_property
from pretix.base.models import CartPosition, ItemCategory, SalesChannel from pretix.base.models import CartPosition, ItemCategory, SalesChannel
from pretix.base.storelogic.products import get_items_for_product_list from pretix.presale.views.event import get_grouped_items
class DummyCategory: class DummyCategory:
@@ -161,7 +161,7 @@ class CrossSellingService:
] ]
def _prepare_items(self, subevent, items_qs, discount_info): def _prepare_items(self, subevent, items_qs, discount_info):
items, _btn = get_items_for_product_list( items, _btn = get_grouped_items(
self.event, self.event,
subevent=subevent, subevent=subevent,
voucher=None, voucher=None,
+3 -6
View File
@@ -271,9 +271,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
event_date_from=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from, event_date_from=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from,
event_date_to=p.subevent.date_to if invoice.event.has_subevents else invoice.event.date_to, event_date_to=p.subevent.date_to if invoice.event.has_subevents else invoice.event.date_to,
event_location=location if invoice.event.settings.invoice_event_location else None, event_location=location if invoice.event.settings.invoice_event_location else None,
tax_rate=p.tax_rate, tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else ''
tax_code=p.tax_code,
tax_name=p.tax_rule.name if p.tax_rule else ''
) )
if p.tax_rule and p.tax_rule.is_reverse_charge(ia) and p.price and not p.tax_value: if p.tax_rule and p.tax_rule.is_reverse_charge(ia) and p.price and not p.tax_value:
@@ -307,7 +305,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
), ),
tax_value=fee.tax_value, tax_value=fee.tax_value,
tax_rate=fee.tax_rate, tax_rate=fee.tax_rate,
tax_code=fee.tax_code,
tax_name=fee.tax_rule.name if fee.tax_rule else '', tax_name=fee.tax_rule.name if fee.tax_rule else '',
fee_type=fee.fee_type, fee_type=fee.fee_type,
fee_internal_type=fee.internal_type or None, fee_internal_type=fee.internal_type or None,
@@ -494,13 +491,13 @@ def build_preview_invoice_pdf(event):
InvoiceLine.objects.create( InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product {}").format(i + 1), invoice=invoice, description=_("Sample product {}").format(i + 1),
gross_value=tax.gross, tax_value=tax.tax, gross_value=tax.gross, tax_value=tax.tax,
tax_rate=tax.rate, tax_name=tax.name, tax_code=tax.code, tax_rate=tax.rate, tax_name=tax.name
) )
else: else:
for i in range(5): for i in range(5):
InvoiceLine.objects.create( InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product A"), invoice=invoice, description=_("Sample product A"),
gross_value=100, tax_value=0, tax_rate=0, tax_code=None, gross_value=100, tax_value=0, tax_rate=0
) )
return event.invoice_renderer.generate(invoice) return event.invoice_renderer.generate(invoice)
+3 -11
View File
@@ -76,7 +76,7 @@ from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter, global_email_filter from pretix.base.signals import email_filter, global_email_filter
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.format import SafeFormatter, format_map from pretix.helpers.format import format_map
from pretix.helpers.hierarkey import clean_filename from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_private_icals from pretix.presale.ical import get_private_icals
@@ -311,17 +311,11 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
try: try:
if plain_text_only: if plain_text_only:
body_html = None body_html = None
elif 'context' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, raw_subject, order, position, context)
elif 'position' in inspect.signature(renderer.render).parameters: elif 'position' in inspect.signature(renderer.render).parameters:
# Backwards compatibility
warnings.warn('Email renderer called without context argument because context argument is not '
'supported.',
DeprecationWarning)
body_html = renderer.render(content_plain, signature, raw_subject, order, position) body_html = renderer.render(content_plain, signature, raw_subject, order, position)
else: else:
# Backwards compatibility # Backwards compatibility
warnings.warn('Email renderer called without position argument because position argument is not ' warnings.warn('E-mail renderer called without position argument because position argument is not '
'supported.', 'supported.',
DeprecationWarning) DeprecationWarning)
body_html = renderer.render(content_plain, signature, raw_subject, order) body_html = renderer.render(content_plain, signature, raw_subject, order)
@@ -329,8 +323,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
logger.exception('Could not render HTML body') logger.exception('Could not render HTML body')
body_html = None body_html = None
body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
send_task = mail_send_task.si( send_task = mail_send_task.si(
to=[email] if isinstance(email, str) else list(email), to=[email] if isinstance(email, str) else list(email),
cc=cc, cc=cc,
@@ -663,7 +655,7 @@ def render_mail(template, context):
if isinstance(template, LazyI18nString): if isinstance(template, LazyI18nString):
body = str(template) body = str(template)
if context: if context:
body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH) body = format_map(body, context)
else: else:
tpl = get_template(template) tpl = get_template(template)
body = tpl.render(context) body = tpl.render(context)
+4 -4
View File
@@ -118,7 +118,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
c.assign(record.get(c.identifier), order, position, order._address) c.assign(record.get(c.identifier), order, position, order._address)
if position.seat is not None: if position.seat is not None:
lock_seats.append((order.sales_channel, position.seat)) lock_seats.append(position.seat)
except (ValidationError, ImportError) as e: except (ValidationError, ImportError) as e:
raise DataImportError( raise DataImportError(
_('Invalid data in row {row}: {message}').format(row=i, message=str(e)) _('Invalid data in row {row}: {message}').format(row=i, message=str(e))
@@ -128,9 +128,9 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
with transaction.atomic(): with transaction.atomic():
# We don't support vouchers, quotas, or memberships here, so we only need to lock if seats are in use # We don't support vouchers, quotas, or memberships here, so we only need to lock if seats are in use
if lock_seats: if lock_seats:
lock_objects([s for c, s in lock_seats], shared_lock_objects=[event]) lock_objects(lock_seats, shared_lock_objects=[event])
for c, s in lock_seats: for s in lock_seats:
if not s.is_available(sales_channel=c): if not s.is_available():
raise DataImportError(_('The seat you selected has already been taken. Please select a different seat.')) raise DataImportError(_('The seat you selected has already been taken. Please select a different seat.'))
save_transactions = [] save_transactions = []
+4 -9
View File
@@ -1721,17 +1721,16 @@ class OrderChangeManager:
try: try:
new_rate = tax_rule.tax_rate_for(ia) new_rate = tax_rule.tax_rate_for(ia)
new_code = tax_rule.tax_code_for(ia)
except TaxRule.SaleNotAllowed: except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['tax_rule_country_blocked']) raise OrderError(error_messages['tax_rule_country_blocked'])
# We use override_tax_rate to make sure .tax() doesn't get clever and re-adjusts the pricing itself # We use override_tax_rate to make sure .tax() doesn't get clever and re-adjusts the pricing itself
if new_rate != pos.tax_rate or new_code != pos.tax_code: if new_rate != pos.tax_rate:
if keep == 'net': if keep == 'net':
new_tax = tax_rule.tax(pos.price - pos.tax_value, base_price_is='net', currency=self.event.currency, new_tax = tax_rule.tax(pos.price - pos.tax_value, base_price_is='net', currency=self.event.currency,
override_tax_rate=new_rate, override_tax_code=new_code) override_tax_rate=new_rate)
else: else:
new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency, new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency,
override_tax_rate=new_rate, override_tax_code=new_code) override_tax_rate=new_rate)
self._totaldiff += new_tax.gross - pos.price self._totaldiff += new_tax.gross - pos.price
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price)) self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
self._invoice_dirty = True self._invoice_dirty = True
@@ -2305,7 +2304,6 @@ class OrderChangeManager:
op.position.price = op.price.gross op.position.price = op.price.gross
op.position.tax_rate = op.price.rate op.position.tax_rate = op.price.rate
op.position.tax_value = op.price.tax op.position.tax_value = op.price.tax
op.position.tax_code = op.price.code
op.position.save() op.position.save()
elif isinstance(op, self.TaxRuleOperation): elif isinstance(op, self.TaxRuleOperation):
if isinstance(op.position, OrderPosition): if isinstance(op.position, OrderPosition):
@@ -2402,7 +2400,7 @@ class OrderChangeManager:
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
pos = OrderPosition.objects.create( pos = OrderPosition.objects.create(
item=op.item, variation=op.variation, addon_to=op.addon_to, item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, price=op.price.gross, order=self.order, tax_rate=op.price.rate,
tax_value=op.price.tax, tax_rule=op.item.tax_rule, tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent, seat=op.seat, positionid=nextposid, subevent=op.subevent, seat=op.seat,
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until, used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
@@ -2425,8 +2423,6 @@ class OrderChangeManager:
elif isinstance(op, self.SplitOperation): elif isinstance(op, self.SplitOperation):
split_positions.append(op.position) split_positions.append(op.position)
elif isinstance(op, self.RegenerateSecretOperation): elif isinstance(op, self.RegenerateSecretOperation):
op.position.web_secret = generate_secret()
op.position.save(update_fields=["web_secret"])
assign_ticket_secret( assign_ticket_secret(
event=self.event, position=op.position, force_invalidate=True, save=True event=self.event, position=op.position, force_invalidate=True, save=True
) )
@@ -2533,7 +2529,6 @@ class OrderChangeManager:
'new_order': split_order.code, 'new_order': split_order.code,
}) })
op.order = split_order op.order = split_order
op.web_secret = generate_secret()
assign_ticket_secret( assign_ticket_secret(
self.event, position=op, force_invalidate=True, self.event, position=op, force_invalidate=True,
) )
+5 -174
View File
@@ -26,7 +26,6 @@ from decimal import Decimal
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -40,8 +39,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
from pretix.base.signals import ( from pretix.base.signals import (
register_mail_placeholders, register_text_placeholders, register_mail_placeholders, register_text_placeholders,
) )
from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.helpers.format import SafeFormatter
from pretix.helpers.format import PlainHtmlAlternativeString, SafeFormatter
logger = logging.getLogger('pretix.base.services.placeholders') logger = logging.getLogger('pretix.base.services.placeholders')
@@ -109,91 +107,6 @@ class SimpleFunctionalTextPlaceholder(BaseTextPlaceholder):
return self._sample return self._sample
class BaseRichTextPlaceholder(BaseTextPlaceholder):
"""
This is the base class for all placeholders which can render either to plain text
or to a rich HTML element.
"""
def __init__(self, identifier, args):
self._identifier = identifier
self._args = args
@property
def identifier(self):
return self._identifier
@property
def required_context(self):
return self._args
@property
def is_block(self):
return False
def render(self, context):
return PlainHtmlAlternativeString(
self.render_plain(**{k: context[k] for k in self._args}),
self.render_html(**{k: context[k] for k in self._args}),
self.is_block,
)
def render_html(self, **kwargs):
"""
HTML rendering of the placeholder. Should return "safe" HTML, i.e. everything needs to be
escaped.
"""
raise NotImplementedError
def render_plain(self, **kwargs):
"""
Plain text rendering of the placeholder.
"""
raise NotImplementedError
def render_sample(self, event):
return PlainHtmlAlternativeString(
self.render_sample_plain(event=event),
self.render_sample_html(event=event),
self.is_block,
)
def render_sample_html(self, event):
raise NotImplementedError
def render_sample_plain(self, event):
raise NotImplementedError
class SimpleButtonPlaceholder(BaseRichTextPlaceholder):
def __init__(self, identifier, args, url_func, text_func, sample_url_func, sample_text_func):
super().__init__(identifier, args)
self._url_func = url_func
self._text_func = text_func
self._sample_url_func = sample_url_func
self._sample_text_func = sample_text_func
def render_html(self, **context):
text = self._text_func(**{k: context[k] for k in self._args})
url = self._url_func(**{k: context[k] for k in self._args})
return f'<a href="{url}" class="button">{escape(text)}</a>'
def render_plain(self, **context):
text = self._text_func(**{k: context[k] for k in self._args})
url = self._url_func(**{k: context[k] for k in self._args})
return f'{text}: {url}'
def render_sample_html(self, event):
text = self._sample_text_func(event)
url = self._sample_url_func(event)
return f'<a href="{url}" class="button">{escape(text)}</a>'
def render_sample_plain(self, event):
text = self._sample_text_func(event)
url = self._sample_url_func(event)
return f'{text}: {url}'
class PlaceholderContext(SafeFormatter): class PlaceholderContext(SafeFormatter):
""" """
Holds the contextual arguments and corresponding list of available placeholders for formatting Holds the contextual arguments and corresponding list of available placeholders for formatting
@@ -296,24 +209,13 @@ def get_best_name(position_or_address, parts=False):
def base_placeholders(sender, **kwargs): def base_placeholders(sender, **kwargs):
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
def _event_sample(event):
if event.has_subevents:
se = event.subevents.first()
if se:
return se.name
return event.name
ph = [ ph = [
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'event', ['event'], lambda event: event.name, lambda event: event.name 'event', ['event'], lambda event: event.name, lambda event: event.name
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name, 'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
_event_sample, lambda event_or_subevent: event_or_subevent.name
),
SimpleFunctionalTextPlaceholder(
'event_series_name', ['event', 'event_or_subevent'], lambda event, event_or_subevent: event.name,
lambda event: event.name
), ),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug 'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
@@ -371,27 +273,6 @@ def base_placeholders(sender, **kwargs):
} }
), ),
), ),
SimpleButtonPlaceholder(
'url_button', ['order', 'event'],
url_func=lambda order, event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_secret()
}
),
text_func=lambda order, event: _("View order details"),
sample_url_func=lambda event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'hash': '98kusd8ofsj8dnkd'
}
),
sample_text_func=lambda event: _("View order details"),
),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri( 'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event, event,
@@ -456,27 +337,6 @@ def base_placeholders(sender, **kwargs):
} }
), ),
), ),
SimpleButtonPlaceholder(
'url_button', ['event', 'position'],
url_func=lambda event, position: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': position.order.code,
'secret': position.web_secret,
'position': position.positionid
}
),
text_func=lambda event, position: _("View registration details"),
sample_url_func=lambda event: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'position': '123'
}
),
sample_text_func=lambda event: _("View registration details"),
),
SimpleFunctionalTextPlaceholder( SimpleFunctionalTextPlaceholder(
'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri( 'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri(
event, event,
@@ -732,8 +592,8 @@ def base_placeholders(sender, **kwargs):
class FormPlaceholderMixin: class FormPlaceholderMixin:
def _set_field_placeholders(self, fn, base_parameters, rich=False): def _set_field_placeholders(self, fn, base_parameters):
placeholders = get_available_placeholders(self.event, base_parameters, rich=rich) placeholders = get_available_placeholders(self.event, base_parameters)
ht = format_placeholders_help_text(placeholders, self.event) ht = format_placeholders_help_text(placeholders, self.event)
if self.fields[fn].help_text: if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht) self.fields[fn].help_text += ' ' + str(ht)
@@ -744,7 +604,7 @@ class FormPlaceholderMixin:
) )
def get_available_placeholders(event, base_parameters, rich=False): def get_available_placeholders(event, base_parameters):
if 'order' in base_parameters: if 'order' in base_parameters:
base_parameters.append('invoice_address') base_parameters.append('invoice_address')
base_parameters.append('position_or_address') base_parameters.append('position_or_address')
@@ -753,35 +613,6 @@ def get_available_placeholders(event, base_parameters, rich=False):
if not isinstance(val, (list, tuple)): if not isinstance(val, (list, tuple)):
val = [val] val = [val]
for v in val: for v in val:
if isinstance(v, BaseRichTextPlaceholder) and not rich:
continue
if all(rp in base_parameters for rp in v.required_context): if all(rp in base_parameters for rp in v.required_context):
params[v.identifier] = v params[v.identifier] = v
return params return params
def get_sample_context(event, context_parameters, rich=True):
context_dict = {}
lbl = _('This value will be replaced based on dynamic parameters.')
for k, v in get_available_placeholders(event, context_parameters, rich=rich).items():
sample = v.render_sample(event)
if isinstance(sample, PlainHtmlAlternativeString):
context_dict[k] = PlainHtmlAlternativeString(
sample.plain,
'<{el} class="placeholder placeholder-html" title="{title}">{html}</{el}>'.format(
el='div' if sample.is_block else 'span',
title=lbl,
html=sample.html,
)
)
elif str(sample).strip().startswith('* ') or str(sample).startswith(' '):
context_dict[k] = '<div class="placeholder" title="{}">{}</div>'.format(
lbl,
markdown_compile_email(str(sample))
)
else:
context_dict[k] = '<span class="placeholder" title="{}">{}</span>'.format(
lbl,
escape(sample)
)
return context_dict
+2 -6
View File
@@ -91,11 +91,9 @@ def get_price(item: Item, variation: ItemVariation = None,
if custom_price_is_net: if custom_price_is_net:
price = tax_rule.tax(max(custom_price, price.net), base_price_is='net', override_tax_rate=price.rate, price = tax_rule.tax(max(custom_price, price.net), base_price_is='net', override_tax_rate=price.rate,
override_tax_code=price.code,
invoice_address=invoice_address, subtract_from_gross=bundled_sum) invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else: else:
price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', override_tax_rate=price.rate, price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', override_tax_rate=price.rate,
override_tax_code=price.code,
invoice_address=invoice_address, subtract_from_gross=bundled_sum) invoice_address=invoice_address, subtract_from_gross=bundled_sum)
else: else:
price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum) price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
@@ -148,12 +146,10 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
if custom_price_input_is_net: if custom_price_input_is_net:
price = tax_rule.tax(max(custom_price_input, price.net), base_price_is='net', override_tax_rate=price.rate, price = tax_rule.tax(max(custom_price_input, price.net), base_price_is='net', override_tax_rate=price.rate,
override_tax_code=price.code, invoice_address=invoice_address, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
subtract_from_gross=bundled_sum)
else: else:
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate, price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
override_tax_code=price.code, invoice_address=invoice_address, invoice_address=invoice_address, subtract_from_gross=bundled_sum)
subtract_from_gross=bundled_sum)
else: else:
price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum, price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum,
base_price_is='gross' if is_bundled else 'auto') base_price_is='gross' if is_bundled else 'auto')
+2 -3
View File
@@ -21,7 +21,6 @@
# #
import logging import logging
import os import os
from decimal import Decimal
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils.timezone import now from django.utils.timezone import now
@@ -98,9 +97,9 @@ def preview(event: int, provider: str):
event = Event.objects.get(id=event) event = Event.objects.get(id=event)
with rolledback_transaction(), language(event.settings.locale, event.settings.region): with rolledback_transaction(), language(event.settings.locale, event.settings.region):
item = event.items.create(name=_("Sample product"), default_price=Decimal('42.23'), item = event.items.create(name=_("Sample product"), default_price=42.23,
description=_("Sample product description")) description=_("Sample product description"))
item2 = event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40')) item2 = event.items.create(name=_("Sample workshop"), default_price=23.40)
from pretix.base.models import Order from pretix.base.models import Order
order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(), order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
+2 -3
View File
@@ -56,7 +56,6 @@ from django.utils.translation import (
from django_countries.fields import Country from django_countries.fields import Country
from hierarkey.models import GlobalSettingsBase, Hierarkey from hierarkey.models import GlobalSettingsBase, Hierarkey
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.rest_framework import I18nField
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from phonenumbers import PhoneNumber, parse from phonenumbers import PhoneNumber, parse
from rest_framework import serializers from rest_framework import serializers
@@ -64,7 +63,7 @@ from rest_framework import serializers
from pretix.api.serializers.fields import ( from pretix.api.serializers.fields import (
ListMultipleChoiceField, UploadedFileField, ListMultipleChoiceField, UploadedFileField,
) )
from pretix.api.serializers.i18n import I18nURLField from pretix.api.serializers.i18n import I18nField, I18nURLField
from pretix.base.forms import I18nMarkdownTextarea, I18nURLFormField from pretix.base.forms import I18nMarkdownTextarea, I18nURLFormField
from pretix.base.models.tax import VAT_ID_COUNTRIES, TaxRule from pretix.base.models.tax import VAT_ID_COUNTRIES, TaxRule
from pretix.base.reldate import ( from pretix.base.reldate import (
@@ -551,7 +550,7 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField, 'serializer_class': serializers.BooleanField,
'type': bool, 'type': bool,
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Require a business address"), label=_("Require a business addresses"),
help_text=_('This will require users to enter a company name.'), help_text=_('This will require users to enter a company name.'),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_required'}), widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_required'}),
) )
+2 -2
View File
@@ -287,9 +287,9 @@ class PhoneNumberShredder(BaseDataShredder):
class EmailAddressShredder(BaseDataShredder): class EmailAddressShredder(BaseDataShredder):
verbose_name = _('Emails') verbose_name = _('E-mails')
identifier = 'order_emails' identifier = 'order_emails'
description = _('This will remove all email addresses from orders and attendees, as well as logged email ' description = _('This will remove all e-mail addresses from orders and attendees, as well as logged email '
'contents. This will also remove the association to customer accounts.') 'contents. This will also remove the association to customer accounts.')
def generate_files(self) -> List[Tuple[str, str, str]]: def generate_files(self) -> List[Tuple[str, str, str]]:
-2
View File
@@ -1,2 +0,0 @@
class IncompleteError(Exception):
pass
-118
View File
@@ -1,118 +0,0 @@
import copy
from collections import defaultdict
from pretix.base.models.tax import TaxedPrice
from pretix.base.storelogic.products import get_items_for_product_list
def addons_is_completed(cart_positions):
for cartpos in cart_positions.filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__item'
):
a = cartpos.addons.all()
for iao in cartpos.item.addons.all():
found = len([1 for p in a if p.item.category_id == iao.addon_category_id and not p.is_bundled])
if found < iao.min_count or found > iao.max_count:
return False
return True
def addons_is_applicable(cart_positions):
return cart_positions.filter(item__addons__isnull=False).exists()
def get_addon_groups(event, sales_channel, customer, cart_positions):
quota_cache = {}
item_cache = {}
groups = []
for cartpos in sorted(cart_positions.filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation',
), key=lambda c: c.sort_key):
groupentry = {
'pos': cartpos,
'item': cartpos.item,
'variation': cartpos.variation,
'categories': []
}
current_addon_products = defaultdict(list)
for a in cartpos.addons.all():
if not a.is_bundled:
current_addon_products[a.item_id, a.variation_id].append(a)
for iao in cartpos.item.addons.all():
ckey = '{}-{}'.format(cartpos.subevent.pk if cartpos.subevent else 0, iao.addon_category.pk)
if ckey not in item_cache:
# Get all items to possibly show
items, _btn = get_items_for_product_list(
event,
subevent=cartpos.subevent,
voucher=None,
channel=sales_channel,
base_qs=iao.addon_category.items,
allow_addons=True,
quota_cache=quota_cache,
memberships=(
customer.usable_memberships(
for_event=cartpos.subevent or event,
testmode=event.testmode
)
if customer else None
),
)
item_cache[ckey] = items
else:
# We can use the cache to prevent a database fetch, but we need separate Python objects
# or our things below like setting `i.initial` will do the wrong thing.
items = [copy.copy(i) for i in item_cache[ckey]]
for i in items:
i.available_variations = [copy.copy(v) for v in i.available_variations]
for i in items:
i.allow_waitinglist = False
if i.has_variations:
for v in i.available_variations:
v.initial = len(current_addon_products[i.pk, v.pk])
if v.initial and i.free_price:
a = current_addon_products[i.pk, v.pk][0]
v.initial_price = TaxedPrice(
net=a.price - a.tax_value,
gross=a.price,
tax=a.tax_value,
name=a.item.tax_rule.name if a.item.tax_rule else "",
rate=a.tax_rate,
code=a.item.tax_rule.code if a.item.tax_rule else None,
)
else:
v.initial_price = v.suggested_price
i.expand = any(v.initial for v in i.available_variations)
else:
i.initial = len(current_addon_products[i.pk, None])
if i.initial and i.free_price:
a = current_addon_products[i.pk, None][0]
i.initial_price = TaxedPrice(
net=a.price - a.tax_value,
gross=a.price,
tax=a.tax_value,
name=a.item.tax_rule.name if a.item.tax_rule else "",
rate=a.tax_rate,
code=a.item.tax_rule.code if a.item.tax_rule else None,
)
else:
i.initial_price = i.suggested_price
if items:
groupentry['categories'].append({
'category': iao.addon_category,
'price_included': iao.price_included or (cartpos.voucher_id and cartpos.voucher.all_addons_included),
'multi_allowed': iao.multi_allowed,
'min_count': iao.min_count,
'max_count': iao.max_count,
'iao': iao,
'items': items
})
if groupentry['categories']:
groups.append(groupentry)
return groups
-271
View File
@@ -1,271 +0,0 @@
from django.core.exceptions import ValidationError
from django.core.validators import EmailValidator
from django.utils.translation import gettext_lazy as _
from pretix.base.models import CartPosition, Question
from pretix.base.services.checkin import _save_answers
from pretix.base.storelogic import IncompleteError
from pretix.presale.signals import question_form_fields
class Field:
@property
def identifier(self):
raise NotImplementedError()
@property
def label(self):
raise NotImplementedError()
@property
def help_text(self):
raise NotImplementedError()
@property
def type(self):
raise NotImplementedError()
@property
def required(self):
return True
@property
def validation_hints(self):
raise {}
def validate_input(self, value):
return value
class PositionField(Field):
def save_input(self, position, value):
raise NotImplementedError()
def current_value(self, position):
raise NotImplementedError()
class SessionField(Field):
def save_input(self, session_data, value):
raise NotImplementedError()
def current_value(self, session_data):
raise NotImplementedError()
class QuestionField(PositionField):
def __init__(self, question: Question):
self.question = question
@property
def label(self):
return self.question.question
@property
def help_text(self):
return self.question.help_text
@property
def type(self):
return self.question.type
@property
def identifier(self):
return f"question_{self.question.identifier}"
def validate_input(self, value):
return self.question.clean_answer(value)
def required(self, value):
return self.question.required
def validation_hints(self):
d = {
"valid_number_min": self.question.valid_number_min,
"valid_number_max": self.question.valid_number_max,
"valid_date_min": self.question.valid_date_min,
"valid_date_max": self.question.valid_date_max,
"valid_datetime_min": self.question.valid_datetime_min,
"valid_datetime_max": self.question.valid_datetime_max,
"valid_string_length_max": self.question.valid_string_length_max,
"dependency_on": f"question_{self.question.dependency_question.identifier}" if self.question.dependency_question_id else None,
"dependency_values": self.question.dependency_values,
}
if self.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
d["choices"] = [
{
"identifier": opt.identifier,
"label": str(opt.answer)
}
for opt in self.question.options.all()
]
return d
def save_input(self, position, value):
answers = [a for a in position.answerlist if a.question_id == self.question.id]
if answers:
answers = {self.question: answers[0]}
else:
answers = {}
_save_answers(position, answers, {self.question: value})
def current_value(self, position):
answers = [a for a in position.answerlist if a.question_id == self.question.id]
if answers:
if self.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
return ",".join([a.idenitifer for a in answers[0].options.all()])
else:
return answers[0].answer
class SyntheticSessionField(SessionField):
def __init__(self, label, help_text, type, identifier, required, save_func, get_func, validate_func):
self._label = label
self._help_text = help_text
self._type = type
self._identifier = identifier
self._required = required
self._save_func = save_func
self._get_func = get_func
self._validate_func = validate_func
super().__init__()
@property
def label(self):
return self._label
@property
def help_text(self):
return self._help_text
@property
def type(self):
return self._type
@property
def required(self):
return self._required
@property
def identifier(self):
return self._identifier
def validation_hints(self):
return {}
def save_input(self, session_data, value):
self._save_func(session_data, value)
def current_value(self, session_data):
return self._get_func(session_data)
def validate_input(self, value):
return self._validate_func(value)
def get_checkout_fields(event):
fields = []
# TODO: support contact_form_fields
# TODO: support contact_form_fields_override
# email
fields.append(SyntheticSessionField(
label=_("Email"),
help_text=None,
type=Question.TYPE_STRING, # TODO: Add a type?
identifier="email",
required=True,
get_func=lambda session_data: session_data.get("email"),
save_func=lambda session_data, value: session_data.update({"email": value}),
validate_func=lambda value: EmailValidator()(value) or value,
))
# TODO: phone
# TODO: invoice address
return fields
def get_position_fields(event, pos: CartPosition):
# TODO: support override sets
fields = []
for q in pos.item.questions_to_ask:
fields.append(QuestionField(q))
return fields
def ensure_fields_are_completed(event, positions, cart_session, invoice_address, all_optional, cart_is_free):
try:
emailval = EmailValidator()
if not cart_session.get('email') and not all_optional:
raise IncompleteError(_('Please enter a valid email address.'))
if cart_session.get('email'):
emailval(cart_session.get('email'))
except ValidationError:
raise IncompleteError(_('Please enter a valid email address.'))
address_asked = (
event.settings.invoice_address_asked and (not event.settings.invoice_address_not_asked_free or not cart_is_free)
)
if not all_optional:
if address_asked:
if event.settings.invoice_address_required and (not invoice_address or not invoice_address.street):
raise IncompleteError(_('Please enter your invoicing address.'))
if event.settings.invoice_name_required and (not invoice_address or not invoice_address.name):
raise IncompleteError(_('Please enter your name.'))
for cp in positions:
answ = {
aw.question_id: aw for aw in cp.answerlist
}
question_cache = {
q.pk: q for q in cp.item.questions_to_ask
}
def question_is_visible(parentid, qvals):
if parentid not in question_cache:
return False
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id,
parentq.dependency_values):
return False
if parentid not in answ:
return False
return (
('True' in qvals and answ[parentid].answer == 'True')
or ('False' in qvals and answ[parentid].answer == 'False')
or (any(qval in [o.identifier for o in answ[parentid].options.all()] for qval in qvals))
)
def question_is_required(q):
return (
q.required and
(not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values))
)
if not all_optional:
for q in cp.item.questions_to_ask:
if question_is_required(q) and q.id not in answ:
raise IncompleteError(_('Please fill in answers to all required questions.'))
if cp.item.ask_attendee_data and event.settings.get('attendee_names_required', as_type=bool) \
and not cp.attendee_name_parts:
raise IncompleteError(_('Please fill in answers to all required questions.'))
if cp.item.ask_attendee_data and event.settings.get('attendee_emails_required', as_type=bool) \
and cp.attendee_email is None:
raise IncompleteError(_('Please fill in answers to all required questions.'))
if cp.item.ask_attendee_data and event.settings.get('attendee_company_required', as_type=bool) \
and cp.company is None:
raise IncompleteError(_('Please fill in answers to all required questions.'))
if cp.item.ask_attendee_data and event.settings.get('attendee_addresses_required', as_type=bool) \
and (cp.street is None and cp.city is None and cp.country is None):
raise IncompleteError(_('Please fill in answers to all required questions.'))
responses = question_form_fields.send(sender=event, position=cp)
form_data = cp.meta_info_data.get('question_form_data', {})
for r, response in sorted(responses, key=lambda r: str(r[0])):
for key, value in response.items():
if value.required and not form_data.get(key):
raise IncompleteError(_('Please fill in answers to all required questions.'))
-132
View File
@@ -1,132 +0,0 @@
import copy
import uuid
from decimal import Decimal
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext as _
from pretix.base.storelogic import IncompleteError
from pretix.base.templatetags.money import money_filter
def payment_is_applicable(event, total, cart_positions, invoice_address, cart_session, request):
for cartpos in cart_positions:
if cartpos.requires_approval(invoice_address=invoice_address):
if 'payments' in cart_session:
del cart_session['payments']
return False
used_providers = {p['provider'] for p in cart_session.get('payments', [])}
for provider in event.get_payment_providers().values():
if provider.is_implicit(request) if callable(provider.is_implicit) else provider.is_implicit:
# TODO: do we need a different is_allowed for storefrontapi?
if provider.is_allowed(request, total=total):
cart_session['payments'] = [
{
'id': str(uuid.uuid4()),
'provider': provider.identifier,
'multi_use_supported': False,
'min_value': None,
'max_value': None,
'info_data': {},
}
]
return False
elif provider.identifier in used_providers:
# is_allowed might have changed, e.g. after add-on selection
cart_session['payments'] = [p for p in cart_session['payments'] if
p['provider'] != provider.identifier]
return True
def current_selected_payments(event, total, cart_session, total_includes_payment_fees=False, fail=False):
def _remove_payment(payment_id):
cart_session['payments'] = [p for p in cart_session['payments'] if p.get('id') != payment_id]
raw_payments = copy.deepcopy(cart_session.get('payments', []))
payments = []
total_remaining = total
for p in raw_payments:
# This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base:
# - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees
# - pretix.presale.storelogic.payment.current_selected_payments
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
_remove_payment(p['id'])
if fail:
raise IncompleteError(
_('Your selected payment method can only be used for a payment of at least {amount}.').format(
amount=money_filter(Decimal(p['min_value']), event.currency)
)
)
continue
to_pay = total_remaining
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
pprov = event.get_payment_providers(cached=True).get(p['provider'])
if not pprov:
_remove_payment(p['id'])
continue
if not total_includes_payment_fees:
fee = pprov.calculate_fee(to_pay)
total_remaining += fee
to_pay += fee
else:
fee = Decimal('0.00')
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
p['payment_amount'] = to_pay
p['provider_name'] = pprov.public_name
p['pprov'] = pprov
p['fee'] = fee
total_remaining -= to_pay
payments.append(p)
return payments
def ensure_payment_is_completed(event, total, cart_session, request):
def _remove_payment(payment_id):
cart_session['payments'] = [p for p in cart_session['payments'] if p.get('id') != payment_id]
if not cart_session.get('payments'):
raise IncompleteError(_('Please select a payment method to proceed.'))
selected = current_selected_payments(event, total, cart_session, fail=True, total_includes_payment_fees=True)
if sum(p['payment_amount'] for p in selected) != total:
raise IncompleteError(_('Please select a payment method to proceed.'))
if len([p for p in selected if not p['multi_use_supported']]) > 1:
raise ImproperlyConfigured('Multiple non-multi-use providers in session, should never happen')
for p in selected:
# TODO: do we need a different is_allowed for storefrontapi?
if not p['pprov'] or not p['pprov'].is_enabled or not p['pprov'].is_allowed(request, total=total):
_remove_payment(p['id'])
if p['payment_amount']:
raise IncompleteError(_('Please select a payment method to proceed.'))
if not p['multi_use_supported'] and not p['pprov'].payment_is_valid_session(request):
raise IncompleteError(_('The payment information you entered was incomplete.'))
def current_payments_valid(cart_session, amount):
singleton_payments = [p for p in cart_session.get('payments', []) if not p.get('multi_use_supported')]
if len(singleton_payments) > 1:
return False
matched = Decimal('0.00')
for p in cart_session.get('payments', []):
if p.get('min_value') and (amount - matched) < Decimal(p['min_value']):
continue
if p.get('max_value') and (amount - matched) > Decimal(p['max_value']):
matched += Decimal(p['max_value'])
else:
matched = Decimal('0.00')
return matched == Decimal('0.00'), amount - matched
-396
View File
@@ -1,396 +0,0 @@
import sys
from django.conf import settings
from django.db.models import (
Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value,
)
from django.db.models.lookups import Exact
from pretix.base.models import (
ItemVariation, Quota, SalesChannel, SeatCategoryMapping,
)
from pretix.base.models.items import (
ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timemachine import time_machine_now
from pretix.presale.signals import item_description
def item_group_by_category(items):
return sorted(
[
# a group is a tuple of a category and a list of items
(cat, [i for i in items if i.category == cat])
for cat in set([i.category for i in items])
# insert categories into a set for uniqueness
# a set is unsorted, so sort again by category
],
key=lambda group: (group[0].position, group[0].id) if (group[0] is not None and group[0].id is not None) else (0, 0)
)
def get_items_for_product_list(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0,
base_qs=None, allow_addons=False, allow_cross_sell=False,
quota_cache=None, filter_items=None, filter_categories=None, memberships=None,
ignore_hide_sold_out_for_item_ids=None):
base_qs_set = base_qs is not None
base_qs = base_qs if base_qs is not None else event.items
requires_seat = Exists(
SeatCategoryMapping.objects.filter(
product_id=OuterRef('pk'),
subevent=subevent
)
)
if not event.settings.seating_choice:
requires_seat = Value(0, output_field=IntegerField())
variation_q = (
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'))
)
if not voucher or not voucher.show_hidden_items:
variation_q &= Q(hide_without_voucher=False)
if memberships is not None:
prefetch_membership_types = ['require_membership_types']
else:
prefetch_membership_types = []
prefetch_var = Prefetch(
'variations',
to_attr='available_variations',
queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).annotate(
subevent_disabled=Exists(
SubEventItemVariation.objects.filter(
Q(disabled=True)
| (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now()))
| (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())),
variation_id=OuterRef('pk'),
subevent=subevent,
)
),
).filter(
variation_q,
Q(all_sales_channels=True) | Q(limit_sales_channels=channel),
active=True,
quotas__isnull=False,
subevent_disabled=False
).prefetch_related(
*prefetch_membership_types,
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
subevent=subevent).select_related("subevent"))
).distinct()
)
prefetch_quotas = Prefetch(
'quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent).select_related("subevent")
)
prefetch_bundles = Prefetch(
'bundles',
queryset=ItemBundle.objects.using(settings.DATABASE_REPLICA).prefetch_related(
Prefetch('bundled_item',
queryset=event.items.using(settings.DATABASE_REPLICA).select_related(
'tax_rule').prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
subevent=subevent)),
)),
Prefetch('bundled_variation',
queryset=ItemVariation.objects.using(
settings.DATABASE_REPLICA
).select_related('item', 'item__tax_rule').filter(item__event=event).prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
subevent=subevent)),
)),
)
)
items = base_qs.using(settings.DATABASE_REPLICA).filter_available(
channel=channel.identifier, voucher=voucher, allow_addons=allow_addons, allow_cross_sell=allow_cross_sell
).select_related(
'category', 'tax_rule', # for re-grouping
'hidden_if_available',
).prefetch_related(
*prefetch_membership_types,
Prefetch(
'hidden_if_item_available',
queryset=event.items.annotate(
has_variations=Count('variations'),
).prefetch_related(
prefetch_var,
prefetch_quotas,
prefetch_bundles,
)
),
prefetch_quotas,
prefetch_var,
prefetch_bundles,
).annotate(
quotac=Count('quotas'),
has_variations=Count('variations'),
subevent_disabled=Exists(
SubEventItem.objects.filter(
Q(disabled=True)
| (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now()))
| (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())),
item_id=OuterRef('pk'),
subevent=subevent,
)
),
mandatory_priced_addons=Exists(
ItemAddOn.objects.filter(
base_item_id=OuterRef('pk'),
min_count__gte=1,
price_included=False
)
),
requires_seat=requires_seat,
).filter(
quotac__gt=0, subevent_disabled=False,
).order_by('category__position', 'category_id', 'position', 'name')
if require_seat:
items = items.filter(requires_seat__gt=0)
elif require_seat is not None:
items = items.filter(requires_seat=0)
if filter_items:
items = items.filter(pk__in=[a for a in filter_items if a.isdigit()])
if filter_categories:
items = items.filter(category_id__in=[a for a in filter_categories if a.isdigit()])
display_add_to_cart = False
quota_cache_key = f'item_quota_cache:{subevent.id if subevent else 0}:{channel.identifier}:{bool(require_seat)}'
quota_cache = quota_cache or event.cache.get(quota_cache_key) or {}
quota_cache_existed = bool(quota_cache)
if subevent:
item_price_override = subevent.item_price_overrides
var_price_override = subevent.var_price_overrides
else:
item_price_override = {}
var_price_override = {}
restrict_vars = set()
if voucher and voucher.quota_id:
# If a voucher is set to a specific quota, we need to filter out on that level
restrict_vars = set(voucher.quota.variations.all())
quotas_to_compute = []
for item in items:
assert item.event_id == event.pk
item.event = event # save a database query if this is looked up
if item.has_variations:
for v in item.available_variations:
for q in v._subevent_quotas:
if q.pk not in quota_cache:
quotas_to_compute.append(q)
else:
for q in item._subevent_quotas:
if q.pk not in quota_cache:
quotas_to_compute.append(q)
if quotas_to_compute:
qa = QuotaAvailability()
qa.queue(*quotas_to_compute)
qa.compute()
quota_cache.update({q.pk: r for q, r in qa.results.items()})
for item in items:
if voucher and voucher.item_id and voucher.variation_id:
# Restrict variations if the voucher only allows one
item.available_variations = [v for v in item.available_variations
if v.pk == voucher.variation_id]
if channel.type_instance.unlimited_items_per_order:
max_per_order = sys.maxsize
else:
max_per_order = item.max_per_order or int(event.settings.max_items_per_order)
if item.hidden_if_available:
q = item.hidden_if_available.availability(_cache=quota_cache)
if q[0] == Quota.AVAILABILITY_OK:
item._remove = True
continue
if item.hidden_if_item_available:
if item.hidden_if_item_available.has_variations:
dependency_available = any(
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK
for var in item.hidden_if_item_available.available_variations
)
else:
q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
dependency_available = q[0] == Quota.AVAILABILITY_OK
if dependency_available:
item._remove = True
continue
if item.require_membership and item.require_membership_hidden:
if not memberships or not any([m.membership_type in item.require_membership_types.all() for m in memberships]):
item._remove = True
continue
item.current_unavailability_reason = item.unavailability_reason(has_voucher=voucher, subevent=subevent)
item.description = str(item.description)
for recv, resp in item_description.send(sender=event, item=item, variation=None, subevent=subevent):
if resp:
item.description += ("<br/>" if item.description else "") + resp
if not item.has_variations:
item._remove = False
if not bool(item._subevent_quotas):
item._remove = True
continue
if voucher and (voucher.allow_ignore_quota or voucher.block_quota):
item.cached_availability = (
Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed
)
else:
item.cached_availability = list(
item.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
)
if not (
ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids
) and event.settings.hide_sold_out and item.cached_availability[0] < Quota.AVAILABILITY_RESERVED:
item._remove = True
continue
item.order_max = min(
item.cached_availability[1]
if item.cached_availability[1] is not None else sys.maxsize,
max_per_order
)
original_price = item_price_override.get(item.pk, item.default_price)
voucher_reduced = False
if voucher:
price = voucher.calculate_price(original_price)
voucher_reduced = price < original_price
include_bundled = not voucher.all_bundles_included
else:
price = original_price
include_bundled = True
item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
if item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled)
else:
item.suggested_price = item.display_price
if price != original_price:
item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True)
else:
item.original_price = (
item.tax(item.original_price, currency=event.currency, include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
if item.original_price else None
)
if not display_add_to_cart:
display_add_to_cart = not item.requires_seat and item.order_max > 0
else:
for var in item.available_variations:
if var.require_membership and var.require_membership_hidden:
if not memberships or not any([m.membership_type in var.require_membership_types.all() for m in memberships]):
var._remove = True
continue
var.description = str(var.description)
for recv, resp in item_description.send(sender=event, item=item, variation=var, subevent=subevent):
if resp:
var.description += ("<br/>" if var.description else "") + resp
if voucher and (voucher.allow_ignore_quota or voucher.block_quota):
var.cached_availability = (
Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed
)
else:
var.cached_availability = list(
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
)
var.order_max = min(
var.cached_availability[1]
if var.cached_availability[1] is not None else sys.maxsize,
max_per_order
)
original_price = var_price_override.get(var.pk, var.price)
voucher_reduced = False
if voucher:
price = voucher.calculate_price(original_price)
voucher_reduced = price < original_price
include_bundled = not voucher.all_bundles_included
else:
price = original_price
include_bundled = True
var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
if item.free_price and var.free_price_suggestion is not None and not voucher_reduced:
var.suggested_price = item.tax(max(price, var.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
elif item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
var.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
else:
var.suggested_price = var.display_price
if price != original_price:
var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True)
else:
var.original_price = (
var.tax(var.original_price or item.original_price, currency=event.currency,
include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
) if var.original_price or item.original_price else None
if not display_add_to_cart:
display_add_to_cart = not item.requires_seat and var.order_max > 0
var.current_unavailability_reason = var.unavailability_reason(has_voucher=voucher, subevent=subevent)
item.original_price = (
item.tax(item.original_price, currency=event.currency, include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
if item.original_price else None
)
item.available_variations = [
v for v in item.available_variations if v._subevent_quotas and (
not voucher or not voucher.quota_id or v in restrict_vars
) and not getattr(v, '_remove', False)
]
if not (ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids) and event.settings.hide_sold_out:
item.available_variations = [v for v in item.available_variations
if v.cached_availability[0] >= Quota.AVAILABILITY_RESERVED]
if voucher and voucher.variation_id:
item.available_variations = [v for v in item.available_variations
if v.pk == voucher.variation_id]
if len(item.available_variations) > 0:
item.min_price = min([v.display_price.net if event.settings.display_net_prices else
v.display_price.gross for v in item.available_variations])
item.max_price = max([v.display_price.net if event.settings.display_net_prices else
v.display_price.gross for v in item.available_variations])
item.best_variation_availability = max([v.cached_availability[0] for v in item.available_variations])
item._remove = not bool(item.available_variations)
if not quota_cache_existed and not voucher and not allow_addons and not base_qs_set and not filter_items and not filter_categories:
event.cache.set(quota_cache_key, quota_cache, 5)
items = [item for item in items
if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove]
return items, display_add_to_cart
@@ -131,9 +131,6 @@
text-align: left; text-align: left;
padding: 0; padding: 0;
} }
.content table td.align-right {
text-align: right;
}
a.button { a.button {
display: inline-block; display: inline-block;
@@ -181,9 +178,6 @@
pre, pre code { pre, pre code {
white-space: pre-line; white-space: pre-line;
} }
.text-right, .content table td.text-right {
text-align: right;
}
{% if rtl %} {% if rtl %}
body { body {
@@ -192,9 +186,6 @@
.content { .content {
text-align: right; text-align: right;
} }
.text-right, .content table td.text-right {
text-align: left;
}
{% endif %} {% endif %}
{% block addcss %}{% endblock %} {% block addcss %}{% endblock %}
-34
View File
@@ -1,34 +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/>.
#
from django import template
from django.utils.html import format_html
register = template.Library()
@register.simple_tag
def icon(key, *args, **kwargs):
return format_html(
'<span class="fa fa-{} {}" aria-hidden="true"></span>',
key,
kwargs["class"] if "class" in kwargs else "",
)
+4 -4
View File
@@ -52,12 +52,12 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
# would make the numbers incorrect. If this branch executes, it's likely a bug in # would make the numbers incorrect. If this branch executes, it's likely a bug in
# pretix, but we won't show wrong numbers! # pretix, but we won't show wrong numbers!
if hide_currency: if hide_currency:
return floatformat(value, "2g") return floatformat(value, 2)
else: else:
return '{} {}'.format(arg, floatformat(value, "2g")) return '{} {}'.format(arg, floatformat(value, 2))
if hide_currency: if hide_currency:
return floatformat(value, f"{places}g") return floatformat(value, places)
locale_parts = translation.get_language().split("-", 1) locale_parts = translation.get_language().split("-", 1)
locale = locale_parts[0] locale = locale_parts[0]
@@ -70,7 +70,7 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
try: try:
return format_currency(value, arg, locale=locale) return format_currency(value, arg, locale=locale)
except: except:
return '{} {}'.format(arg, floatformat(value, f"{places}g")) return '{} {}'.format(arg, floatformat(value, places))
@register.filter("money_numberfield") @register.filter("money_numberfield")
+8 -9
View File
@@ -54,7 +54,7 @@ from tlds import tld_set
register = template.Library() register = template.Library()
ALLOWED_TAGS_SNIPPET = { ALLOWED_TAGS_SNIPPET = [
'a', 'a',
'abbr', 'abbr',
'acronym', 'acronym',
@@ -68,8 +68,8 @@ ALLOWED_TAGS_SNIPPET = {
'strike', 'strike',
's', 's',
# Update doc/user/markdown.rst if you change this! # Update doc/user/markdown.rst if you change this!
} ]
ALLOWED_TAGS = ALLOWED_TAGS_SNIPPET | { ALLOWED_TAGS = ALLOWED_TAGS_SNIPPET + [
'blockquote', 'blockquote',
'li', 'li',
'ol', 'ol',
@@ -91,7 +91,7 @@ ALLOWED_TAGS = ALLOWED_TAGS_SNIPPET | {
'h6', 'h6',
'pre', 'pre',
# Update doc/user/markdown.rst if you change this! # Update doc/user/markdown.rst if you change this!
} ]
ALLOWED_ATTRIBUTES = { ALLOWED_ATTRIBUTES = {
'a': ['href', 'title', 'class'], 'a': ['href', 'title', 'class'],
@@ -106,7 +106,7 @@ ALLOWED_ATTRIBUTES = {
# Update doc/user/markdown.rst if you change this! # Update doc/user/markdown.rst if you change this!
} }
ALLOWED_PROTOCOLS = {'http', 'https', 'mailto', 'tel'} ALLOWED_PROTOCOLS = ['http', 'https', 'mailto', 'tel']
URL_RE = SimpleLazyObject(lambda: build_url_re(tlds=sorted(tld_set, key=len, reverse=True))) URL_RE = SimpleLazyObject(lambda: build_url_re(tlds=sorted(tld_set, key=len, reverse=True)))
@@ -211,9 +211,9 @@ class CleanPostprocessor(Postprocessor):
def run(self, text): def run(self, text):
return bleach.clean( return bleach.clean(
text, text,
tags=set(self.tags), tags=self.tags,
attributes=self.attributes, attributes=self.attributes,
protocols=set(self.protocols), protocols=self.protocols,
strip=self.strip strip=self.strip
) )
@@ -305,11 +305,10 @@ def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes
source, source,
extensions=[ extensions=[
'markdown.extensions.sane_lists', 'markdown.extensions.sane_lists',
'markdown.extensions.tables',
EmailNl2BrExtension(), EmailNl2BrExtension(),
LinkifyAndCleanExtension( LinkifyAndCleanExtension(
linker, linker,
tags=set(allowed_tags), tags=allowed_tags,
attributes=allowed_attributes, attributes=allowed_attributes,
protocols=ALLOWED_PROTOCOLS, protocols=ALLOWED_PROTOCOLS,
strip=False, strip=False,
@@ -1,42 +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/>.
#
from django import template
from django.utils.html import format_html, mark_safe
register = template.Library()
@register.simple_tag
def textbubble(type, *args, **kwargs):
return format_html(
'<span class="textbubble-{}">{}',
type or "info",
"" if "icon" not in kwargs else format_html(
'<i class="fa fa-{}" aria-hidden="true"></i> ',
kwargs["icon"]
)
)
@register.simple_tag
def endtextbubble():
return mark_safe('</span>')
-2
View File
@@ -67,7 +67,6 @@ class EventSlugBanlistValidator(BanlistValidator):
'_global', '_global',
'__debug__', '__debug__',
'api', 'api',
'storefrontapi',
'events', 'events',
'csp_report', 'csp_report',
'widget', 'widget',
@@ -92,7 +91,6 @@ class OrganizerSlugBanlistValidator(BanlistValidator):
'__debug__', '__debug__',
'about', 'about',
'api', 'api',
'storefrontapi',
'csp_report', 'csp_report',
'widget', 'widget',
'lead', 'lead',
+5 -19
View File
@@ -22,30 +22,16 @@
import pycountry import pycountry
from django.http import JsonResponse from django.http import JsonResponse
from pretix.base.addressvalidation import (
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED,
)
from pretix.base.models.tax import VAT_ID_COUNTRIES
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
def states(request): def states(request):
cc = request.GET.get("country", "DE") cc = request.GET.get("country", "DE")
info = {
'street': {'required': True},
'zipcode': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED},
'city': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED},
'state': {'visible': cc in COUNTRIES_WITH_STATE_IN_ADDRESS, 'required': cc in COUNTRIES_WITH_STATE_IN_ADDRESS},
'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False},
}
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS: if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
return JsonResponse({'data': [], **info, }) return JsonResponse({'data': []})
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc] types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types] statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
return JsonResponse({ return JsonResponse({'data': [
'data': [ {'name': s.name, 'code': s.code[3:]}
{'name': s.name, 'code': s.code[3:]} for s in sorted(statelist, key=lambda s: s.name)
for s in sorted(statelist, key=lambda s: s.name) ]})
],
**info,
})
+1 -1
View File
@@ -40,5 +40,5 @@ class PretixControlConfig(AppConfig):
label = 'pretixcontrol' label = 'pretixcontrol'
def ready(self): def ready(self):
from . import logdisplay # noqa
from .views import dashboards # noqa from .views import dashboards # noqa
from . import logdisplay # noqa
+9 -1
View File
@@ -33,7 +33,9 @@ from django_scopes.forms import (
from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import Gate from pretix.base.models import Gate
from pretix.base.models.checkin import Checkin, CheckinList from pretix.base.models.checkin import Checkin, CheckinList
from pretix.control.forms import ItemMultipleChoiceField from pretix.control.forms import (
ItemMultipleChoiceField, SalesChannelCheckboxSelectMultiple,
)
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
@@ -65,6 +67,10 @@ class CheckinListForm(forms.ModelForm):
kwargs.pop('locales', None) kwargs.pop('locales', None)
super().__init__(**kwargs) super().__init__(**kwargs)
self.fields['limit_products'].queryset = self.event.items.all() self.fields['limit_products'].queryset = self.event.items.all()
self.fields['auto_checkin_sales_channels'].queryset = self.event.organizer.sales_channels.all()
self.fields['auto_checkin_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(
self.event, choices=self.fields['auto_checkin_sales_channels'].widget.choices
)
if not self.event.organizer.gates.exists(): if not self.event.organizer.gates.exists():
del self.fields['gates'] del self.fields['gates']
@@ -96,6 +102,7 @@ class CheckinListForm(forms.ModelForm):
'limit_products', 'limit_products',
'subevent', 'subevent',
'include_pending', 'include_pending',
'auto_checkin_sales_channels',
'allow_multiple_entries', 'allow_multiple_entries',
'allow_entry_after_exit', 'allow_entry_after_exit',
'rules', 'rules',
@@ -118,6 +125,7 @@ class CheckinListForm(forms.ModelForm):
'limit_products': ItemMultipleChoiceField, 'limit_products': ItemMultipleChoiceField,
'gates': SafeModelMultipleChoiceField, 'gates': SafeModelMultipleChoiceField,
'subevent': SafeModelChoiceField, 'subevent': SafeModelChoiceField,
'auto_checkin_sales_channels': SafeModelMultipleChoiceField,
'exit_all_at': NextTimeField, 'exit_all_at': NextTimeField,
} }
+41 -114
View File
@@ -35,7 +35,7 @@
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
from decimal import Decimal from decimal import Decimal
from urllib.parse import urlencode from urllib.parse import urlencode, urlparse
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import pycountry import pycountry
@@ -63,7 +63,6 @@ from pretix.base.forms import (
) )
from pretix.base.models import Event, Organizer, TaxRule, Team from pretix.base.models import Event, Organizer, TaxRule, Team
from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent
from pretix.base.models.tax import TAX_CODE_LISTS
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.services.placeholders import FormPlaceholderMixin from pretix.base.services.placeholders import FormPlaceholderMixin
from pretix.base.settings import ( from pretix.base.settings import (
@@ -77,10 +76,8 @@ from pretix.control.forms import (
) )
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
from pretix.multidomain.models import AlternativeDomainAssignment, KnownDomain from pretix.multidomain.models import KnownDomain
from pretix.multidomain.urlreverse import ( from pretix.multidomain.urlreverse import build_absolute_uri
build_absolute_uri, get_organizer_domain,
)
from pretix.plugins.banktransfer.payment import BankTransfer from pretix.plugins.banktransfer.payment import BankTransfer
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
@@ -139,11 +136,6 @@ class EventWizardBasicsForm(I18nModelForm):
choices=settings.LANGUAGES, choices=settings.LANGUAGES,
label=_("Default language"), label=_("Default language"),
) )
no_taxes = forms.BooleanField(
label=_("I don't want to specify taxes now"),
help_text=_("You can always configure tax rates later."),
required=False,
)
tax_rate = forms.DecimalField( tax_rate = forms.DecimalField(
label=_("Sales tax rate"), label=_("Sales tax rate"),
help_text=_("Do you need to pay sales tax on your tickets? In this case, please enter the applicable tax rate " help_text=_("Do you need to pay sales tax on your tickets? In this case, please enter the applicable tax rate "
@@ -231,11 +223,6 @@ class EventWizardBasicsForm(I18nModelForm):
raise ValidationError({ raise ValidationError({
'timezone': _('Your default locale must be specified.') 'timezone': _('Your default locale must be specified.')
}) })
if not data.get("no_taxes") and not data.get("tax_rate"):
raise ValidationError({
'tax_rate': _('You have not specified a tax rate. If you do not want us to compute sales taxes, please '
'check "{field}" above.').format(field=self.fields["no_taxes"].label)
})
# change timezone # change timezone
zone = ZoneInfo(data.get('timezone')) zone = ZoneInfo(data.get('timezone'))
@@ -366,9 +353,14 @@ class EventUpdateForm(I18nModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.change_slug = kwargs.pop('change_slug', False) self.change_slug = kwargs.pop('change_slug', False)
self.domain = kwargs.pop('domain', False)
kwargs.setdefault('initial', {}) kwargs.setdefault('initial', {})
self.instance = kwargs['instance'] self.instance = kwargs['instance']
if self.domain and self.instance:
initial_domain = self.instance.domains.first()
if initial_domain:
kwargs['initial'].setdefault('domain', initial_domain.domainname)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not self.change_slug: if not self.change_slug:
@@ -377,54 +369,48 @@ class EventUpdateForm(I18nModelForm):
self.fields['location'].widget.attrs['placeholder'] = _( self.fields['location'].widget.attrs['placeholder'] = _(
'Sample Conference Center\nHeidelberg, Germany' 'Sample Conference Center\nHeidelberg, Germany'
) )
if self.domain:
try:
self.fields['domain'] = forms.CharField( self.fields['domain'] = forms.CharField(
max_length=255, max_length=255,
label=_('Domain'), label=_('Custom domain'),
initial=self.instance.domain.domainname,
required=False,
disabled=True,
help_text=_('You can configure this in your organizer settings.')
)
except KnownDomain.DoesNotExist:
domain = get_organizer_domain(self.instance.organizer)
try:
current_domain_assignment = self.instance.alternative_domain_assignment
except AlternativeDomainAssignment.DoesNotExist:
current_domain_assignment = None
self.fields['domain'] = forms.ChoiceField(
label=_('Domain'),
help_text=_('You can add more domains in your organizer account.'),
choices=[('', _('Same as organizer account') + (f" ({domain})" if domain else ""))] + [
(d.domainname, d.domainname) for d in self.instance.organizer.domains.filter(mode=KnownDomain.MODE_ORG_ALT_DOMAIN)
],
initial=current_domain_assignment.domain_id if current_domain_assignment else "",
required=False, 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'].queryset = self.event.organizer.sales_channels.all()
self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={ self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={
'data-inverse-dependency': '<[name$=all_sales_channels]', 'data-inverse-dependency': '<[name$=all_sales_channels]',
}, choices=self.fields['limit_sales_channels'].widget.choices) }, choices=self.fields['limit_sales_channels'].widget.choices)
def clean_domain(self):
d = self.cleaned_data['domain']
if d:
if d == urlparse(settings.SITE_URL).hostname:
raise ValidationError(
_('You cannot choose the base domain of this installation.')
)
if KnownDomain.objects.filter(domainname=d).exclude(event=self.instance.pk).exists():
raise ValidationError(
_('This domain is already in use for a different event or organizer.')
)
return d
def save(self, commit=True): def save(self, commit=True):
instance = super().save(commit) instance = super().save(commit)
try: if self.domain:
current_domain_assignment = instance.alternative_domain_assignment current_domain = instance.domains.first()
except AlternativeDomainAssignment.DoesNotExist: if self.cleaned_data['domain']:
current_domain_assignment = None if current_domain and current_domain.domainname != self.cleaned_data['domain']:
if self.cleaned_data['domain'] and not hasattr(instance, 'domain'): current_domain.delete()
domain = self.instance.organizer.domains.get(mode=KnownDomain.MODE_ORG_ALT_DOMAIN, domainname=self.cleaned_data["domain"]) KnownDomain.objects.create(
AlternativeDomainAssignment.objects.update_or_create( organizer=instance.organizer, event=instance, domainname=self.cleaned_data['domain']
event=instance, )
defaults={ elif not current_domain:
"domain": domain, KnownDomain.objects.create(
} organizer=instance.organizer, event=instance, domainname=self.cleaned_data['domain']
) )
instance.cache.clear() elif current_domain:
elif current_domain_assignment: current_domain.delete()
current_domain_assignment.delete()
instance.cache.clear() instance.cache.clear()
return instance return instance
@@ -1386,7 +1372,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
self.event.meta_values_cached = self.event.meta_values.select_related('property').all() self.event.meta_values_cached = self.event.meta_values.select_related('property').all()
for k, v in self.base_context.items(): for k, v in self.base_context.items():
self._set_field_placeholders(k, v, rich=k.startswith('mail_text_')) self._set_field_placeholders(k, v)
for k, v in list(self.fields.items()): for k, v in list(self.fields.items()):
if k.endswith('_attendee') and not event.settings.attendee_emails_asked: if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
@@ -1505,11 +1491,6 @@ class TaxRuleLineForm(I18nForm):
('require_approval', _('Order requires approval')), ('require_approval', _('Order requires approval')),
], ],
) )
code = forms.ChoiceField(
label=_("Tax code"),
choices=[("", _("Default tax code")), *TAX_CODE_LISTS],
required=False,
)
rate = forms.DecimalField( rate = forms.DecimalField(
label=_('Deviating tax rate'), label=_('Deviating tax rate'),
max_digits=10, decimal_places=2, max_digits=10, decimal_places=2,
@@ -1524,43 +1505,6 @@ class TaxRuleLineForm(I18nForm):
}) })
) )
def __init__(self, *args, **kwargs):
self.parent_form = kwargs.pop("parent_form")
super().__init__(*args, **kwargs)
def clean(self):
d = super().clean()
parent_code = self.parent_form.cleaned_data.get("code")
parent_rate = self.parent_form.cleaned_data.get("rate")
code = d.get("code") or parent_code
rate = d.get("rate")
if rate is None:
rate = parent_rate
if d.get("action") in ("reverse", "no", "block") and d.get("rate"):
raise ValidationError(_("A combination of this calculation mode with a non-zero tax rate does not make sense."))
if d.get("action") == "reverse" and d.get("code") and code != "AE":
# Reverse charge but code is not reverse charge -- this is the one case we ignore if the "default code"
# is used because it is the one scenario we can auto-fix
raise ValidationError(_("This combination of calculation mode and tax code does not make sense."))
if d.get("action") == "no" and code and code.split("/")[0] in ("S", "AE", "L", "M", "B"):
# No VAT but code indicates VAT
raise ValidationError(_("This combination of calculation mode and tax code does not make sense."))
if d.get("action") == "vat" and code and rate != Decimal("0.00") and code.split("/")[0] in ("O", "E", "Z", "G", "K", "AE"):
# VAT, but code indicates exempt
raise ValidationError(_("A combination of this tax code with a non-zero tax rate does not make sense."))
if d.get("action") == "vat" and code and rate == Decimal("0.00") and code.split("/")[0] in ("S", "L", "M", "B"):
# no VAT, but code indicates non-exempt
raise ValidationError(_("A combination of this tax code with a zero tax rate does not make sense."))
return d
class I18nBaseFormSet(I18nFormSetMixin, forms.BaseFormSet): class I18nBaseFormSet(I18nFormSetMixin, forms.BaseFormSet):
# compatibility shim for django-i18nfield library # compatibility shim for django-i18nfield library
@@ -1572,16 +1516,8 @@ class I18nBaseFormSet(I18nFormSetMixin, forms.BaseFormSet):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
class BaseTaxRuleLineFormSet(I18nBaseFormSet):
def __init__(self, *args, **kwargs):
self.parent_form = kwargs.pop('parent_form')
super().__init__(*args, **kwargs)
self.form_kwargs['parent_form'] = self.parent_form
TaxRuleLineFormSet = formset_factory( TaxRuleLineFormSet = formset_factory(
TaxRuleLineForm, formset=BaseTaxRuleLineFormSet, TaxRuleLineForm, formset=I18nBaseFormSet,
can_order=True, can_delete=True, extra=0 can_order=True, can_delete=True, extra=0
) )
@@ -1589,16 +1525,7 @@ TaxRuleLineFormSet = formset_factory(
class TaxRuleForm(I18nModelForm): class TaxRuleForm(I18nModelForm):
class Meta: class Meta:
model = TaxRule model = TaxRule
fields = [ fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes']
'name',
'rate',
'price_includes_tax',
'code',
'eu_reverse_charge',
'home_country',
'internal_name',
'keep_gross_if_rate_changes'
]
class WidgetCodeForm(forms.Form): class WidgetCodeForm(forms.Form):
+4 -4
View File
@@ -549,7 +549,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
) )
email = forms.CharField( email = forms.CharField(
required=False, required=False,
label=_('Email address') label=_('E-mail address')
) )
comment = forms.CharField( comment = forms.CharField(
required=False, required=False,
@@ -563,7 +563,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
email_known_to_work = forms.NullBooleanField( email_known_to_work = forms.NullBooleanField(
required=False, required=False,
widget=FilterNullBooleanSelect, widget=FilterNullBooleanSelect,
label=_('Email address verified'), label=_('E-mail address verified'),
) )
total = forms.DecimalField( total = forms.DecimalField(
localize=True, localize=True,
@@ -648,7 +648,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
) )
self.fields['attendee_email'] = forms.CharField( self.fields['attendee_email'] = forms.CharField(
required=False, required=False,
label=_('Attendee email address') label=_('Attendee e-mail address')
) )
self.fields['attendee_address_company'] = forms.CharField( self.fields['attendee_address_company'] = forms.CharField(
required=False, required=False,
@@ -1967,7 +1967,7 @@ class CheckinListAttendeeFilterForm(FilterForm):
if s == '1': if s == '1':
qs = qs.filter(last_entry__isnull=False) qs = qs.filter(last_entry__isnull=False)
elif s == '2': elif s == '2':
qs = self.list._filter_positions_inside(qs) qs = qs.filter(pk__in=self.list.positions_inside.values_list('pk'))
elif s == '3': elif s == '3':
qs = qs.filter(last_entry__isnull=False).filter( qs = qs.filter(last_entry__isnull=False).filter(
Q(last_exit__isnull=False) & Q(last_exit__gte=F('last_entry')) Q(last_exit__isnull=False) & Q(last_exit__gte=F('last_entry'))
+1 -1
View File
@@ -128,7 +128,7 @@ class UpdateSettingsForm(SettingsForm):
) )
update_check_email = forms.EmailField( update_check_email = forms.EmailField(
required=False, required=False,
label=_("Email notifications"), label=_("E-mail notifications"),
help_text=_("We will notify you at this address if we detect that a new update is available. This " help_text=_("We will notify you at this address if we detect that a new update is available. This "
"address will not be transmitted to pretix.eu, the emails will be sent by this server " "address will not be transmitted to pretix.eu, the emails will be sent by this server "
"locally.") "locally.")
+1 -46
View File
@@ -490,9 +490,7 @@ class OrderPositionChangeForm(forms.Form):
) )
operation_secret = forms.BooleanField( operation_secret = forms.BooleanField(
required=False, required=False,
label=_('Generate a new secret'), label=_('Generate a new secret')
help_text=_('This affects both the ticket secret (often used as a QR code) as well as the link used to '
'individually access the ticket.')
) )
operation_cancel = forms.BooleanField( operation_cancel = forms.BooleanField(
required=False, required=False,
@@ -611,49 +609,6 @@ class OrderFeeChangeForm(forms.Form):
change_decimal_field(self.fields['value'], instance.order.event.currency) change_decimal_field(self.fields['value'], instance.order.event.currency)
class OrderFeeAddForm(forms.Form):
fee_type = forms.ChoiceField(choices=OrderFee.FEE_TYPES)
value = forms.DecimalField(
max_digits=13, decimal_places=2,
localize=True,
label=_('Price'),
help_text=_("including all taxes"),
)
tax_rule = forms.ModelChoiceField(
TaxRule.objects.none(),
required=False,
)
description = forms.CharField(required=False)
def __init__(self, *args, **kwargs):
order = kwargs.pop('order')
super().__init__(*args, **kwargs)
self.fields['tax_rule'].queryset = order.event.tax_rules.all()
change_decimal_field(self.fields['value'], order.event.currency)
class OrderFeeAddFormset(forms.BaseFormSet):
def __init__(self, *args, **kwargs):
self.order = kwargs.pop('order', None)
super().__init__(*args, **kwargs)
def _construct_form(self, i, **kwargs):
kwargs['order'] = self.order
return super()._construct_form(i, **kwargs)
@property
def empty_form(self):
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
use_required_attribute=False,
order=self.order,
)
self.add_fields(form, None)
return form
class OrderContactForm(forms.ModelForm): class OrderContactForm(forms.ModelForm):
regenerate_secrets = forms.BooleanField(required=False, label=_('Invalidate secrets'), regenerate_secrets = forms.BooleanField(required=False, label=_('Invalidate secrets'),
help_text=_('Regenerates the order and ticket secrets. You will ' help_text=_('Regenerates the order and ticket secrets. You will '
+42 -87
View File
@@ -133,108 +133,63 @@ class OrganizerDeleteForm(forms.Form):
class OrganizerUpdateForm(OrganizerForm): class OrganizerUpdateForm(OrganizerForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.domain = kwargs.pop('domain', False)
self.change_slug = kwargs.pop('change_slug', False) self.change_slug = kwargs.pop('change_slug', False)
kwargs.setdefault('initial', {}) kwargs.setdefault('initial', {})
self.instance = kwargs['instance'] self.instance = kwargs['instance']
if self.domain and self.instance:
initial_domain = self.instance.domains.filter(event__isnull=True).first()
if initial_domain:
kwargs['initial'].setdefault('domain', initial_domain.domainname)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not self.change_slug: if not self.change_slug:
self.fields['slug'].widget.attrs['readonly'] = 'readonly' self.fields['slug'].widget.attrs['readonly'] = 'readonly'
if self.domain:
self.fields['domain'] = forms.CharField(
max_length=255,
label=_('Custom domain'),
required=False,
help_text=_('You need to configure the custom domain in the webserver beforehand.')
)
def clean_domain(self):
d = self.cleaned_data['domain']
if d:
if d == urlparse(settings.SITE_URL).hostname:
raise ValidationError(
_('You cannot choose the base domain of this installation.')
)
if KnownDomain.objects.filter(domainname=d).exclude(organizer=self.instance.pk,
event__isnull=True).exists():
raise ValidationError(
_('This domain is already in use for a different event or organizer.')
)
return d
def clean_slug(self): def clean_slug(self):
if self.change_slug: if self.change_slug:
return self.cleaned_data['slug'] return self.cleaned_data['slug']
return self.instance.slug return self.instance.slug
def save(self, commit=True):
instance = super().save(commit)
class KnownDomainForm(forms.ModelForm): if self.domain:
class Meta: current_domain = instance.domains.filter(event__isnull=True).first()
model = KnownDomain if self.cleaned_data['domain']:
fields = ["domainname", "mode", "event"] if current_domain and current_domain.domainname != self.cleaned_data['domain']:
field_classes = { current_domain.delete()
"event": SafeModelChoiceField, KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain'])
} elif not current_domain:
KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain'])
elif current_domain:
current_domain.delete()
instance.cache.clear()
for ev in instance.events.all():
ev.cache.clear()
def __init__(self, *args, **kwargs): return instance
self.organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
self.fields["event"].queryset = self.organizer.events.all()
if self.instance and self.instance.pk:
self.fields["domainname"].widget.attrs['readonly'] = 'readonly'
def clean_domainname(self):
if self.instance and self.instance.pk:
return self.instance.domainname
d = self.cleaned_data['domainname']
if d:
if d == urlparse(settings.SITE_URL).hostname:
raise ValidationError(
_('You cannot choose the base domain of this installation.')
)
if KnownDomain.objects.filter(domainname=d).exclude(organizer=self.instance.organizer).exists():
raise ValidationError(
_('This domain is already in use for a different event or organizer.')
)
return d
def clean(self):
d = super().clean()
if d["mode"] == KnownDomain.MODE_ORG_DOMAIN and d["event"]:
raise ValidationError(
_("Do not choose an event for this mode.")
)
if d["mode"] == KnownDomain.MODE_ORG_ALT_DOMAIN and d["event"]:
raise ValidationError(
_("Do not choose an event for this mode. You can assign events to this domain in event settings.")
)
if d["mode"] == KnownDomain.MODE_EVENT_DOMAIN and not d["event"]:
raise ValidationError(
_("You need to choose an event.")
)
return d
class BaseKnownDomainFormSet(forms.BaseInlineFormSet):
def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
def _construct_form(self, i, **kwargs):
kwargs['organizer'] = self.organizer
return super()._construct_form(i, **kwargs)
@property
def empty_form(self):
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
use_required_attribute=False,
organizer=self.organizer,
)
self.add_fields(form, None)
return form
def clean(self):
super().clean()
data = [f.cleaned_data for f in self.forms]
if len([d for d in data if d.get("mode") == KnownDomain.MODE_ORG_DOMAIN and not d.get("DELETE")]) > 1:
raise ValidationError(_("You may set only one organizer domain."))
return data
KnownDomainFormset = inlineformset_factory(
Organizer, KnownDomain,
KnownDomainForm,
formset=BaseKnownDomainFormSet,
can_order=False, can_delete=True, extra=0
)
class SafeOrderPositionChoiceField(forms.ModelChoiceField): class SafeOrderPositionChoiceField(forms.ModelChoiceField):
+1 -1
View File
@@ -40,7 +40,7 @@ class StaffSessionForm(forms.ModelForm):
class UserEditForm(forms.ModelForm): class UserEditForm(forms.ModelForm):
error_messages = { error_messages = {
'duplicate_identifier': _("There already is an account associated with this email address. " 'duplicate_identifier': _("There already is an account associated with this e-mail address. "
"Please choose a different one."), "Please choose a different one."),
'pw_mismatch': _("Please enter the same password twice"), 'pw_mismatch': _("Please enter the same password twice"),
} }
+1 -1
View File
@@ -613,7 +613,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
if logentry.action_type == 'pretix.event.order.consent': if logentry.action_type == 'pretix.event.order.consent':
return _('The user confirmed the following message: "{}"').format( return _('The user confirmed the following message: "{}"').format(
bleach.clean(logentry.parsed_data.get('msg'), tags=set(), strip=True) bleach.clean(logentry.parsed_data.get('msg'), tags=[], strip=True)
) )
if logentry.action_type == 'pretix.event.order.canceled': if logentry.action_type == 'pretix.event.order.canceled':
+12 -14
View File
@@ -78,7 +78,7 @@ def get_event_navigation(request: HttpRequest):
'active': url.url_name == 'event.settings.tickets', 'active': url.url_name == 'event.settings.tickets',
}, },
{ {
'label': _('Email'), 'label': _('E-mail'),
'url': reverse('control:event.settings.mail', kwargs={ 'url': reverse('control:event.settings.mail', kwargs={
'event': request.event.slug, 'event': request.event.slug,
'organizer': request.event.organizer.slug, 'organizer': request.event.organizer.slug,
@@ -132,6 +132,16 @@ def get_event_navigation(request: HttpRequest):
'icon': 'wrench', 'icon': 'wrench',
'children': event_settings 'children': event_settings
}) })
if request.event.has_subevents:
nav.append({
'label': pgettext_lazy('subevent', 'Dates'),
'url': reverse('control:event.subevents', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': ('event.subevent' in url.url_name),
'icon': 'calendar',
})
if 'can_change_items' in request.eventpermset: if 'can_change_items' in request.eventpermset:
nav.append({ nav.append({
@@ -187,18 +197,6 @@ def get_event_navigation(request: HttpRequest):
] ]
}) })
if 'can_change_event_settings' in request.eventpermset:
if request.event.has_subevents:
nav.append({
'label': pgettext_lazy('subevent', 'Dates'),
'url': reverse('control:event.subevents', kwargs={
'event': request.event.slug,
'organizer': request.event.organizer.slug,
}),
'active': ('event.subevent' in url.url_name),
'icon': 'calendar',
})
if 'can_view_orders' in request.eventpermset: if 'can_view_orders' in request.eventpermset:
children = [ children = [
{ {
@@ -498,7 +496,7 @@ def get_organizer_navigation(request):
'active': url.url_name.startswith('organizer.propert'), 'active': url.url_name.startswith('organizer.propert'),
}, },
{ {
'label': _('Email'), 'label': _('E-mail'),
'url': reverse('control:organizer.settings.mail', kwargs={ 'url': reverse('control:organizer.settings.mail', kwargs={
'organizer': request.organizer.slug, 'organizer': request.organizer.slug,
}), }),
@@ -61,7 +61,6 @@
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script> <script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
<script type="text/javascript" src="{% static "lightbox/js/lightbox.js" %}"></script> <script type="text/javascript" src="{% static "lightbox/js/lightbox.js" %}"></script>
<script type="text/javascript" src="{% static "are-you-sure/jquery.are-you-sure.js" %}"></script> <script type="text/javascript" src="{% static "are-you-sure/jquery.are-you-sure.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/addressform.js" %}"></script>
{% endcompress %} {% endcompress %}
{{ html_head|safe }} {{ html_head|safe }}

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