Compare commits

..

85 Commits

Author SHA1 Message Date
Mira Weller
2848d85511 Tests python3.11 compat 2024-10-14 13:20:07 +02:00
Mira Weller
21707f8407 Fix test case 2024-10-14 12:06:27 +02:00
Mira Weller
711479bfed fix display dependency in category settings 2024-10-14 10:23:34 +02:00
Mira Weller
c401e54831 improve formatting for subevents 2024-10-14 10:23:34 +02:00
Mira
27cfd4dbdd Update src/pretix/control/templates/pretixcontrol/items/category.html
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-10-11 13:59:22 +02:00
Mira Weller
e5ab1b08a2 reformat code 2024-10-11 11:34:13 +02:00
Mira
7d22fe1a54 Update src/pretix/static/pretixcontrol/js/ui/main.js
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-10-11 11:11:40 +02:00
Mira
6c52cc8157 Apply suggestions from code review
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-10-11 11:11:08 +02:00
Mira Weller
0191d258ab accessibility 2024-10-09 12:17:53 +02:00
Mira Weller
b312a21e5e remove erroneous paste 2024-10-09 12:15:30 +02:00
Mira Weller
f9ca9a781e one loop less 2024-10-09 12:13:11 +02:00
Mira Weller
a314d219b8 fix iteration 2024-10-09 12:01:47 +02:00
Mira Weller
9a6756ce5d show discount notice on all categories with an available discount, not only those with cross_selling_condition=discount 2024-10-09 11:52:46 +02:00
Mira Weller
b3ca02d8e5 change style of discount notice 2024-10-09 11:38:04 +02:00
Mira Weller
88936b5e7a improve wording of category types 2024-10-09 11:37:41 +02:00
Mira
6b9eefd231 Update src/pretix/static/pretixpresale/scss/_checkout.scss
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-10-07 21:19:02 +02:00
Mira Weller
6587cca608 add discount label 2024-10-02 12:12:43 +02:00
Mira Weller
3856095088 improve category type label for CROSS_SELLING_MODES=both 2024-10-02 09:57:16 +02:00
Mira Weller
a84a27cc0b transfer cross_selling_match_products on event clone 2024-10-02 09:53:32 +02:00
Mira Weller
7d6b2d6df8 better description for CROSS_SELLING_CONDITION=discounts 2024-10-02 09:31:59 +02:00
Mira Weller
d4f997c345 describe new category options in docs 2024-10-02 09:29:05 +02:00
Mira Weller
bef88bf0d0 fix SUBEVENT_MODE_DISTINCT discounts 2024-10-01 18:46:44 +02:00
Mira Weller
159717c19f formatting 2024-10-01 18:36:24 +02:00
Mira Weller
5e9b5a9c24 support for variations 2024-10-01 18:35:07 +02:00
Mira Weller
52849f8fdd bugfix 2024-10-01 18:34:57 +02:00
Mira Weller
4f1ee82c4f remove unused vars 2024-10-01 18:15:59 +02:00
Mira Weller
d5e480b7fd fix cross selling recommendations for SUBEVENT_MODE_SAME discounts 2024-10-01 18:14:46 +02:00
Mira Weller
fd6ae65f23 add another test case 2024-10-01 18:14:17 +02:00
Mira Weller
94733135f0 add failing test for SUBEVENT_MODE_SAME 2024-10-01 17:29:25 +02:00
Mira Weller
e51927c4e0 update query numbers 2024-10-01 16:54:24 +02:00
Mira Weller
11c7c950cb add subevent support to test framework 2024-10-01 16:52:47 +02:00
Mira Weller
0695365526 improved layout 2024-10-01 15:05:14 +02:00
Mira
0947476b41 Update src/pretix/base/services/cross_selling.py 2024-09-30 21:19:24 +02:00
Mira Weller
7a4aead22d rename id_prefix to form_prefix 2024-09-30 21:16:17 +02:00
Mira
44de4bb26b Apply suggestions from code review
Co-authored-by: Raphael Michel <michel@rami.io>
2024-09-30 21:13:19 +02:00
Mira Weller
d879637b73 update query counts 2024-09-30 16:53:34 +02:00
Mira Weller
b5fc227fca rebase migration 2024-09-27 16:36:54 +02:00
Mira Weller
1fb1696863 Merge remote-tracking branch 'origin/master' into cross-selling 2024-09-27 16:27:47 +02:00
Mira Weller
939d50061b Merge remote-tracking branch 'origin/master' into cross-selling
# Conflicts:
#	src/pretix/presale/templates/pretixpresale/event/fragment_product_list.html
2024-08-29 13:49:28 +02:00
Mira Weller
c1a5e8d912 codestyle 2024-08-01 23:01:11 +02:00
Mira Weller
106026045e fix sqlite compat 2024-08-01 22:59:51 +02:00
Mira Weller
badbb64f4f add validation to ItemCategorySerializer 2024-07-26 20:05:03 +02:00
Mira Weller
537a0993b0 add more detailed description of collect_potential_discounts parameter 2024-07-26 19:40:28 +02:00
Mira Weller
9337ad1f70 add prefetching, add test cases checking number of queries 2024-07-25 14:37:19 +02:00
Mira Weller
5087e654e2 cleanup 2024-07-22 11:26:14 +02:00
Mira Weller
dac2209243 rebase migration 2024-07-19 14:21:24 +02:00
Mira Weller
9cb708cf6f fix typing 2024-07-19 14:21:24 +02:00
Mira Weller
e18c699529 formatting, refactoring 2024-07-19 14:21:24 +02:00
Mira Weller
9c3150ccde add license header 2024-07-19 14:21:24 +02:00
Mira Weller
923798ea5f fix cross-selling recommendation logic bug 2024-07-19 14:21:24 +02:00
Mira Weller
b8d2372cf6 store apply_discounts result for use in test cases 2024-07-19 14:21:24 +02:00
Mira Weller
e01e9151c3 correct type annotation 2024-07-19 14:21:24 +02:00
Mira Weller
09398ad7c7 add more test cases 2024-07-19 14:21:24 +02:00
Mira Weller
d1de8f5863 remove redundant check
(this is already checked using the 'if not self.condition_min_count or self.condition_min_value' condition directly above)
2024-07-19 14:21:24 +02:00
Mira Weller
bee0eaa2fa add some unit tests for cross-selling logic 2024-07-19 14:21:24 +02:00
Mira Weller
ac771b8ca8 refactor cross-selling logic into its own module 2024-07-19 14:21:24 +02:00
Mira Weller
cb635b2c37 add TODO for known error 2024-07-19 14:21:24 +02:00
Mira Weller
3fe6919bef add typing 2024-07-19 14:21:24 +02:00
Mira Weller
8cfb69c265 clarifications 2024-07-19 14:21:24 +02:00
Mira Weller
77fc13605e new sales channel compat 2024-07-19 14:21:24 +02:00
Mira Weller
a95976ed50 translate comments 2024-07-19 14:21:24 +02:00
Mira Weller
2e3a611498 isort 2024-07-19 14:21:24 +02:00
Mira Weller
6bf16f1510 Use namedtuple 2024-07-19 14:21:24 +02:00
Mira Weller
d29b183801 fix test case 2024-07-19 14:21:24 +02:00
Mira Weller
188ef5f463 isort 2024-07-19 14:21:24 +02:00
Mira Weller
a7e292ea58 fix case 2024-07-19 14:21:24 +02:00
Mira Weller
d04b855cce category_type refactoring 2024-07-19 14:21:24 +02:00
Mira Weller
01b535a0af cache potential discount information
relevant if shop has multiple categories with cross_selling_condition=discounts
2024-07-19 14:21:23 +02:00
Mira Weller
d9f31aae8c better var names 2024-07-19 14:21:23 +02:00
Mira Weller
715347cb35 filter non-buyable items from list 2024-07-19 14:21:23 +02:00
Mira Weller
32cc45f19a make cross-selling-applicable more specific and cache state
(only show if really applicable, e.g. don't show if product can't be bought due to order_max)
2024-07-19 14:21:23 +02:00
Mira Weller
cadf8dd39d better order_max handling 2024-07-19 14:21:23 +02:00
Mira Weller
b136ac37c8 Hide addon text if only cross-selling, no addons in addon step 2024-07-19 14:21:23 +02:00
Mira Weller
8627eefebc first round of cleanup 2024-07-19 14:21:23 +02:00
Mira Weller
e71d3e21ca display discounted prices, limit number of products according to discount rule 2024-07-19 14:21:23 +02:00
Mira Weller
a18adb8a88 wip 2024-07-19 14:21:23 +02:00
Mira Weller
f56d67ec9c add todo notes 2024-07-19 14:21:23 +02:00
Mira Weller
c156581ad1 fix discount collection (?) 2024-07-19 14:21:23 +02:00
Mira Weller
8791280d0b Implement discount prediction (very WIP!) 2024-07-19 14:21:23 +02:00
Mira Weller
97925e2d77 migration, show category type in ui 2024-07-19 14:21:23 +02:00
Mira Weller
a0d865cf4f implement cross_selling_visibility "always" and "products" 2024-07-19 14:21:21 +02:00
Mira Weller
2cd5d87da4 Refactor fragment_product_list.html 2024-07-19 14:21:00 +02:00
Mira Weller
e5c7c85e75 Allow adding products from multiple subevents to the cart at once 2024-07-19 14:21:00 +02:00
Mira Weller
3e0992a7a7 Make the comments in cart.py less incorrect 2024-07-19 14:21:00 +02:00
Mira Weller
f19e5bef72 Cross-selling category configuration 2024-07-19 14:21:00 +02:00
63 changed files with 2661 additions and 2081 deletions

View File

@@ -294,10 +294,6 @@ Example::
setting is not provided, pretix will generate a random secret on the first start
and will store it in the filesystem for later usage.
``secret_fallback0`` ... ``secret_fallback9``
Prior versions of the secret to be used by Django for signing and verification purposes that will still
be accepted but no longer be used for new signing.
``debug``
Whether or not to run in debug mode. Default is ``False``.

View File

@@ -23,6 +23,22 @@ position integer An integer, use
is_addon boolean If ``true``, items within this category are not on sale
on their own but the category provides a source for
defining add-ons for other products.
cross_selling_mode string If ``null``, cross-selling is disabled for this category.
If ``"only"``, it is only visible in the cross-selling
step.
If ``"both"``, it is visible on the normal index page
as well.
Only available if ``is_addon`` is ``false``.
cross_selling_condition string Only relevant if ``cross_selling_mode`` is not ``null``.
If ``"always"``, always show in cross-selling step.
If ``"products"``, only show if the cart contains one of
the products listed in ``cross_selling_match_products``.
If ``"discounts"``, only show products that qualify for
a discount according to discount rules.
cross_selling_match_products list of integer Only relevant if ``cross_selling_condition`` is
``"products"``. Internal ID of the items of which at
least one needs to be in the cart for this category to
be shown.
===================================== ========================== =======================================================
@@ -60,7 +76,10 @@ Endpoints
"internal_name": "",
"description": {"en": "Tickets are what you need to get in."},
"position": 1,
"is_addon": false
"is_addon": false,
"cross_selling_mode": null,
"cross_selling_condition": null,
"cross_selling_match_products": []
}
]
}
@@ -102,7 +121,10 @@ Endpoints
"internal_name": "",
"description": {"en": "Tickets are what you need to get in."},
"position": 1,
"is_addon": false
"is_addon": false,
"cross_selling_mode": null,
"cross_selling_condition": null,
"cross_selling_match_products": []
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -130,7 +152,10 @@ Endpoints
"internal_name": "",
"description": {"en": "Tickets are what you need to get in."},
"position": 1,
"is_addon": false
"is_addon": false,
"cross_selling_mode": null,
"cross_selling_condition": null,
"cross_selling_match_products": []
}
**Example response**:
@@ -147,7 +172,10 @@ Endpoints
"internal_name": "",
"description": {"en": "Tickets are what you need to get in."},
"position": 1,
"is_addon": false
"is_addon": false,
"cross_selling_mode": null,
"cross_selling_condition": null,
"cross_selling_match_products": []
}
:param organizer: The ``slug`` field of the organizer of the event to create a category for
@@ -193,7 +221,10 @@ Endpoints
"internal_name": "",
"description": {"en": "Tickets are what you need to get in."},
"position": 1,
"is_addon": true
"is_addon": true,
"cross_selling_mode": null,
"cross_selling_condition": null,
"cross_selling_match_products": []
}
:param organizer: The ``slug`` field of the organizer to modify

View File

@@ -51,7 +51,7 @@ Endpoints
"results": [
{
"identifier": "web",
"label": {
"name": {
"en": "Online shop"
},
"type": "web",
@@ -88,7 +88,7 @@ Endpoints
{
"identifier": "web",
"label": {
"name": {
"en": "Online shop"
},
"type": "web",
@@ -116,7 +116,7 @@ Endpoints
{
"identifier": "api.custom",
"label": {
"name": {
"en": "Custom integration"
},
"type": "api",
@@ -133,7 +133,7 @@ Endpoints
{
"identifier": "api.custom",
"label": {
"name": {
"en": "Custom integration"
},
"type": "api",
@@ -178,7 +178,7 @@ Endpoints
{
"identifier": "web",
"label": {
"name": {
"en": "Online shop"
},
"type": "web",

View File

@@ -14,7 +14,7 @@ Core
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification,
item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter,
register_ticket_secret_generators, gift_card_transaction_display,
register_text_placeholders, register_mail_placeholders, device_info_updated
register_text_placeholders, register_mail_placeholders
Order events
""""""""""""

View File

@@ -29,8 +29,8 @@ item_assignments list of objects Products this l
===================================== ========================== =======================================================
Layout endpoints
----------------
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/
@@ -268,75 +268,5 @@ Layout endpoints
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
Ticket rendering endpoint
-----------------------------
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/ticketpdfrenderer/render_batch/
With this API call, you can instruct the system to render a set of tickets into one combined PDF file. To specify
which tickets to render, you need to submit a list of "parts". For every part, the following fields are supported:
* ``orderposition`` (``integer``, required): The ID of the order position to render.
* ``override_channel`` (``string``, optional): The sales channel ID to be used for layout selection instead of the
original channel of the order.
* ``override_layout`` (``integer``, optional): The ticket layout ID to be used instead of the auto-selected one.
If your input parameters validate correctly, a ``202 Accepted`` status code is returned.
The body points you to the download URL of the result. Running a ``GET`` request on that result URL will
yield one of the following status codes:
* ``200 OK`` The export succeeded. The body will be your resulting file. Might be large!
* ``409 Conflict`` Your export is still running. The body will be JSON with the structure ``{"status": "running"}``. ``status`` can be ``waiting`` before the task is actually being processed. Please retry, but wait at least one second before you do.
* ``410 Gone`` Running the export has failed permanently. The body will be JSON with the structure ``{"status": "failed", "message": "Error message"}``
* ``404 Not Found`` The export does not exist / is expired.
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
.. note:: To avoid performance issues, a maximum number of 1000 parts is currently allowed.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/ticketpdfrenderer/render_batch/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"parts": [
{
"orderposition": 55412
},
{
"orderposition": 55412,
"override_channel": "web"
},
{
"orderposition": 55412,
"override_layout": 56
}
]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"download": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/ticketpdfrenderer/download/29891ede-196f-4942-9e26-d055a36e98b8/3f279f13-c198-4137-b49b-9b360ce9fcce/"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 202: no error
:statuscode 400: Invalid input options
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/pdf-layout.schema.json

View File

@@ -80,18 +80,18 @@ dependencies = [
"psycopg2-binary",
"pycountry",
"pycparser==2.22",
"pycryptodome==3.21.*",
"pycryptodome==3.20.*",
"pypdf==5.0.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
"pytz",
"pytz-deprecation-shim==0.1.*",
"pyuca",
"qrcode==8.0",
"redis==5.1.*",
"qrcode==7.4.*",
"redis==5.0.*",
"reportlab==4.2.*",
"requests==2.31.*",
"sentry-sdk==2.15.*",
"sentry-sdk==2.14.*",
"sepaxml==2.6.*",
"slimit",
"stripe==7.9.*",

View File

@@ -88,20 +88,16 @@ class SalesChannelMigrationMixin:
}
if data.get("all_sales_channels") and set(data["sales_channels"]) != all_channels:
raise ValidationError({
"limit_sales_channels": [
"If 'all_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to "
"the list of all sales channels."
]
})
raise ValidationError(
"If 'all_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to "
"the list of all sales channels."
)
if data.get("limit_sales_channels") and set(data["sales_channels"]) != set(data["limit_sales_channels"]):
raise ValidationError({
"limit_sales_channels": [
"If 'limit_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to "
"the same list."
]
})
raise ValidationError(
"If 'limit_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to "
"the same list."
)
if data["sales_channels"] == all_channels:
data["all_sales_channels"] = True

View File

@@ -441,7 +441,22 @@ class ItemCategorySerializer(I18nAwareModelSerializer):
class Meta:
model = ItemCategory
fields = ('id', 'name', 'internal_name', 'description', 'position', 'is_addon')
fields = (
'id', 'name', 'internal_name', 'description', 'position',
'is_addon', 'cross_selling_mode',
'cross_selling_condition', 'cross_selling_match_products'
)
def validate(self, data):
data = super().validate(data)
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
if full_data.get('is_addon') and full_data.get('cross_selling_mode'):
raise ValidationError('is_addon and cross_selling_mode are mutually exclusive')
return data
class QuestionOptionSerializer(I18nAwareModelSerializer):

View File

@@ -200,11 +200,6 @@ class UpdateView(APIView):
device.save()
device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device)
from ...base.signals import device_info_updated
device_info_updated.send(
sender=Device, old_device=request.auth, new_device=device
)
serializer = DeviceSerializer(device)
return Response(serializer.data)

View File

@@ -0,0 +1,32 @@
# Generated by Django 4.2.11 on 2024-05-27 13:19
from django.db import migrations, models
import pretix.base.models.orders
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0270_historicpassword"),
]
operations = [
migrations.AddField(
model_name="itemcategory",
name="cross_selling_condition",
field=models.CharField(null=True, max_length=10),
),
migrations.AddField(
model_name="itemcategory",
name="cross_selling_mode",
field=models.CharField(null=True, max_length=5),
),
migrations.AddField(
model_name="itemcategory",
name="cross_selling_match_products",
field=models.ManyToManyField(
related_name="matched_by_cross_selling_categories", to="pretixbase.item"
),
),
]

View File

@@ -571,23 +571,13 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
def get_session_auth_hash(self):
"""
Return an HMAC that needs to be the same throughout the session, used e.g. for forced
logout after every password change.
"""
return self._get_session_auth_hash(secret=settings.SECRET_KEY)
def get_session_auth_fallback_hash(self):
for fallback_secret in settings.SECRET_KEY_FALLBACKS:
yield self._get_session_auth_hash(secret=fallback_secret)
def _get_session_auth_hash(self, secret):
"""
Return an HMAC that needs to
"""
key_salt = "pretix.base.models.User.get_session_auth_hash"
payload = self.password
payload += self.email
payload += self.session_token
return salted_hmac(key_salt, payload, secret=secret).hexdigest()
return salted_hmac(key_salt, payload).hexdigest()
def update_session_token(self):
self.session_token = generate_session_token()

View File

@@ -219,24 +219,13 @@ class Customer(LoggedModel):
return is_password_usable(self.password)
def get_session_auth_hash(self):
"""
Return an HMAC that needs to be the same throughout the session, used e.g. for forced
logout after every password change.
"""
return self._get_session_auth_hash(secret=settings.SECRET_KEY)
def get_session_auth_fallback_hash(self):
for fallback_secret in settings.SECRET_KEY_FALLBACKS:
yield self._get_session_auth_hash(secret=fallback_secret)
def _get_session_auth_hash(self, secret):
"""
Return an HMAC of the password field.
"""
key_salt = "pretix.base.models.customers.Customer.get_session_auth_hash"
payload = self.password
payload += self.email
return salted_hmac(key_salt, payload, secret=secret).hexdigest()
return salted_hmac(key_salt, payload).hexdigest()
def get_email_context(self):
from pretix.base.settings import get_name_parts_localized

View File

@@ -20,11 +20,11 @@
# <https://www.gnu.org/licenses/>.
#
from collections import defaultdict
from collections import defaultdict, namedtuple
from decimal import Decimal
from itertools import groupby
from math import ceil
from typing import Dict, Optional, Tuple
from math import ceil, inf
from typing import Dict
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
@@ -36,6 +36,8 @@ from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
PositionInfo = namedtuple('PositionInfo', ['item_id', 'subevent_id', 'line_price_gross', 'is_addon_to', 'voucher_discount'])
class Discount(LoggedModel):
SUBEVENT_MODE_MIXED = 'mixed'
@@ -245,22 +247,26 @@ class Discount(LoggedModel):
return False
return True
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result):
if self.condition_min_value and sum(positions[idx][2] for idx in condition_idx_group) < self.condition_min_value:
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
if self.condition_min_value and sum(positions[idx].line_price_gross for idx in condition_idx_group) < self.condition_min_value:
return
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
raise ValueError('Validation invariant violated.')
for idx in benefit_idx_group:
previous_price = positions[idx][2]
previous_price = positions[idx].line_price_gross
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency,
)
result[idx] = new_price
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result):
if collect_potential_discounts is not None:
for idx in condition_idx_group:
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
if len(condition_idx_group) < self.condition_min_count:
return
@@ -268,23 +274,53 @@ class Discount(LoggedModel):
raise ValueError('Validation invariant violated.')
if self.benefit_only_apply_to_cheapest_n_matches:
if not self.condition_min_count:
raise ValueError('Validation invariant violated.')
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
# sort by line_price
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3
n_groups = min(len(condition_idx_group) // self.condition_min_count, ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches))
# how many discount applications are allowed according to condition products in cart
possible_applications_cond = len(condition_idx_group) // self.condition_min_count
# how many discount applications are possible according to benefitting products in cart
possible_applications_benefit = ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches)
n_groups = min(possible_applications_cond, possible_applications_benefit)
consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
if collect_potential_discounts is not None:
if n_groups * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
# partially used discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket
# but only 1 t-shirt) -> 1 shirt definitiv potential discount
for idx in consume_idx:
collect_potential_discounts[idx] = [
(self, n_groups * self.benefit_only_apply_to_cheapest_n_matches - len(benefit_idx_group), -1, subevent_id)
]
if possible_applications_cond * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
# unused discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket
# but 0 t-shirts) -> 2 shirt maybe potential discount (if the 1 ticket is not consumed by a later discount)
for i, idx in enumerate(condition_idx_group[
n_groups * self.condition_min_count:
possible_applications_cond * self.condition_min_count
]):
collect_potential_discounts[idx] += [
(self, self.benefit_only_apply_to_cheapest_n_matches, i // self.condition_min_count, subevent_id)
]
else:
consume_idx = condition_idx_group
benefit_idx = benefit_idx_group
if collect_potential_discounts is not None:
for idx in consume_idx:
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
for idx in benefit_idx:
previous_price = positions[idx][2]
previous_price = positions[idx].line_price_gross
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency,
@@ -292,15 +328,16 @@ class Discount(LoggedModel):
result[idx] = new_price
for idx in consume_idx:
result.setdefault(idx, positions[idx][2])
result.setdefault(idx, positions[idx].line_price_gross)
def apply(self, positions: Dict[int, Tuple[int, Optional[int], Decimal, bool, Decimal]]) -> Dict[int, Decimal]:
def apply(self, positions: Dict[int, PositionInfo],
collect_potential_discounts=None) -> Dict[int, Decimal]:
"""
Tries to apply this discount to a cart
:param positions: Dictionary mapping IDs to tuples of the form
``(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)``.
:param positions: Dictionary mapping IDs to PositionInfo tuples.
Bundled positions may not be included.
:param collect_potential_discounts: For detailed description, see pretix.base.services.pricing.apply_discounts
:return: A dictionary mapping keys from the input dictionary to new prices. All positions
contained in this dictionary are considered "consumed" and should not be considered
@@ -342,13 +379,13 @@ class Discount(LoggedModel):
if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events
if self.condition_min_count:
self._apply_min_count(positions, condition_candidates, benefit_candidates, result)
self._apply_min_count(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts, None)
else:
self._apply_min_value(positions, condition_candidates, benefit_candidates, result)
self._apply_min_value(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts, None)
elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
def key(idx):
return positions[idx][1] or 0 # subevent_id
return positions[idx].subevent_id or 0
# Build groups of candidates with the same subevent, then apply our regular algorithm
# to each group
@@ -357,11 +394,11 @@ class Discount(LoggedModel):
candidate_groups = [(k, list(g)) for k, g in _groups]
for subevent_id, g in candidate_groups:
benefit_g = [idx for idx in benefit_candidates if positions[idx][1] == subevent_id]
benefit_g = [idx for idx in benefit_candidates if positions[idx].subevent_id == subevent_id]
if self.condition_min_count:
self._apply_min_count(positions, g, benefit_g, result)
self._apply_min_count(positions, g, benefit_g, result, collect_potential_discounts, subevent_id)
else:
self._apply_min_value(positions, g, benefit_g, result)
self._apply_min_value(positions, g, benefit_g, result, collect_potential_discounts, subevent_id)
elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
if self.condition_min_value or not self.benefit_same_products:
@@ -377,9 +414,9 @@ class Discount(LoggedModel):
# Build a list of subevent IDs in descending order of frequency
subevent_to_idx = defaultdict(list)
for idx, p in positions.items():
subevent_to_idx[p[1]].append(idx)
subevent_to_idx[p.subevent_id].append(idx)
for v in subevent_to_idx.values():
v.sort(key=lambda idx: positions[idx][2])
v.sort(key=lambda idx: positions[idx].line_price_gross)
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
# Build groups of exactly condition_min_count distinct subevents
@@ -394,7 +431,7 @@ class Discount(LoggedModel):
l = [ll for ll in l if ll in condition_candidates and ll not in current_group]
if cardinality and len(l) != cardinality:
continue
if se not in {positions[idx][1] for idx in current_group}:
if se not in {positions[idx].subevent_id for idx in current_group}:
candidates += l
cardinality = len(l)
@@ -403,7 +440,7 @@ class Discount(LoggedModel):
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
# and 2 from the end" scheme to optimize price distribution among groups
candidates = sorted(candidates, key=lambda idx: positions[idx][2])
candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross)
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
candidate = candidates[0]
else:
@@ -415,14 +452,14 @@ class Discount(LoggedModel):
if len(current_group) >= max(self.condition_min_count, 1):
candidate_groups.append(current_group)
for c in current_group:
subevent_to_idx[positions[c][1]].remove(c)
subevent_to_idx[positions[c].subevent_id].remove(c)
current_group = []
# Distribute "leftovers"
for se in subevent_order:
if subevent_to_idx[se]:
for group in candidate_groups:
if se not in {positions[idx][1] for idx in group}:
if se not in {positions[idx].subevent_id for idx in group}:
group.append(subevent_to_idx[se].pop())
if not subevent_to_idx[se]:
break
@@ -432,6 +469,8 @@ class Discount(LoggedModel):
positions,
[idx for idx in g if idx in condition_candidates],
[idx for idx in g if idx in benefit_candidates],
result
result,
None,
None
)
return result

View File

@@ -870,10 +870,12 @@ class Event(EventMixin, LoggedModel):
for i in Item.objects.filter(event=other).prefetch_related(
'variations', 'limit_sales_channels', 'require_membership_types',
'variations__limit_sales_channels', 'variations__require_membership_types',
'matched_by_cross_selling_categories',
):
vars = list(i.variations.all())
require_membership_types = list(i.require_membership_types.all())
limit_sales_channels = list(i.limit_sales_channels.all())
matched_by_cross_selling_categories = list(i.matched_by_cross_selling_categories.all())
item_map[i.pk] = i
i.pk = None
i.event = self
@@ -911,6 +913,9 @@ class Event(EventMixin, LoggedModel):
if not v.all_sales_channels:
v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
if matched_by_cross_selling_categories:
i.matched_by_cross_selling_categories.set([category_map[c.pk] for c in matched_by_cross_selling_categories])
for i in self.items.filter(hidden_if_item_available__isnull=False):
i.hidden_if_item_available = item_map[i.hidden_if_item_available_id]
i.save()

View File

@@ -63,14 +63,13 @@ from django_countries.fields import Country
from django_scopes import ScopedManager
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Event, SubEvent
from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.models.tax import TaxedPrice
from pretix.base.timemachine import time_machine_now
from ...helpers.images import ImageSizeValidator
from ..media import MEDIA_TYPES
from .event import Event, SubEvent
from pretix.helpers.images import ImageSizeValidator
class ItemCategory(LoggedModel):
@@ -111,6 +110,33 @@ class ItemCategory(LoggedModel):
'only be bought in combination with a product that has this category configured as a possible '
'source for add-ons.')
)
CROSS_SELLING_MODES = (
(None, _('Normal category')),
('both', _('Normal + cross-selling category')),
('only', _('Cross-selling category')),
)
cross_selling_mode = models.CharField(
choices=CROSS_SELLING_MODES,
null=True,
max_length=5
)
CROSS_SELLING_CONDITION = (
('always', _('Always show in cross-selling step')),
('discounts', _('Only show products that qualify for a discount according to discount rules')),
('products', _('Only show if the cart contains one of the following products')),
)
cross_selling_condition = models.CharField(
verbose_name=_("Cross-selling condition"),
choices=CROSS_SELLING_CONDITION,
null=True,
max_length=10,
)
cross_selling_match_products = models.ManyToManyField(
'pretixbase.Item',
blank=True,
verbose_name=_("Cross-selling condition products"),
related_name="matched_by_cross_selling_categories",
)
class Meta:
verbose_name = _("Product category")
@@ -119,19 +145,31 @@ class ItemCategory(LoggedModel):
def __str__(self):
name = self.internal_name or self.name
if self.is_addon:
return _('{category} (Add-On products)').format(category=str(name))
category_type = self.get_category_type_display()
if category_type:
return _('{category} ({category_type})').format(category=str(name), category_type=category_type)
return str(name)
def get_category_type_display(self):
if self.is_addon:
return _('Add-On products')
return _('Add-on category')
elif self.cross_selling_mode:
return self.get_cross_selling_mode_display()
else:
return None
@property
def category_type(self):
return 'addon' if self.is_addon else 'normal'
return 'addon' if self.is_addon else self.cross_selling_mode or 'normal'
@category_type.setter
def category_type(self, new_value):
if new_value == 'addon':
self.is_addon = True
self.cross_selling_mode = None
else:
self.is_addon = False
self.cross_selling_mode = None if new_value == 'normal' else new_value
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
@@ -270,7 +308,7 @@ class SubEventItemVariation(models.Model):
return True
def filter_available(qs, channel='web', voucher=None, allow_addons=False):
def filter_available(qs, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False):
# Channel can currently be a SalesChannel or a str, since we need that compatibility, but a SalesChannel
# makes the query SIGNIFICANTLY faster
from .organizer import SalesChannel
@@ -291,6 +329,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
if not allow_cross_sell:
q &= Q(Q(category__isnull=True) | ~Q(category__cross_selling_mode='only'))
if voucher:
if voucher.item_id:
@@ -304,8 +344,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
class ItemQuerySet(models.QuerySet):
def filter_available(self, channel='web', voucher=None, allow_addons=False):
return filter_available(self, channel, voucher, allow_addons)
def filter_available(self, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False):
return filter_available(self, channel, voucher, allow_addons, allow_cross_sell)
class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__):
@@ -313,8 +353,8 @@ class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__)
super().__init__()
self._queryset_class = ItemQuerySet
def filter_available(self, channel='web', voucher=None, allow_addons=False):
return filter_available(self.get_queryset(), channel, voucher, allow_addons)
def filter_available(self, channel='web', voucher=None, allow_addons=False, allow_cross_sell=False):
return filter_available(self.get_queryset(), channel, voucher, allow_addons, allow_cross_sell)
class Item(LoggedModel):

View File

@@ -40,7 +40,6 @@ import json
import logging
import operator
import string
import warnings
from collections import Counter
from datetime import datetime, time, timedelta
from decimal import Decimal
@@ -382,28 +381,8 @@ class Order(LockModel, LoggedModel):
self.event.cache.delete('complain_testmode_orders')
self.delete()
def email_confirm_secret(self):
return self.tagged_secret("email_confirm", 9)
def email_confirm_hash(self):
warnings.warn('Use email_confirm_secret() instead of email_confirm_hash().',
DeprecationWarning)
return self.email_confirm_secret()
def check_email_confirm_secret(self, received_secret):
return (
hmac.compare_digest(
self.tagged_secret("email_confirm", 9),
received_secret[:9].lower()
) or any(
# TODO: remove this clause after a while (compatibility with old secrets currently in flight)
hmac.compare_digest(
hashlib.sha256(sk.encode() + self.secret.encode()).hexdigest()[:9],
received_secret
)
for sk in [settings.SECRET_KEY, *settings.SECRET_KEY_FALLBACKS]
)
)
return hashlib.sha256(settings.SECRET_KEY.encode() + self.secret.encode()).hexdigest()[:9]
def get_extended_status_display(self):
# Changes in this method should to be replicated in pretixcontrol/orders/fragment_order_status.html

View File

@@ -304,24 +304,10 @@ class TaxRule(LoggedModel):
subtract_from_gross = Decimal('0.00')
rate = adjust_rate
def _limit_subtract(base_price, subtract_from_gross):
if not subtract_from_gross:
return base_price
if base_price >= Decimal('0.00'):
# For positive prices, make sure they don't go negative because of bundles
return max(Decimal('0.00'), base_price - subtract_from_gross)
else:
# If the price is already negative, we don't really care any more
return base_price - subtract_from_gross
if rate == Decimal('0.00'):
gross = _limit_subtract(base_price, subtract_from_gross)
return TaxedPrice(
net=gross,
gross=gross,
tax=Decimal('0.00'),
rate=rate,
name=self.name,
net=base_price - subtract_from_gross, gross=base_price - subtract_from_gross, tax=Decimal('0.00'),
rate=rate, name=self.name
)
if base_price_is == 'auto':
@@ -331,14 +317,19 @@ class TaxRule(LoggedModel):
base_price_is = 'net'
if base_price_is == 'gross':
gross = _limit_subtract(base_price, subtract_from_gross)
if base_price >= Decimal('0.00'):
# For positive prices, make sure they don't go negative because of bundles
gross = max(Decimal('0.00'), base_price - subtract_from_gross)
else:
# If the price is already negative, we don't really care any more
gross = base_price - subtract_from_gross
net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))),
currency)
elif base_price_is == 'net':
net = base_price
gross = round_decimal((net * (1 + rate / 100)), currency)
if subtract_from_gross:
gross = _limit_subtract(gross, subtract_from_gross)
gross -= subtract_from_gross
net = round_decimal(gross - (gross * (1 - 100 / (100 + rate))),
currency)
else:

View File

@@ -1542,10 +1542,9 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
:param voucher: A voucher code
:param session: Session ID of a guest
:param cart_id: The cart ID of the cart to modify
"""
with language(locale), time_machine_now_assigned(override_now_dt):
try:
@@ -1566,10 +1565,10 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
"""
Removes a list of items from a user's cart.
Removes an item specified by its position ID from a user's cart.
:param event: The event ID in question
:param position: A cart position ID
:param session: Session ID of a guest
:param cart_id: The cart ID of the cart to modify
"""
with language(locale), time_machine_now_assigned(override_now_dt):
try:
@@ -1590,9 +1589,9 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
"""
Removes a list of items from a user's cart.
Removes all items from a user's cart.
:param event: The event ID in question
:param session: Session ID of a guest
:param cart_id: The cart ID of the cart to modify
"""
with language(locale), time_machine_now_assigned(override_now_dt):
try:
@@ -1611,13 +1610,15 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en',
def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None:
"""
Removes a list of items from a user's cart.
Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to
ensure the requested addon state.
:param event: The event ID in question
:param addons: A list of dicts with the keys addon_to, item, variation
:param session: Session ID of a guest
:param add_to_cart_items: A list of dicts with the keys item, variation, count, custom_price, voucher, seat ID
:param cart_id: The cart ID of the cart to modify
"""
with language(locale), time_machine_now_assigned(override_now_dt):
ia = False
@@ -1635,6 +1636,7 @@ def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, l
try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
cm.set_addons(addons)
cm.add_new_items(add_to_cart_items)
cm.commit()
except LockTimeoutException:
self.retry()

View File

@@ -0,0 +1,232 @@
#
# 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 collections import defaultdict
from decimal import Decimal
from itertools import groupby
from math import inf
from typing import List
from django.utils.functional import cached_property
from pretix.base.models import CartPosition, ItemCategory, SalesChannel
from pretix.presale.views.event import get_grouped_items
class DummyCategory:
"""
Used to create fake category objects for displaying the same cross-selling category multiple times,
once for each subevent
"""
def __init__(self, category: ItemCategory, subevent):
self.id = category.id
self.name = str(category.name)
self.subevent_name = str(subevent)
self.description = category.description
class CrossSellingService:
def __init__(self, event, sales_channel: SalesChannel, cartpositions: List[CartPosition], customer):
self.event = event
self.sales_channel = sales_channel
self.cartpositions = cartpositions
self.customer = customer
def get_data(self):
if self.event.has_subevents:
subevents = set(pos.subevent for pos in self.cartpositions)
result = (
(DummyCategory(category, subevent),
self._prepare_items(subevent, items_qs, discount_info),
f'subevent_{subevent.pk}_')
for subevent in subevents
for (category, items_qs, discount_info) in self._applicable_categories(subevent.pk)
)
else:
result = (
(category,
self._prepare_items(None, items_qs, discount_info),
'')
for (category, items_qs, discount_info) in self._applicable_categories(0)
)
result = [(category, items, form_prefix) for (category, items, form_prefix) in result if len(items) > 0]
for category, items, form_prefix in result:
category.category_has_discount = any(item.original_price for item in items)
return result
def _applicable_categories(self, subevent_id):
return [
(c, products_qs, discount_info) for (c, products_qs, discount_info) in
(
(c, *self._get_visible_items_for_category(subevent_id, c))
for c in self.event.categories.filter(cross_selling_mode__isnull=False).prefetch_related('items')
)
if products_qs is not None
]
def _get_visible_items_for_category(self, filter_subevent_id, category: ItemCategory):
"""
If this category should be visible in the cross-selling step for a given cart and sales_channel, this method
returns a queryset of the items that should be displayed, as well as a dict giving additional information on them.
:returns: (QuerySet<Item>, dict<(subevent_id, item_pk): (max_count, discount_rule)>)
max_count is `inf` if the item should not be limited
discount_rule is None if the item will not be discounted
"""
if category.cross_selling_mode is None:
return None, {}
if category.cross_selling_condition == 'always':
return category.items.all(), {}
if category.cross_selling_condition == 'products':
match = set(match.pk for match in category.cross_selling_match_products.only('pk')) # TODO prefetch this
return (category.items.all(), {}) if any(pos.item.pk in match for pos in self.cartpositions) else (None, {})
if category.cross_selling_condition == 'discounts':
my_item_pks = [item.id for item in category.items.all()]
potential_discount_items = {
item.pk: (max_count, discount_rule)
for subevent_id, item, max_count, discount_rule in self._potential_discounts_by_subevent_and_item_for_current_cart
if max_count > 0 and item.pk in my_item_pks and item.is_available() and (subevent_id == filter_subevent_id or subevent_id is None)
}
return category.items.filter(pk__in=potential_discount_items), potential_discount_items
@cached_property
def _potential_discounts_by_subevent_and_item_for_current_cart(self):
potential_discounts_by_cartpos = defaultdict(list)
from ..services.pricing import apply_discounts
self._discounted_prices = apply_discounts(
self.event,
self.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled,
cp.listed_price - cp.price_after_voucher)
for cp in self.cartpositions
],
collect_potential_discounts=potential_discounts_by_cartpos
)
# flatten potential_discounts_by_cartpos (a dict of lists of potential discounts) into a set of potential discounts
# (which is technically stored as a dict, but we use it as an OrderedSet here)
potential_discount_set = dict.fromkeys(
info for lst in potential_discounts_by_cartpos.values() for info in lst)
# sum up the max_counts and pass them on (also pass on the discount_rules so we can calculate actual discounted prices later):
# group by benefit product
# - max_count for product: sum up max_counts
# - discount_rule for product: take first discount_rule
def discount_info(subevent_id, item, infos_for_item):
infos_for_item = list(infos_for_item)
return (
subevent_id,
item,
sum(max_count for (subevent_id, item, discount_rule, max_count, i) in infos_for_item),
next(discount_rule for (subevent_id, item, discount_rule, max_count, i) in infos_for_item),
)
return [
discount_info(subevent_id, item, infos_for_item) for (subevent_id, item), infos_for_item in
groupby(
sorted(
(
(subevent_id, item, discount_rule, max_count, i)
for (discount_rule, max_count, i, subevent_id) in potential_discount_set.keys()
for item in discount_rule.benefit_limit_products.all()
),
key=lambda tup: (tup[0], tup[1].pk)
),
lambda tup: (tup[0], tup[1]))
]
def _prepare_items(self, subevent, items_qs, discount_info):
items, _btn = get_grouped_items(
self.event,
subevent=subevent,
voucher=None,
channel=self.sales_channel,
base_qs=items_qs,
allow_addons=False,
allow_cross_sell=True,
memberships=(
self.customer.usable_memberships(
for_event=subevent or self.event,
testmode=self.event.testmode
)
if self.customer else None
),
)
new_items = list()
for item in items:
max_count = inf
if item.pk in discount_info:
(max_count, discount_rule) = discount_info[item.pk]
# only benefit_only_apply_to_cheapest_n_matches discounted items have a max_count, all others get 'inf'
if not max_count:
max_count = inf
# calculate discounted price
if discount_rule and discount_rule.benefit_discount_matching_percent > 0:
if not item.has_variations:
item.original_price = item.original_price or item.display_price
previous_price = item.display_price
new_price = (
previous_price * (
(Decimal('100.00') - discount_rule.benefit_discount_matching_percent) / Decimal('100.00'))
)
item.display_price = new_price
else:
# discounts always match "whole" items, not specific variations -> we apply the discount to all
# available variations of the item
for var in item.available_variations:
var.original_price = var.original_price or var.display_price
previous_price = var.display_price
new_price = (
previous_price * (
(Decimal('100.00') - discount_rule.benefit_discount_matching_percent) / Decimal('100.00'))
)
var.display_price = new_price
if not item.has_variations:
# reduce order_max by number of items already in cart (prevent recommending a product the user can't add anyway)
item.order_max = min(
item.order_max - sum(1 for pos in self.cartpositions if pos.item_id == item.pk),
max_count
)
if item.order_max > 0:
new_items.append(item)
else:
new_vars = list()
for var in item.available_variations:
# reduce order_max by number of items already in cart (prevent recommending a product the user can't add anyway)
var.order_max = min(
var.order_max - sum(1 for pos in self.cartpositions if pos.item_id == item.pk and pos.variation_id == var.pk),
max_count
)
if var.order_max > 0:
new_vars.append(var)
if len(new_vars):
item.available_variations = new_vars
new_items.append(item)
return new_items

View File

@@ -301,7 +301,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
order.event, 'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_secret()
'hash': order.email_confirm_hash()
}
)
)

View File

@@ -262,7 +262,7 @@ def base_placeholders(sender, **kwargs):
'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_secret()
'hash': order.email_confirm_hash()
}
), lambda event: build_absolute_uri(
event,
@@ -443,7 +443,7 @@ def base_placeholders(sender, **kwargs):
'organizer': event.organizer.slug,
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_secret(),
'hash': order.email_confirm_hash(),
}),
)
for order in orders

View File

@@ -20,8 +20,9 @@
# <https://www.gnu.org/licenses/>.
#
import re
from collections import defaultdict
from decimal import Decimal
from typing import List, Optional, Tuple
from typing import List, Optional, Tuple, Union
from django import forms
from django.db.models import Q
@@ -31,6 +32,7 @@ from pretix.base.models import (
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
SalesChannel, Voucher,
)
from pretix.base.models.discount import Discount, PositionInfo
from pretix.base.models.event import Event, SubEvent
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.timemachine import time_machine_now
@@ -155,14 +157,22 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
return price
def apply_discounts(event: Event, sales_channel: str,
positions: List[Tuple[int, Optional[int], Decimal, bool, bool]]) -> List[Decimal]:
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
positions: List[Tuple[int, Optional[int], Decimal, bool, bool, Decimal]],
collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]:
"""
Applies any dynamic discounts to a cart
:param event: Event the cart belongs to
:param sales_channel: Sales channel the cart was created with
:param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
based on the "consumed" items, but lack matching "benefitting" items will be collected therein.
The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list
of tuples describing the discounts that could be applied in the form `(discount, max_count, grouping_id)`.
`max_count` is either the maximum number of benefitting items that the discount would apply to, or `inf` if that number
is not limited. The `grouping_id` can be used to distinguish several occurrences of the same discount.
:return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input
"""
if isinstance(sales_channel, SalesChannel):
@@ -177,10 +187,10 @@ def apply_discounts(event: Event, sales_channel: str,
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs:
result = discount.apply({
idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
idx: PositionInfo(item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount) in enumerate(positions)
if not is_bundled and idx not in new_prices
})
}, collect_potential_discounts)
for k in result.keys():
result[k] = (result[k], discount)
new_prices.update(result)

View File

@@ -838,12 +838,3 @@ is given as the first argument.
The ``sender`` keyword argument will contain the organizer.
"""
device_info_updated = django.dispatch.Signal()
"""
Arguments: ``old_device``, ``new_device``
This signal is sent out each time the information for a Device is modified.
Both the original and updated versions of the Device are included to allow
receivers to see what has been updated.
"""

View File

@@ -143,7 +143,7 @@
</tr>
</table>
<div class="order-button">
<a href="{% abseventurl event "presale:event.order.open" hash=order.email_confirm_secret order=order.code secret=order.secret %}" class="button">
<a href="{% abseventurl event "presale:event.order.open" hash=order.email_confirm_hash order=order.code secret=order.secret %}" class="button">
{% trans "View order details" %}
</a>
</div>

View File

@@ -103,7 +103,7 @@ def timeline_for_event(event, subevent=None):
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=rd.datetime(ev),
description=pgettext_lazy('timeline', 'Customers can no longer modify their order information'),
description=pgettext_lazy('timeline', 'Customers can no longer modify their orders'),
edit_url=ev_edit_url
))
@@ -159,18 +159,6 @@ def timeline_for_event(event, subevent=None):
})
))
rd = event.settings.get('change_allow_user_until', as_type=RelativeDateWrapper)
if rd and event.settings.change_allow_user_until:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=rd.datetime(ev),
description=pgettext_lazy('timeline', 'Customers can no longer make changes to their orders'),
edit_url=reverse('control:event.settings.cancel', kwargs={
'event': event.slug,
'organizer': event.organizer.slug
})
))
rd = event.settings.get('waiting_list_auto_disable', as_type=RelativeDateWrapper)
if rd and event.settings.waiting_list_enabled:
tl.append(TimelineEvent(

View File

@@ -40,7 +40,8 @@ from urllib.parse import urlencode
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db.models import Max
from django.db.models import Max, Q
from django.forms import ChoiceField, RadioSelect
from django.forms.formsets import DELETION_FIELD_NAME
from django.urls import reverse
from django.utils.functional import cached_property
@@ -79,11 +80,67 @@ class CategoryForm(I18nModelForm):
'name',
'internal_name',
'description',
'is_addon'
'cross_selling_condition',
'cross_selling_match_products',
]
widgets = {
'description': I18nMarkdownTextarea,
'cross_selling_condition': RadioSelect,
}
field_classes = {
'cross_selling_match_products': SafeModelMultipleChoiceField,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
tpl = '{} &nbsp; <span class="text-muted">{}</span>'
self.fields['category_type'] = ChoiceField(widget=RadioSelect, choices=(
('normal', mark_safe(tpl.format(
_('Normal category'),
_('Products in this category are regular products displayed on the front page.')
)),),
('addon', mark_safe(tpl.format(
_('Add-on product category'),
_('Products in this category are add-on products and can only be bought as add-ons.')
)),),
('only', mark_safe(tpl.format(
_('Cross-selling category'),
_('Products in this category are regular products, but are only shown in the cross-selling step, '
'according to the configuration below.')
)),),
('both', mark_safe(tpl.format(
_('Normal + cross-selling category'),
_('Products in this category are regular products displayed on the front page, but are additionally '
'shown in the cross-selling step, according to the configuration below.')
)),),
))
self.fields['category_type'].initial = self.instance.category_type
self.fields['cross_selling_condition'].widget.attrs['data-display-dependency'] = '#id_category_type_2,#id_category_type_3'
self.fields['cross_selling_condition'].widget.attrs['data-disable-dependent'] = 'true'
self.fields['cross_selling_condition'].widget.choices = self.fields['cross_selling_condition'].widget.choices[1:]
self.fields['cross_selling_condition'].required = False
self.fields['cross_selling_match_products'].widget = forms.CheckboxSelectMultiple(
attrs={
'class': 'scrolling-multiple-choice',
'data-display-dependency': '#id_cross_selling_condition_2'
}
)
self.fields['cross_selling_match_products'].queryset = self.event.items.filter(
# don't show products which are only visible in addon/cross-sell step themselves
Q(category__isnull=True) | Q(
Q(category__is_addon=False) & Q(Q(category__cross_selling_mode='both') | Q(category__cross_selling_mode__isnull=True))
)
)
def clean(self):
d = super().clean()
if d.get('category_type') == 'only' or d.get('category_type') == 'both':
if not d.get('cross_selling_condition'):
raise ValidationError({'cross_selling_condition': [_('This field is required')]})
self.instance.category_type = d.get('category_type')
return d
class QuestionForm(I18nModelForm):

View File

@@ -239,14 +239,11 @@ class VoucherForm(I18nModelForm):
self.instance.event, self.instance.quota, self.instance.item, self.instance.variation
)
Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk)
if 'seat' in self.fields:
if data.get('seat'):
self.instance.seat = Voucher.clean_seat_id(
data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
)
self.instance.item = self.instance.seat.product
else:
self.instance.seat = None
if 'seat' in self.fields and data.get('seat'):
self.instance.seat = Voucher.clean_seat_id(
data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
)
self.instance.item = self.instance.seat.product
voucher_form_validation.send(sender=self.instance.event, form=self, data=data)

View File

@@ -31,6 +31,7 @@
<thead>
<tr>
<th>{% trans "Product categories" %}</th>
<th>{% trans "Category type" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
@@ -40,6 +41,9 @@
<td>
<strong><a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}">{{ c.internal_name|default:c.name }}</a></strong>
</td>
<td>
{{ c.get_category_type_display }}
</td>
<td class="text-right flip">
<button title="{% trans "Move up" %}" formaction="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button title="{% trans "Move down" %}" formaction="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>

View File

@@ -16,7 +16,9 @@
{% bootstrap_field form.internal_name layout="control" %}
</div>
{% bootstrap_field form.description layout="control" %}
{% bootstrap_field form.is_addon layout="control" %}
{% bootstrap_field form.category_type layout="control" horizontal_field_class="big-radio-wrapper col-lg-9" %}
{% bootstrap_field form.cross_selling_condition layout="control" horizontal_field_class="col-lg-9" %}
{% bootstrap_field form.cross_selling_match_products layout="control" %}
</fieldset>
</div>
{% if category %}

View File

@@ -21,11 +21,6 @@
{% trans "The waiting list is no longer active for this event. The waiting list no longer affects quotas and no longer notifies waiting users." %}
</div>
{% endif %}
{% if request.event.settings.hide_sold_out %}
<div class="alert alert-warning">
{% trans "According to your event settings, sold out products are hidden from customers. This way, customers will not be able to discovere the waiting list." %}
</div>
{% endif %}
<div class="row">
{% if 'can_change_orders' in request.eventpermset %}
<form method="post" class="col-md-6"

View File

@@ -94,9 +94,7 @@ def process_login(request, user, keep_logged_in):
pretix_successful_logins.inc(1)
handle_login_source(user, request)
auth_login(request, user)
t = int(time.time())
request.session['pretix_auth_login_time'] = t
request.session['pretix_auth_last_used'] = t
request.session['pretix_auth_login_time'] = int(time.time())
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts=None):
return redirect_to_url(next_url)
return redirect('control:index')

View File

@@ -39,7 +39,7 @@ from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Exists, Max, OuterRef, Prefetch, Q, Subquery
from django.http import Http404, HttpResponseNotAllowed, HttpResponseRedirect
from django.http import Http404, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.functional import cached_property
@@ -193,9 +193,6 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, CheckInList
class CheckInListBulkRevertConfirmView(CheckInListQueryMixin, EventPermissionRequiredMixin, TemplateView):
template_name = "pretixcontrol/checkin/bulk_revert_confirm.html"
def get(self, request, *args, **kwargs):
return HttpResponseNotAllowed(permitted_methods=["POST"])
def post(self, request, *args, **kwargs):
self.list = get_object_or_404(self.request.event.checkin_lists.all(), pk=kwargs.get("list"))
return super().get(request, *args, **kwargs)

File diff suppressed because it is too large Load Diff

View File

@@ -8,16 +8,16 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-27 13:34+0000\n"
"PO-Revision-Date: 2024-10-01 22:52+0000\n"
"Last-Translator: Patrick Chilton <chpatrick@gmail.com>\n"
"Language-Team: Hungarian <https://translate.pretix.eu/projects/pretix/"
"pretix-js/hu/>\n"
"PO-Revision-Date: 2020-01-24 08:00+0000\n"
"Last-Translator: Prokaj Miklós <mixolid0@gmail.com>\n"
"Language-Team: Hungarian <https://translate.pretix.eu/projects/pretix/pretix-"
"js/hu/>\n"
"Language: hu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.7.2\n"
"X-Generator: Weblate 3.5.1\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
@@ -134,6 +134,9 @@ msgstr ""
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js:167
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:50
#, fuzzy
#| msgctxt "widget"
#| msgid "Continue"
msgid "Continue"
msgstr "Folytatás"
@@ -170,7 +173,7 @@ msgstr "Kapcsolatfelvétel Stripe-pal…"
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:72
msgid "Total"
msgstr "Összeg"
msgstr "Teljes"
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:291
msgid "Contacting your bank …"
@@ -238,7 +241,7 @@ msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:44
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:45
msgid "Canceled"
msgstr "Lemondva"
msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:46
msgid "Confirmed"
@@ -246,7 +249,7 @@ msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:47
msgid "Approval pending"
msgstr "Engedélyre vár"
msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:48
#, fuzzy
@@ -257,7 +260,7 @@ msgstr "Beváltás"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:49
msgid "Cancel"
msgstr "Lemondás"
msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:51
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:60
@@ -270,7 +273,7 @@ msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:53
msgid "Additional information required"
msgstr "Több információ szükséges"
msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:54
msgid "Valid ticket"
@@ -286,7 +289,7 @@ msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:57
msgid "Information required"
msgstr "Információ szükséges"
msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/main.js:58
#, fuzzy
@@ -468,7 +471,7 @@ msgstr ""
#: pretix/static/pretixcontrol/js/ui/checkinrules.js:99
msgid "Product"
msgstr "Termék"
msgstr ""
#: pretix/static/pretixcontrol/js/ui/checkinrules.js:103
#, fuzzy
@@ -727,12 +730,12 @@ msgstr[0] "(még egy időpont)"
msgstr[1] "(még {num} időpont)"
#: pretix/static/pretixpresale/js/ui/cart.js:43
#, fuzzy
#| msgid "The items in your cart are no longer reserved for you."
msgid ""
"The items in your cart are no longer reserved for you. You can still "
"complete your order as long as theyre available."
msgstr ""
"A kosárba helyezett tételek tovább már nincsenek lefoglalva. Még "
"megpróbálhatod befejezni a rendelést, ha még elérhetőek."
msgstr "A kosárba helyezett termékek tovább nincsenek tovább foglalva."
#: pretix/static/pretixpresale/js/ui/cart.js:45
msgid "Cart expired"

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-26 11:22+0000\n"
"PO-Revision-Date: 2024-10-08 18:00+0000\n"
"Last-Translator: Davide Manzella <manzella.davide97@gmail.com>\n"
"PO-Revision-Date: 2024-08-22 15:00+0000\n"
"Last-Translator: Michelangelo <michelangelo.morrillo@gmail.com>\n"
"Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix/"
"it/>\n"
"Language: it\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.7.2\n"
"X-Generator: Weblate 5.7\n"
#: pretix/_base_settings.py:79
msgid "English"
@@ -37,7 +37,7 @@ msgstr "Arabo"
#: pretix/_base_settings.py:83
msgid "Basque"
msgstr "Basco"
msgstr ""
#: pretix/_base_settings.py:84
msgid "Catalan"
@@ -121,7 +121,7 @@ msgstr "Russo"
#: pretix/_base_settings.py:104
msgid "Slovak"
msgstr "Slovacco"
msgstr ""
#: pretix/_base_settings.py:105
msgid "Swedish"
@@ -178,8 +178,10 @@ msgid "Allowed URIs list, space separated"
msgstr "Lista delle URI autorizzate, separate da spazio"
#: pretix/api/models.py:47
#, fuzzy
#| msgid "Allowed URIs list, space separated"
msgid "Allowed Post Logout URIs list, space separated"
msgstr "Lista delle URI Dopo Logout autorizzate, separate da spazio"
msgstr "Lista delle URI autorizzate, separate da spazio"
#: pretix/api/models.py:51 pretix/base/models/customers.py:395
#: pretix/plugins/paypal/payment.py:113 pretix/plugins/paypal2/payment.py:110
@@ -262,9 +264,10 @@ msgid "Unknown plugin: '{name}'."
msgstr "Plugin sconosciuto: '{name}'."
#: pretix/api/serializers/event.py:295
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "Unknown plugin: '{name}'."
msgid "Restricted plugin: '{name}'."
msgstr "Plugin a cui è limitato l'accesso: '{name}'."
msgstr "Plugin sconosciuto: '{name}'."
#: pretix/api/serializers/item.py:86 pretix/api/serializers/item.py:148
#: pretix/api/serializers/item.py:359
@@ -329,6 +332,8 @@ msgid "This type of question cannot be asked during check-in."
msgstr "Questo tipo di domanda non può essere fatta durante il check-in."
#: pretix/api/serializers/item.py:531 pretix/control/forms/item.py:142
#, fuzzy
#| msgid "This type of question cannot be asked during check-in."
msgid "This type of question cannot be shown during check-in."
msgstr "Questo tipo di domanda non può essere fatta durante il check-in."
@@ -397,7 +402,7 @@ msgstr ""
#: pretix/api/views/checkin.py:604 pretix/api/views/checkin.py:611
msgid "Medium connected to other event"
msgstr "Medium connesso ad un altro evento"
msgstr ""
#: pretix/api/views/oauth.py:107 pretix/control/logdisplay.py:475
#, python-brace-format
@@ -510,8 +515,10 @@ msgid "Order denied"
msgstr "Ordine rifiutato"
#: pretix/api/webhooks.py:313
#, fuzzy
#| msgid "Order denied"
msgid "Order deleted"
msgstr "Ordine cancellato"
msgstr "Ordine rifiutato"
#: pretix/api/webhooks.py:317
msgid "Ticket checked in"
@@ -573,8 +580,9 @@ msgid "Test-Mode of shop has been deactivated"
msgstr "La modalità test del negozio è stata disattivata"
#: pretix/api/webhooks.py:370
#, fuzzy
msgid "Waiting list entry added"
msgstr "Inserito correttamente nella lista d'attesa"
msgstr "Lista d'attesa"
#: pretix/api/webhooks.py:374
msgid "Waiting list entry changed"
@@ -651,7 +659,7 @@ msgstr "Password"
#: pretix/base/auth.py:176 pretix/base/auth.py:183
msgid "Your password must contain both numeric and alphabetic characters."
msgstr "La password deve contenere sia caratteri numerici che alfabetici."
msgstr ""
#: pretix/base/auth.py:202 pretix/base/auth.py:212
#, python-format
@@ -659,9 +667,8 @@ msgid "Your password may not be the same as your previous password."
msgid_plural ""
"Your password may not be the same as one of your %(history_length)s previous "
"passwords."
msgstr[0] "La password non può essere uguale a quella che si vuole cambiare."
msgstr[0] ""
msgstr[1] ""
"La password non può essere uguale ad una delle %(history_length)s precedenti."
#: pretix/base/channels.py:168
msgid "Online shop"
@@ -669,15 +676,13 @@ msgstr "Vendite online"
#: pretix/base/channels.py:174
msgid "API"
msgstr "API"
msgstr ""
#: pretix/base/channels.py:175
msgid ""
"API sales channels come with no built-in functionality, but may be used for "
"custom integrations."
msgstr ""
"API per i canali di vendita senza funzionalità incorporate, ma può essere "
"usata per integrazioni personalizzate."
#: pretix/base/context.py:45
#, python-brace-format
@@ -741,8 +746,6 @@ msgid ""
"No supported Token Endpoint Auth Methods supported: "
"{token_endpoint_auth_methods_supported}"
msgstr ""
"Nessun metodo di autenticazione di tipo Token per Endpoint supportatp: "
"{token_endpoint_auth_methods_supported}"
#: pretix/base/customersso/oidc.py:203 pretix/base/customersso/oidc.py:210
#: pretix/base/customersso/oidc.py:229 pretix/base/customersso/oidc.py:246
@@ -1086,6 +1089,7 @@ msgid "No"
msgstr "No"
#: pretix/base/exporters/dekodi.py:42 pretix/base/exporters/invoices.py:66
#, fuzzy
msgctxt "export_category"
msgid "Invoices"
msgstr "Fatture"
@@ -1612,6 +1616,7 @@ msgid "Product data"
msgstr "Dati del prodotto"
#: pretix/base/exporters/items.py:50 pretix/base/exporters/orderlist.py:1128
#, fuzzy
msgctxt "export_category"
msgid "Product data"
msgstr "Dati del prodotto"
@@ -1795,8 +1800,9 @@ msgstr "Richiede particolare attenzione"
#: pretix/base/modelimport_orders.py:617 pretix/base/models/items.py:614
#: pretix/base/models/items.py:1197 pretix/base/models/orders.py:287
#: pretix/plugins/checkinlists/exporters.py:522
#, fuzzy
msgid "Check-in text"
msgstr "Testo di Check-in"
msgstr "Checkout"
#: pretix/base/exporters/items.py:91 pretix/base/models/items.py:619
#: pretix/base/models/items.py:1117
@@ -2313,8 +2319,10 @@ msgid "Order comment"
msgstr "Commento all'ordine"
#: pretix/base/exporters/orderlist.py:622
#, fuzzy
#| msgid "Position ID"
msgid "Add-on to position ID"
msgstr "Add-on al ID Posizione"
msgstr "ID Posizione"
#: pretix/base/exporters/orderlist.py:650 pretix/base/pdf.py:340
msgid "Invoice address street"
@@ -2345,7 +2353,7 @@ msgstr "Nazione di fatturazione"
#: pretix/control/templates/pretixcontrol/subevents/detail.html:162
#: pretix/plugins/checkinlists/apps.py:44
msgid "Check-in lists"
msgstr "Liste de Check-in"
msgstr ""
#: pretix/base/exporters/orderlist.py:822
msgid "Order transaction data"
@@ -2545,8 +2553,9 @@ msgid "Payment method"
msgstr "Metodo di pagamento"
#: pretix/base/exporters/orderlist.py:1077
#, fuzzy
msgid "Matching ID"
msgstr "ID Combaciato"
msgstr "ID Pagamento"
#: pretix/base/exporters/orderlist.py:1077
#: pretix/control/templates/pretixcontrol/order/refund_choose.html:38
@@ -2611,6 +2620,7 @@ msgstr "Transazioni con carta regalo"
#: pretix/base/exporters/orderlist.py:1183
#: pretix/base/exporters/orderlist.py:1288
#, fuzzy
msgctxt "export_category"
msgid "Gift cards"
msgstr "Carte regalo"
@@ -2812,29 +2822,34 @@ msgstr "Ultima data di fatturazione dell'ordine"
#: pretix/control/templates/pretixcontrol/organizers/reusable_media.html:6
#: pretix/control/templates/pretixcontrol/organizers/reusable_media.html:9
msgid "Reusable media"
msgstr "Media Riutilizzabile"
msgstr ""
#: pretix/base/exporters/reusablemedia.py:35
msgctxt "export_category"
msgid "Reusable media"
msgstr "Media Riutilizzabile"
msgstr ""
#: pretix/base/exporters/reusablemedia.py:36
#, fuzzy
#| msgid ""
#| "Download a spreadsheet with information on all events in this organizer "
#| "account."
msgid ""
"Download a spread sheet with the data of all reusable medias on your account."
msgstr ""
"Scarica un foglio di calcolo con le informazioni con i dati di tutti i media "
"riutlizzabili dell' account."
"Scarica un foglio di calcolo con le informazioni su tutti gli eventi di "
"questo account organizzatore."
#: pretix/base/exporters/reusablemedia.py:46 pretix/base/models/media.py:67
#, fuzzy
msgctxt "reusable_medium"
msgid "Media type"
msgstr "Tipo di Media"
msgstr "Tipo di tariffa"
#: pretix/base/exporters/reusablemedia.py:47 pretix/base/models/media.py:73
msgctxt "reusable_medium"
msgid "Identifier"
msgstr "Identificativo"
msgstr ""
#: pretix/base/exporters/reusablemedia.py:49 pretix/base/models/media.py:81
#: pretix/base/models/orders.py:263 pretix/base/models/orders.py:3014
@@ -2846,18 +2861,22 @@ msgstr "Data di scadenza"
#: pretix/base/exporters/reusablemedia.py:50 pretix/base/models/media.py:90
#: pretix/control/templates/pretixcontrol/order/index.html:215
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html:132
#, fuzzy
msgid "Customer account"
msgstr "Account del cliente"
msgstr "Domande"
#: pretix/base/exporters/reusablemedia.py:51 pretix/base/models/media.py:97
#, fuzzy
msgid "Linked ticket"
msgstr "Ticket collegato"
msgstr "Testo footer aggiuntivo"
#: pretix/base/exporters/reusablemedia.py:52 pretix/base/models/media.py:104
#, fuzzy
msgid "Linked gift card"
msgstr "Carta regalo collegata"
msgstr "Pagato con carta regalo"
#: pretix/base/exporters/waitinglist.py:42
#, fuzzy
msgctxt "export_category"
msgid "Waiting list"
msgstr "Lista d'attesa"
@@ -2936,7 +2955,7 @@ msgstr "Codice del voucher"
#: pretix/base/forms/__init__.py:118
#, python-brace-format
msgid "You can use {markup_name} in this field."
msgstr "Puoi usare {markup_name} in questo campo."
msgstr ""
#: pretix/base/forms/__init__.py:178
#, python-format
@@ -3150,15 +3169,12 @@ msgid ""
"up. Please note: to use literal \"{\" or \"}\", you need to double them as "
"\"{{\" and \"}}\"."
msgstr ""
"è presente un errore nella sintassi dei placeholder. Controllare che le "
"graffe di apertura \"{\" e di chiusura \"}\" sui placeholder match up. Fare "
"attenzione: perutlizzare \"{\" oppure \"}\", è necessario raddoppiarli \"{{ "
"e \"}}\"."
#: pretix/base/forms/validators.py:72 pretix/control/views/event.py:763
#, python-format
#, fuzzy, python-format
#| msgid "Invalid placeholder(s): %(value)s"
msgid "Invalid placeholder: {%(value)s}"
msgstr "Placeholder non valido: %(value)s"
msgstr "Placeholder(s) non valido: %(value)s"
#: pretix/base/forms/widgets.py:68
#, python-format
@@ -3175,7 +3191,7 @@ msgstr "Segnalibri disponibili: {list}"
#: pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_create.html:40
#: pretix/plugins/sendmail/templates/pretixplugins/sendmail/rule_update.html:54
msgid "Time"
msgstr "Orario"
msgstr ""
#: pretix/base/forms/widgets.py:234 pretix/base/forms/widgets.py:239
msgid "Business or institutional customer"
@@ -3404,7 +3420,7 @@ msgstr ""
#: pretix/base/invoice.py:858
msgid "Default invoice renderer (European-style letter)"
msgstr "Renderizzatore di fatture di default (Stile Europeo)"
msgstr ""
#: pretix/base/invoice.py:947
msgctxt "invoice"
@@ -3413,13 +3429,15 @@ msgstr "(Per cortesia citare sempre.)"
#: pretix/base/invoice.py:994
msgid "Simplified invoice renderer"
msgstr "Renderizzatore di fatture semplificato"
msgstr ""
#: pretix/base/invoice.py:1013
#, python-brace-format
#, fuzzy, python-brace-format
#| msgctxt "subevent"
#| msgid "Event series date changed"
msgctxt "invoice"
msgid "Event date: {date_range}"
msgstr "Data evento: {date_range}"
msgstr "Serie di date modificate"
#: pretix/base/media.py:61
msgid "Barcode / QR-Code"
@@ -3437,57 +3455,58 @@ msgstr "Lista predefinita"
#: pretix/base/modelimport.py:112
msgid "Keep empty"
msgstr "Lasciare vuoto"
msgstr ""
#: pretix/base/modelimport.py:139
#, python-brace-format
msgid "Invalid setting for column \"{header}\"."
msgstr "Settaggio invalido per colonna \"{header}\"."
msgstr ""
#: pretix/base/modelimport.py:199
#, python-brace-format
msgid "Could not parse {value} as a yes/no value."
msgstr "Non è stato possibile leggere {value} come valore di sì/no."
msgstr ""
#: pretix/base/modelimport.py:222
#, python-brace-format
msgid "Could not parse {value} as a date and time."
msgstr "Non è stato possibile leggere {value} come data e ora."
msgstr ""
#: pretix/base/modelimport.py:232 pretix/control/views/orders.py:1161
#: pretix/control/views/orders.py:1190 pretix/control/views/orders.py:1234
#: pretix/control/views/orders.py:1269 pretix/control/views/orders.py:1292
msgid "You entered an invalid number."
msgstr "Hai inserito un numero invalido."
msgstr ""
#: pretix/base/modelimport.py:276 pretix/base/modelimport.py:288
msgctxt "subevent"
msgid "No matching date was found."
msgstr "Nessuna data combaciante è stata trovata."
msgstr ""
#: pretix/base/modelimport.py:278 pretix/base/modelimport.py:290
msgctxt "subevent"
msgid "Multiple matching dates were found."
msgstr "Molteplici date che combaciano sono state trovate."
msgstr ""
#: pretix/base/modelimport_orders.py:85
#, fuzzy
msgid "Enter a valid phone number."
msgstr "Inserire un numero di telefono valido."
msgstr "Numero di telefono"
#: pretix/base/modelimport_orders.py:100 pretix/presale/views/waiting.py:118
msgctxt "subevent"
msgid "You need to select a date."
msgstr "Devi selezionare una data."
msgstr ""
#: pretix/base/modelimport_orders.py:128
#: pretix/base/modelimport_vouchers.py:194
msgid "No matching product was found."
msgstr "Nessun prodotto combaciante è stato trovato."
msgstr ""
#: pretix/base/modelimport_orders.py:130
#: pretix/base/modelimport_vouchers.py:196
msgid "Multiple matching products were found."
msgstr "Molteplici prodotti combacianti sono stati trovati."
msgstr ""
#: pretix/base/modelimport_orders.py:139
#: pretix/base/modelimport_vouchers.py:205 pretix/base/models/items.py:1205
@@ -3499,13 +3518,13 @@ msgstr "Variante prodotto"
#: pretix/base/modelimport_vouchers.py:225
#: pretix/base/modelimport_vouchers.py:259
msgid "No matching variation was found."
msgstr "Nessuna variazione combaciante trovata."
msgstr ""
#: pretix/base/modelimport_orders.py:161
#: pretix/base/modelimport_vouchers.py:227
#: pretix/base/modelimport_vouchers.py:261
msgid "Multiple matching variations were found."
msgstr "Multiple variazioni combacianti trovate."
msgstr ""
#: pretix/base/modelimport_orders.py:164
msgid "You need to select a variation for this product."
@@ -3524,15 +3543,15 @@ msgstr "Indirizzo di fatturazione"
#: pretix/base/modelimport_orders.py:251 pretix/base/modelimport_orders.py:397
msgid "Please enter a valid country code."
msgstr "Inserire un codice prefisso del paese valido."
msgstr ""
#: pretix/base/modelimport_orders.py:268 pretix/base/modelimport_orders.py:414
msgid "States are not supported for this country."
msgstr "Stati non sono supportati per questa nazione."
msgstr ""
#: pretix/base/modelimport_orders.py:276 pretix/base/modelimport_orders.py:422
msgid "Please enter a valid state."
msgstr "Inserire uno stato valido."
msgstr ""
#: pretix/base/modelimport_orders.py:325 pretix/control/forms/filter.py:651
msgid "Attendee e-mail address"
@@ -3556,7 +3575,7 @@ msgstr "Stato"
#: pretix/base/modelimport_orders.py:432
msgid "Calculate from product"
msgstr "Calculare dal prodotto"
msgstr ""
#: pretix/base/modelimport_orders.py:449
#: pretix/control/templates/pretixcontrol/checkin/index.html:111
@@ -3566,29 +3585,29 @@ msgstr "Codice biglietto"
#: pretix/base/modelimport_orders.py:450
msgid "Generate automatically"
msgstr "Generare automaticamente"
msgstr ""
#: pretix/base/modelimport_orders.py:459
msgid "You cannot assign a position secret that already exists."
msgstr "Non è possibile assegnare un segreto di posizione preesistente."
msgstr ""
#: pretix/base/modelimport_orders.py:490
msgid "Please enter a valid language code."
msgstr "Inserire un codice linguaggio valido."
msgstr ""
#: pretix/base/modelimport_orders.py:558 pretix/base/modelimport_orders.py:560
msgid "Please enter a valid sales channel."
msgstr "Inserire un valido canale di vendita."
msgstr ""
#: pretix/base/modelimport_orders.py:584
#: pretix/base/modelimport_vouchers.py:291
msgid "Multiple matching seats were found."
msgstr "Molteplici posti a sedere trovati."
msgstr ""
#: pretix/base/modelimport_orders.py:586
#: pretix/base/modelimport_vouchers.py:293
msgid "No matching seat was found."
msgstr "Nessun posto a sedere trovato."
msgstr ""
#: pretix/base/modelimport_orders.py:589
#: pretix/base/modelimport_vouchers.py:296 pretix/base/services/cart.py:212
@@ -3597,12 +3616,10 @@ msgstr "Nessun posto a sedere trovato."
msgid ""
"The seat you selected has already been taken. Please select a different seat."
msgstr ""
"Il posto a sedere da lei selezionato è stato già preso. Per piacere "
"selezionare un posto a sedere differente."
#: pretix/base/modelimport_orders.py:592 pretix/base/services/cart.py:209
msgid "You need to select a specific seat."
msgstr "è necessario selezionare un posto a sedere specifico."
msgstr ""
#: pretix/base/modelimport_orders.py:646 pretix/base/models/items.py:1618
#: pretix/base/models/items.py:1713 pretix/control/forms/item.py:91
@@ -3610,16 +3627,16 @@ msgstr "è necessario selezionare un posto a sedere specifico."
#: pretix/control/templates/pretixcontrol/items/question_edit.html:17
#: pretix/control/templates/pretixcontrol/items/questions.html:21
msgid "Question"
msgstr "Domanda"
msgstr ""
#: pretix/base/modelimport_orders.py:656 pretix/base/modelimport_orders.py:664
#: pretix/base/models/items.py:1777 pretix/base/models/items.py:1795
msgid "Invalid option selected."
msgstr "L' opzione selezionata è invalida."
msgstr ""
#: pretix/base/modelimport_orders.py:658 pretix/base/modelimport_orders.py:666
msgid "Ambiguous option selected."
msgstr "L'opzione selezionata è ambigua."
msgstr ""
#: pretix/base/modelimport_orders.py:697 pretix/base/models/orders.py:237
#: pretix/control/forms/orders.py:643 pretix/control/forms/organizer.py:784
@@ -3628,49 +3645,48 @@ msgstr "Cliente"
#: pretix/base/modelimport_orders.py:710
msgid "No matching customer was found."
msgstr "Nessun cliente ciìombaciante è stato trovato."
msgstr ""
#: pretix/base/modelimport_vouchers.py:50 pretix/base/models/vouchers.py:488
msgid "A voucher with this code already exists."
msgstr "Un voucher con questo codice esiste già."
msgstr ""
#: pretix/base/modelimport_vouchers.py:68 pretix/base/models/memberships.py:57
#: pretix/base/models/vouchers.py:196 pretix/control/views/vouchers.py:120
#: pretix/presale/templates/pretixpresale/organizers/customer_membership.html:28
msgid "Maximum usages"
msgstr "Massimi utlizzi"
msgstr ""
#: pretix/base/modelimport_vouchers.py:79
msgid "The maximum number of usages must be set."
msgstr "Il massimo numero di ultizzi deve essere configurato."
msgstr ""
#: pretix/base/modelimport_vouchers.py:88 pretix/base/models/vouchers.py:205
#, fuzzy
msgid "Minimum usages"
msgstr "Mini utlizzi"
msgstr "Utilizzi massimi per voucher"
#: pretix/base/modelimport_vouchers.py:103 pretix/base/models/vouchers.py:213
msgid "Maximum discount budget"
msgstr "Massimo discount budget"
msgstr ""
#: pretix/base/modelimport_vouchers.py:119 pretix/base/models/vouchers.py:225
#: pretix/control/forms/filter.py:2106
msgid "Reserve ticket from quota"
msgstr "Prenotare biglietto dalla quota disponibile"
msgstr ""
#: pretix/base/modelimport_vouchers.py:127 pretix/base/models/vouchers.py:233
msgid "Allow to bypass quota"
msgstr "Permettere di superare la quota"
msgstr ""
#: pretix/base/modelimport_vouchers.py:135 pretix/base/models/vouchers.py:239
msgid "Price mode"
msgstr "Modalità prezzo"
msgstr ""
#: pretix/base/modelimport_vouchers.py:150
#, python-brace-format
msgid "Could not parse {value} as a price mode, use one of {options}."
msgstr ""
"Non è possible leggere {value} come modalità prezzo, usare una delle "
"{options}."
#: pretix/base/modelimport_vouchers.py:160 pretix/base/models/vouchers.py:245
msgid "Voucher value"
@@ -3678,64 +3694,56 @@ msgstr "Valore Voucher"
#: pretix/base/modelimport_vouchers.py:165
msgid "It is pointless to set a value without a price mode."
msgstr "è inutile settare un valore senza modalità prezzo."
msgstr ""
#: pretix/base/modelimport_vouchers.py:237 pretix/base/models/items.py:2040
#: pretix/base/models/vouchers.py:272
#: pretix/control/templates/pretixcontrol/items/quota_edit.html:8
#: pretix/control/templates/pretixcontrol/items/quota_edit.html:15
msgid "Quota"
msgstr "Quota"
msgstr ""
#: pretix/base/modelimport_vouchers.py:253
msgid "You cannot specify a quota if you specified a product."
msgstr "Non è possiblile specificare una quota se hai specificato un prodotto."
msgstr ""
#: pretix/base/modelimport_vouchers.py:282 pretix/base/models/vouchers.py:495
msgid "You need to choose a date if you select a seat."
msgstr "è necessario scegliere una data se si seleziona un posto a sedere."
msgstr ""
#: pretix/base/modelimport_vouchers.py:299 pretix/base/models/vouchers.py:513
msgid "You need to choose a specific product if you select a seat."
msgstr ""
"è necessario scegliere un prodotto specifico se si seleziona un posto a "
"sedere."
#: pretix/base/modelimport_vouchers.py:302 pretix/base/models/vouchers.py:516
msgid "Seat-specific vouchers can only be used once."
msgstr ""
"Voucher specifici per un posto a sedere possono essere usati solo una volta "
"sola."
#: pretix/base/modelimport_vouchers.py:306 pretix/base/models/vouchers.py:519
#, fuzzy, python-brace-format
#, python-brace-format
msgid "You need to choose the product \"{prod}\" for this seat."
msgstr ""
"è necessario sceglliere il prodotto \"[prod}\" per questo posto a sedere."
#: pretix/base/modelimport_vouchers.py:318 pretix/base/models/vouchers.py:285
#: pretix/control/templates/pretixcontrol/vouchers/index.html:129
#: pretix/control/templates/pretixcontrol/vouchers/tags.html:42
#: pretix/control/views/vouchers.py:120
msgid "Tag"
msgstr "Etichetta"
msgstr ""
#: pretix/base/modelimport_vouchers.py:334 pretix/base/models/vouchers.py:297
msgid "Shows hidden products that match this voucher"
msgstr "Mostrare i prodotti nascoscti che combaciano con questo voucher"
msgstr ""
#: pretix/base/modelimport_vouchers.py:343 pretix/base/models/vouchers.py:301
msgid "Offer all add-on products for free when redeeming this voucher"
msgstr ""
"Offrire tutti i prodotti add-on gratuitamente riscattando questo voucher"
#: pretix/base/modelimport_vouchers.py:351 pretix/base/models/vouchers.py:305
msgid ""
"Include all bundled products without a designated price when redeeming this "
"voucher"
msgstr ""
"Includere tutti i prodotti inclusi nel pacchetto senza un prezzo quando "
"riscatti questo voucher"
#: pretix/base/models/auth.py:248
msgid "Is active"
@@ -3816,21 +3824,18 @@ msgstr ""
#: pretix/base/models/checkin.py:65
msgctxt "checkin"
msgid "Ignore check-ins on this list in statistics"
msgstr "Ignora check-in presenti in questa lista, per le statistiche"
msgstr ""
#: pretix/base/models/checkin.py:69
msgctxt "checkin"
msgid "Tickets with a check-in on this list should be considered \"used\""
msgstr ""
"Biglietti con un check-in in questa lista sono da considerarsi \"usati\""
#: pretix/base/models/checkin.py:70
msgid ""
"This is relevant in various situations, e.g. for deciding if a ticket can "
"still be canceled by the customer."
msgstr ""
"Questo è rilevante in varie situazioni, ad esempio per devidere se un "
"biglietto può ancora essere cancellato dal cliente."
#: pretix/base/models/checkin.py:74
msgctxt "checkin"
@@ -3903,10 +3908,6 @@ msgid ""
"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."
msgstr ""
"Questa opzione è deprecata e verra rimossa nei mesi seguenti. Come "
"rimpiazzo, il nuovo plugin \"Auto check-in\" può essere usato. Quando verra "
"rimossa questa opzione, migreremo automaticamente il tuo evento nell' "
"utilizzo del nuovo plugin."
#: pretix/base/models/checkin.py:340
msgid "Entry"
@@ -4292,18 +4293,15 @@ msgid ""
"Optional. No products will be sold after this date. If you do not set this "
"value, the presale will end after the end date of your event."
msgstr ""
"Opzionale. nessun prodotto verrà venduto oltre questa data. Se questo campo "
"non viene settato, il periodo di prevendita finirà dopo la data di fine "
"evento."
#: pretix/base/models/event.py:599 pretix/base/models/event.py:1480
#: pretix/control/forms/subevents.py:94
msgid "Optional. No products will be sold before this date."
msgstr "Opzionale. Nessun prodotto verrà venduto rima di questa data."
msgstr ""
#: pretix/base/models/event.py:624 pretix/control/navigation.py:65
msgid "Plugins"
msgstr "Plugin"
msgstr ""
#: pretix/base/models/event.py:631 pretix/base/pdf.py:229
#: pretix/control/forms/event.py:260 pretix/control/forms/filter.py:1677
@@ -4313,11 +4311,11 @@ msgstr "Plugin"
#: pretix/presale/templates/pretixpresale/organizers/index.html:90
#: pretix/presale/views/widget.py:682
msgid "Event series"
msgstr "Serie di eventi"
msgstr ""
#: pretix/base/models/event.py:635 pretix/base/models/event.py:1508
msgid "Seating plan"
msgstr "Piani dei posti a sedere"
msgstr ""
#: pretix/base/models/event.py:642 pretix/base/models/items.py:626
#, fuzzy
@@ -31769,7 +31767,7 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/organizers/index.html:25
msgid "Past events"
msgstr "Eventi passati"
msgstr ""
#: pretix/presale/templates/pretixpresale/organizers/index.html:27
msgid "Upcoming events"
@@ -31834,7 +31832,7 @@ msgstr ""
#: pretix/presale/views/cart.py:190
msgid "Please enter positive numbers only."
msgstr "Inserisci solo numeri positivi."
msgstr ""
#: pretix/presale/views/cart.py:428
msgid "We applied the voucher to as many products in your cart as we could."
@@ -31903,12 +31901,11 @@ msgid ""
"Your email address has not been updated since the address is already in use "
"for another customer account."
msgstr ""
"Il tuo indirizzo email non è stato aggiornato perché è già in uso per un "
"altro account cliente."
#: pretix/presale/views/customer.py:576
#, fuzzy
msgid "Your email address has been updated."
msgstr "Il tuo indirizzo email è stato aggiornato."
msgstr "La tua gift card è stata applicata."
#: pretix/presale/views/customer.py:789 pretix/presale/views/customer.py:800
#, python-brace-format
@@ -31916,41 +31913,43 @@ msgid ""
"We were unable to use your login since the email address {email} is already "
"used for a different account in this system."
msgstr ""
"Non siamo riusciti a utilizzare le tue credenziali di accesso poiché "
"l'indirizzo email {email} è già utilizzato per un altro account in questo "
"sistema."
#: pretix/presale/views/event.py:890
msgid "Unknown event code or not authorized to access this event."
msgstr ""
"Codice evento sconosciuto o non autorizzato ad accedere a questo evento."
#: pretix/presale/views/event.py:897
msgctxt "subevent"
msgid "No date selected."
msgstr "Nessuna data selezionata."
msgstr ""
#: pretix/presale/views/event.py:900
msgctxt "subevent"
msgid "Unknown date selected."
msgstr "Data selezionata sconosciuta."
msgstr ""
#: pretix/presale/views/event.py:925 pretix/presale/views/event.py:933
#: pretix/presale/views/event.py:936
msgid "Please go back and try again."
msgstr "Torna indietro e riprova."
msgstr ""
#: pretix/presale/views/event.py:949
#, fuzzy
#| msgid "Purchased"
msgid "Fake date time"
msgstr "Data e ora errati"
msgstr "Acquistato"
#: pretix/presale/views/event.py:961
#, fuzzy
#| msgid "Unknown order code or not authorized to access this order."
msgid "You are not allowed to access time machine mode."
msgstr "Non ti è consentito accedere alla modalità macchina del tempo."
msgstr "Numero di ordine sconosciuto oppure non autorizzato ad accedere."
#: pretix/presale/views/event.py:963
#, fuzzy
#| msgid "This gift card can only be used in test mode."
msgid "This feature is only available in test mode."
msgstr "This feature is only available in test mode."
msgstr "Questa gift card può essere utilizzata solo in modalità test."
#: pretix/presale/views/event.py:980
#, fuzzy
@@ -31968,16 +31967,17 @@ msgid "The payment is too late to be accepted."
msgstr "Il pagamento è troppo in ritardo per essere accettato."
#: pretix/presale/views/order.py:463
#, fuzzy
msgid "An invoice has been generated."
msgstr "È stata generata una fattura."
msgstr "Il dispositivo è statao creato."
#: pretix/presale/views/order.py:561
msgid "The payment method for this order cannot be changed."
msgstr "Il metodo di pagamento per questo ordine non può essere modificato."
msgstr ""
#: pretix/presale/views/order.py:572
msgid "A payment is currently pending for this order."
msgstr "Al momento è in sospeso un pagamento per questo ordine."
msgstr "Il pagamento è in attesa per questo ordine."
#: pretix/presale/views/order.py:853 pretix/presale/views/order.py:925
msgid "You cannot modify this order"
@@ -32004,8 +32004,6 @@ msgstr ""
#: pretix/presale/views/order.py:1119
msgid "Please click the link we sent you via email to download your tickets."
msgstr ""
"Clicca sul link che ti abbiamo inviato via email per scaricare i tuoi "
"biglietti."
#: pretix/presale/views/order.py:1600
#, python-brace-format
@@ -32013,28 +32011,22 @@ msgid ""
"The order has been changed. You can now proceed by paying the open amount of "
"{amount}."
msgstr ""
"L'ordine è stato modificato. Ora puoi procedere pagando l'importo scoperto "
"di {amount}."
#: pretix/presale/views/order.py:1612
msgid "You did not make any changes."
msgstr "Non hai apportato nessuna modifica."
msgstr ""
#: pretix/presale/views/order.py:1636
msgid "You may not change your order in a way that reduces the total price."
msgstr ""
"Non è possibile modificare l'ordine in modo da ridurre il prezzo totale."
#: pretix/presale/views/order.py:1638
msgid "You may only change your order in a way that increases the total price."
msgstr ""
"È possibile modificare l'ordine solo in modo da aumentare il prezzo totale."
#: pretix/presale/views/order.py:1640
msgid "You may not change your order in a way that changes the total price."
msgstr ""
"Non è possibile modificare l'ordine in modo tale da modificare il prezzo "
"totale."
#: pretix/presale/views/order.py:1642
msgid "You may not change your order in a way that would require a refund."
@@ -32063,14 +32055,10 @@ msgid ""
"{number} hours. If the email did not arrive, please check your spam folder "
"and also double check that you used the correct email address."
msgstr ""
"Se l'indirizzo email inserito è valido e associato a un ticket, ti abbiamo "
"già inviato un'email con un link al tuo ticket nelle ultime {number} ore. Se "
"l'email non è arrivata, controlla la cartella spam e verifica di aver "
"utilizzato l'indirizzo email corretto."
#: pretix/presale/views/user.py:91
msgid "We have trouble sending emails right now, please check back later."
msgstr "Al momento abbiamo problemi con l'invio delle email. Riprova più tardi."
msgstr ""
#: pretix/presale/views/user.py:94
msgid ""
@@ -32091,13 +32079,13 @@ msgid ""
msgstr ""
#: pretix/presale/views/waiting.py:141
#, python-brace-format
#, fuzzy, python-brace-format
msgid ""
"We've added you to the waiting list. We will send an email to {email} as "
"soon as this product gets available again."
msgstr ""
"Ti abbiamo aggiunto alla lista d'attesa. Ti invieremo un'email a {email} non "
"appena i biglietti saranno di nuovo disponibili."
"Ti abbiamo aggiunto alla lista d'attesa. Riceverai un'email non appena i "
"biglietti saranno di nuovo disponibili."
#: pretix/presale/views/waiting.py:169
msgid "We could not find you on our waiting list."
@@ -32141,7 +32129,7 @@ msgstr ""
#: pretix/settings.py:788
msgid "Write access"
msgstr "Accesso in scrittura"
msgstr ""
#: pretix/settings.py:799
msgid "Kosovo"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-09-26 11:22+0000\n"
"PO-Revision-Date: 2024-09-27 18:00+0000\n"
"PO-Revision-Date: 2024-08-28 10:03+0000\n"
"Last-Translator: Anarion Dunedain <anarion80@gmail.com>\n"
"Language-Team: Polish <https://translate.pretix.eu/projects/pretix/pretix/pl/"
">\n"
@@ -18,7 +18,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 "
"|| n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 5.7.2\n"
"X-Generator: Weblate 5.7\n"
#: pretix/_base_settings.py:79
msgid "English"
@@ -38,7 +38,7 @@ msgstr "Arabski"
#: pretix/_base_settings.py:83
msgid "Basque"
msgstr "Baskijski"
msgstr ""
#: pretix/_base_settings.py:84
msgid "Catalan"
@@ -649,7 +649,7 @@ msgstr "Hasło"
#: pretix/base/auth.py:176 pretix/base/auth.py:183
msgid "Your password must contain both numeric and alphabetic characters."
msgstr "Twoje hasło musi zawierać znaki alfabetyczne i numeryczne."
msgstr ""
#: pretix/base/auth.py:202 pretix/base/auth.py:212
#, python-format
@@ -657,13 +657,9 @@ msgid "Your password may not be the same as your previous password."
msgid_plural ""
"Your password may not be the same as one of your %(history_length)s previous "
"passwords."
msgstr[0] "Twoje hasło nie może być takie samo jak poprzednie hasło."
msgstr[0] ""
msgstr[1] ""
"Twoje hasło nie może być takie samo jak jedno z twoich %(history_length)s "
"poprzednich haseł."
msgstr[2] ""
"Twoje hasło nie może być takie samo jak jedno z twoich %(history_length)s "
"poprzednich haseł."
#: pretix/base/channels.py:168
msgid "Online shop"
@@ -742,8 +738,6 @@ msgid ""
"No supported Token Endpoint Auth Methods supported: "
"{token_endpoint_auth_methods_supported}"
msgstr ""
"Brak wspieranych metod autentykacji tokena: "
"{token_endpoint_auth_methods_supported}"
#: pretix/base/customersso/oidc.py:203 pretix/base/customersso/oidc.py:210
#: pretix/base/customersso/oidc.py:229 pretix/base/customersso/oidc.py:246
@@ -6167,14 +6161,10 @@ msgid ""
"business customers in other EU countries in a way that works for all "
"organizers. Use custom rules instead."
msgstr ""
"Ta funkcja zostanie usunięta w przyszłości, ponieważ nie obsługuje podatku "
"VAT dla klientów niebędących firmami w innych krajach UE w sposób, który "
"działa dla wszystkich organizatorów. Zamiast tego należy użyć reguł "
"niestandardowych."
#: pretix/base/models/tax.py:204
msgid "DEPRECATED"
msgstr "WYCOFANY"
msgstr ""
#: pretix/base/models/tax.py:205
msgid ""
@@ -9547,15 +9537,19 @@ msgid "Show event times and dates on the ticket shop"
msgstr "Pokaż godziny i daty wydarzeń w sklepie z biletami"
#: pretix/base/settings.py:1297
#, fuzzy
#| msgid ""
#| "If disabled, no date or time will be shown on the ticket shop's front "
#| "page. This settings does however not affect the display in other "
#| "locations."
msgid ""
"If disabled, no date or time will be shown on the ticket shop's front page. "
"This settings also affects a few other locations, however it should not be "
"expected that the date of the event is shown nowhere to users."
msgstr ""
"Jeśli opcja ta jest wyłączona, data ani godzina nie będą wyświetlane na "
"stronie głównej sklepu z biletami. To ustawienie ma również wpływ na kilka "
"innych lokalizacji, jednak nie należy oczekiwać, że data wydarzenia nie "
"będzie nigdzie wyświetlana użytkownikom."
"Jeśli opcja ta zostanie wyłączona, na stronie głównej sklepu z biletami nie "
"będzie wyświetlana data ani godzina. To ustawienie nie ma jednak wpływu na "
"wyświetlanie w innych lokalizacjach."
#: pretix/base/settings.py:1308
msgid "Show event end date"
@@ -12998,12 +12992,16 @@ msgid "Subject (if order will not expire automatically)"
msgstr "Temat (jeśli zamówienie nie wygasa automatycznie)"
#: pretix/control/forms/event.py:1146
#, fuzzy
#| msgid "Incomplete payment received: {code}"
msgid "Subject (if an incomplete payment was received)"
msgstr "Temat (jeśli otrzymano niekompletną płatność)"
msgstr "Otrzymana niekompletna płatność: {code}"
#: pretix/control/forms/event.py:1151
#, fuzzy
#| msgid "Incomplete payment received: {code}"
msgid "Text (if an incomplete payment was received)"
msgstr "Tekst (jeśli otrzymano niekompletną płatność)"
msgstr "Otrzymana niekompletna płatność: {code}"
#: pretix/control/forms/event.py:1154
msgid ""
@@ -19329,8 +19327,10 @@ msgstr ""
"doradcą podatkowym."
#: pretix/control/templates/pretixcontrol/event/tax_edit.html:44
#, fuzzy
#| msgid "Customers"
msgid "Custom rules"
msgstr "Reguły niestandardowe"
msgstr "Klienci"
#: pretix/control/templates/pretixcontrol/event/tax_edit.html:46
msgid ""
@@ -26725,8 +26725,6 @@ msgid ""
"The team could not be deleted because the team or one of its API tokens is "
"part of historical audit logs."
msgstr ""
"Zespół nie mógł zostać usunięty, ponieważ zespół lub jeden z jego tokenów "
"API jest częścią historycznych dzienników inspekcji."
#: pretix/control/views/organizer.py:703
msgid ""

View File

@@ -19,33 +19,21 @@
# 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 datetime import timedelta
from celery.result import AsyncResult
from django.conf import settings
from django.db import transaction
from django.db.models import QuerySet
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.functional import lazy
from django.utils.timezone import now
from django_scopes import scopes_disabled
from rest_framework import serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework import serializers, viewsets
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.reverse import reverse
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import CompatibleJSONField
from ...api.serializers.fields import UploadedFileField
from ...base.models import CachedFile, OrderPosition, SalesChannel
from ...base.models import SalesChannel
from ...base.pdf import PdfLayoutValidator
from ...helpers.http import ChunkBasedFileResponse
from ...multidomain.utils import static_absolute
from .models import TicketLayout, TicketLayoutItem
from .tasks import bulk_render
class ItemAssignmentSerializer(I18nAwareModelSerializer):
@@ -168,123 +156,3 @@ class TicketLayoutItemViewSet(viewsets.ReadOnlyModelViewSet):
**super().get_serializer_context(),
'event': self.request.event,
}
with scopes_disabled():
class RenderJobPartSerializer(serializers.Serializer):
orderposition = serializers.PrimaryKeyRelatedField(
queryset=OrderPosition.objects.none(),
required=True,
allow_null=False,
)
override_layout = serializers.PrimaryKeyRelatedField(
queryset=TicketLayout.objects.none(),
required=False,
allow_null=True,
)
override_channel = serializers.SlugRelatedField(
queryset=SalesChannel.objects.none(),
slug_field='identifier',
required=False,
allow_null=True,
)
class RenderJobSerializer(serializers.Serializer):
parts = RenderJobPartSerializer(many=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['parts'].child.fields['orderposition'].queryset = OrderPosition.objects.filter(order__event=self.context['event'])
self.fields['parts'].child.fields['override_layout'].queryset = self.context['event'].ticket_layouts.all()
self.fields['parts'].child.fields['override_channel'].queryset = self.context['event'].organizer.sales_channels.all()
def validate(self, attrs):
if len(attrs["parts"]) > 1000:
raise ValidationError({"parts": ["Please do not submit more than 1000 parts."]})
return super().validate(attrs)
class TicketRendererViewSet(viewsets.ViewSet):
permission = 'can_view_orders'
def get_serializer_kwargs(self):
return {}
def list(self, request, *args, **kwargs):
raise Http404()
def retrieve(self, request, *args, **kwargs):
raise Http404()
def update(self, request, *args, **kwargs):
raise Http404()
def partial_update(self, request, *args, **kwargs):
raise Http404()
def destroy(self, request, *args, **kwargs):
raise Http404()
@action(detail=False, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
def download(self, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
if cf.file:
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
return resp
elif not settings.HAS_CELERY:
return Response(
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
status=status.HTTP_410_GONE
)
res = AsyncResult(kwargs['asyncid'])
if res.failed():
if isinstance(res.info, dict) and res.info['exc_type'] == 'ExportError':
msg = res.info['exc_message']
else:
msg = 'Internal error'
return Response(
{'status': 'failed', 'message': msg},
status=status.HTTP_410_GONE
)
return Response(
{
'status': 'running' if res.state in ('PROGRESS', 'STARTED', 'SUCCESS') else 'waiting',
},
status=status.HTTP_409_CONFLICT
)
@action(detail=False, methods=['POST'])
def render_batch(self, *args, **kwargs):
serializer = RenderJobSerializer(data=self.request.data, context={
"event": self.request.event,
})
serializer.is_valid(raise_exception=True)
cf = CachedFile(web_download=False)
cf.date = now()
cf.expires = now() + timedelta(hours=24)
cf.save()
async_result = bulk_render.apply_async(args=(
self.request.event.id,
str(cf.id),
[
{
"orderposition": r["orderposition"].id,
"override_layout": r["override_layout"].id if r.get("override_layout") else None,
"override_channel": r["override_channel"].id if r.get("override_channel") else None,
} for r in serializer.validated_data["parts"]
]
))
url_kwargs = {
'asyncid': str(async_result.id),
'cfid': str(cf.id),
}
url_kwargs.update(self.kwargs)
return Response({
'download': reverse('api-v1:ticketpdfrenderer-download', kwargs=url_kwargs, request=self.request)
}, status=status.HTTP_202_ACCEPTED)

View File

@@ -19,26 +19,18 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import json
import logging
from io import BytesIO
from django.core.files.base import ContentFile
from django.db.models import Prefetch, prefetch_related_objects
from pypdf import PdfWriter
from pretix.base.models import (
CachedFile, Checkin, Event, EventMetaValue, ItemMetaValue,
ItemVariationMetaValue, OrderPosition, SalesChannel, SubEventMetaValue,
cachedfile_name,
CachedFile, Event, OrderPosition, cachedfile_name,
)
from pretix.base.services.orders import OrderError
from pretix.base.services.tasks import EventTask
from pretix.celery_app import app
from ...base.i18n import language
from ...base.services.export import ExportError
from .models import TicketLayout
from .ticketoutput import PdfTicketOutput
logger = logging.getLogger(__name__)
@@ -54,93 +46,3 @@ def tickets_create_pdf(event: Event, fileid: int, position: int, channel) -> int
file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
file.save()
return file.pk
@app.task(base=EventTask, throws=(OrderError, ExportError,))
def bulk_render(event: Event, fileid: int, parts: list) -> int:
file = CachedFile.objects.get(id=fileid)
channels = SalesChannel.objects.in_bulk([p["override_channel"] for p in parts if p.get("override_channel")])
layouts = TicketLayout.objects.in_bulk([p["override_layout"] for p in parts if p.get("override_layout")])
positions = OrderPosition.objects.all()
prefetch_related_objects([event.organizer], 'meta_properties')
prefetch_related_objects(
[event],
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'),
'questions',
'item_meta_properties',
)
positions = positions.prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.select_related("device")),
Prefetch('item', queryset=event.items.prefetch_related(
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached')
)),
'variation',
'answers', 'answers__options', 'answers__question',
'item__category',
'addon_to__answers', 'addon_to__answers__options', 'addon_to__answers__question',
Prefetch('addons', positions.select_related('item', 'variation')),
Prefetch('subevent', queryset=event.subevents.prefetch_related(
Prefetch('meta_values', to_attr='meta_values_cached',
queryset=SubEventMetaValue.objects.select_related('property'))
)),
'linked_media',
Prefetch('order', event.orders.select_related('invoice_address').prefetch_related(
Prefetch(
'positions',
positions.prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.select_related('device')),
Prefetch('item', queryset=event.items.prefetch_related(
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached')
)),
Prefetch('variation', queryset=event.items.prefetch_related(
Prefetch('meta_values', ItemVariationMetaValue.objects.select_related('property'),
to_attr='meta_values_cached')
)),
'answers', 'answers__options', 'answers__question',
'item__category',
Prefetch('subevent', queryset=event.subevents.prefetch_related(
Prefetch('meta_values', to_attr='meta_values_cached',
queryset=SubEventMetaValue.objects.select_related('property'))
)),
Prefetch('addons', positions.select_related('item', 'variation', 'seat'))
).select_related('addon_to', 'seat', 'addon_to__seat')
)
))
).select_related(
'addon_to', 'seat', 'addon_to__seat'
)
positions = positions.in_bulk([p["orderposition"] for p in parts])
merger = PdfWriter()
for part in parts:
p = positions[part["orderposition"]]
p.order.event = event # performance optimization
with (language(p.order.locale)):
kwargs = {}
if part.get("override_channel"):
kwargs["override_channel"] = channels[part["override_channel"]].identifier
if part.get("override_layout"):
l = layouts[part["override_layout"]]
kwargs["override_layout"] = json.loads(l.layout)
kwargs["override_background"] = l.background
prov = PdfTicketOutput(
event,
**kwargs,
)
filename, ctype, data = prov.generate(p)
merger.append(ContentFile(data))
outbuffer = BytesIO()
merger.write(outbuffer)
merger.close()
outbuffer.seek(0)
file.type = "application/pdf"
file.file.save(cachedfile_name(file, file.filename), ContentFile(outbuffer.getvalue()))
file.save()
return file.pk

View File

@@ -23,7 +23,7 @@ from django.urls import re_path
from pretix.api.urls import event_router
from pretix.plugins.ticketoutputpdf.api import (
TicketLayoutItemViewSet, TicketLayoutViewSet, TicketRendererViewSet,
TicketLayoutItemViewSet, TicketLayoutViewSet,
)
from pretix.plugins.ticketoutputpdf.views import (
LayoutCreate, LayoutDelete, LayoutEditorView, LayoutGetDefault,
@@ -48,4 +48,3 @@ urlpatterns = [
]
event_router.register('ticketlayouts', TicketLayoutViewSet)
event_router.register('ticketlayoutitems', TicketLayoutItemViewSet)
event_router.register('ticketpdfrenderer', TicketRendererViewSet, basename='ticketpdfrenderer')

View File

@@ -65,6 +65,7 @@ from pretix.base.services.cart import (
CartError, CartManager, add_payment_to_cart, error_messages, get_fees,
set_cart_addons,
)
from pretix.base.services.cross_selling import CrossSellingService
from pretix.base.services.memberships import validate_memberships_in_order
from pretix.base.services.orders import perform_order
from pretix.base.services.tasks import EventTask
@@ -93,7 +94,8 @@ from pretix.presale.views import (
CartMixin, get_cart, get_cart_is_free, get_cart_total,
)
from pretix.presale.views.cart import (
cart_session, create_empty_cart_id, get_or_create_cart_id,
_items_from_post_data, cart_session, create_empty_cart_id,
get_or_create_cart_id,
)
from pretix.presale.views.event import get_grouped_items
from pretix.presale.views.questions import QuestionsViewMixin
@@ -486,9 +488,31 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
label = pgettext_lazy('checkoutflow', 'Add-on products')
icon = 'puzzle-piece'
def _check_is_applicable(self, request):
self.request = request
# check whether addons are applicable
if get_cart(request).filter(item__addons__isnull=False).exists():
return True
# don't re-check whether cross-selling is applicable if we're already past the AddOnsStep
cur_step_identifier = request.resolver_match.kwargs.get('step')
is_past_this_step = any(step.identifier == cur_step_identifier for step in request._checkout_flow[request._checkout_flow.index(self) + 1:])
if is_past_this_step:
applicable = self.cart_session.get('_checkoutflow_addons_applicable', None)
if applicable is not None:
return applicable
# check whether cross-selling is applicable
applicable = self.cross_selling_is_applicable
self.cart_session['_checkoutflow_addons_applicable'] = applicable
return applicable
def is_applicable(self, request):
if not hasattr(request, '_checkoutflow_addons_applicable'):
request._checkoutflow_addons_applicable = get_cart(request).filter(item__addons__isnull=False).exists()
cur_step_identifier = request.resolver_match.kwargs.get('step')
request._checkoutflow_addons_applicable = self._check_is_applicable(request) or cur_step_identifier == self.identifier
return request._checkoutflow_addons_applicable
def is_completed(self, request, warn=False):
@@ -605,10 +629,21 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
formset.append(formsetentry)
return formset
@cached_property
def cross_selling_is_applicable(self):
return any(len(items) > 0 for (category, items, form_prefix) in self.cross_selling_data)
@cached_property
def cross_selling_data(self):
return CrossSellingService(
self.request.event, self.request.sales_channel, self.positions, self.request.customer
).get_data()
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['forms'] = self.forms
ctx['cart'] = self.get_cart()
ctx['cross_selling_data'] = self.cross_selling_data
return ctx
def get_success_message(self, value):
@@ -687,7 +722,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
def post(self, request, *args, **kwargs):
self.request = request
data = []
addons = []
for f in self.forms:
for c in f['categories']:
try:
@@ -697,7 +732,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
return self.get(request, *args, **kwargs)
for (i, v), (c, price) in selected.items():
data.append({
addons.append({
'addon_to': f['pos'].pk,
'item': i.pk,
'variation': v.pk if v else None,
@@ -705,7 +740,9 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
'price': price,
})
return self.do(self.request.event.id, data, get_or_create_cart_id(self.request),
add_to_cart_items = _items_from_post_data(self.request, warn_if_empty=False)
return self.do(self.request.event.id, addons, add_to_cart_items, get_or_create_cart_id(self.request),
invoice_address=self.invoice_address.pk, locale=get_language(),
sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None))

View File

@@ -6,9 +6,12 @@
{% load money %}
{% load thumb %}
{% block inner %}
<p>
{% trans "For some of the products in your cart, you can choose additional options before you continue." %}
</p>
{% if forms %}
<p>
{% trans "For some of the products in your cart, you can choose additional options before you continue." %}
</p>
{% endif %}
<form class="form-horizontal" method="post" data-asynctask
data-asynctask-headline="{% trans "We're now trying to book these add-ons for you!" %}">
{% csrf_token %}
@@ -17,10 +20,12 @@
<details class="panel panel-default" open>
<summary class="panel-heading">
<h3 class="panel-title">
<span class="sr-only">{% trans "Add-ons:" %}</span>
<strong>{{ form.item.name }}{% if form.variation %}
{{ form.variation }}
{% endif %}</strong>
<span>
{% trans "Additional options for" %}
<strong>{{ form.item.name }}{% if form.variation %}
{{ form.variation }}
{% endif %}</strong>
</span>
</h3>
</summary>
<div id="cp{{ form.pos.pk }}">
@@ -46,6 +51,20 @@
</details>
{% endfor %}
</div>
{% if cross_selling_data %}
<details class="panel panel-default cross-selling" open>
<summary class="panel-heading">
<h3 class="panel-title">
{% trans "More recommendations" %}
</h3>
</summary>
<div class="panel-body">
{% include "pretixpresale/event/fragment_product_list.html" with items_by_category=cross_selling_data ev=event %}
</div>
</details>
{% endif %}
<div class="row checkout-button-row">
<div class="col-md-4 col-sm-6">
<a class="btn btn-block btn-default btn-lg"

View File

@@ -5,20 +5,31 @@
{% load thumb %}
{% load eventsignal %}
{% load rich_text %}
{% for tup in items_by_category %}
{% if tup.0 %}
<section aria-labelledby="category-{{ tup.0.id }}"{% if tup.0.description %} aria-describedby="category-info-{{ tup.0.id }}"{% endif %}>
<h3 id="category-{{ tup.0.id }}">{{ tup.0.name }}</h3>
{% if tup.0.description %}
<div id="category-info-{{ tup.0.id }}">{{ tup.0.description|localize|rich_text }}</div>
{% for tup in items_by_category %}{% with category=tup.0 items=tup.1 form_prefix=tup.2 %}
{% if category %}
<section aria-labelledby="{{ form_prefix }}category-{{ category.id }}"{% if category.description %} aria-describedby="{{ form_prefix }}category-info-{{ category.id }}"{% endif %}>
<h3 id="{{ form_prefix }}category-{{ category.id }}">{{ category.name }}
{% if category.subevent_name %}
<small class="text-muted"><i class="fa fa-calendar"></i> {{ category.subevent_name }}</small>
{% endif %}
{% if category.category_has_discount %}
<small class="text-success">
<i class="fa fa-star" aria-hidden="true"></i>
<span class="sr-only">Congratulations!</span>
{% trans "Your order qualifies for a discount" %}
</small>
{% endif %}
</h3>
{% if category.description %}
<div id="{{ form_prefix }}category-info-{{ category.id }}">{{ category.description|localize|rich_text }}</div>
{% endif %}
{% else %}
<section aria-labelledby="category-none">
<h3 id="category-none" class="sr-only">{% trans "Uncategorized items" %}</h3>
<section aria-labelledby="{{ form_prefix }}category-none">
<h3 id="{{ form_prefix }}category-none" class="sr-only">{% trans "Uncategorized items" %}</h3>
{% endif %}
{% for item in tup.1 %}
{% for item in items %}
{% if item.has_variations %}
<article aria-labelledby="item-{{ item.pk }}-legend"{% if item.description %} aria-describedby="item-{{ item.pk }}-description"{% endif %} class="item-with-variations{% if event.settings.show_variations_expanded %} details-open{% endif %}" id="item-{{ item.pk }}">
<article aria-labelledby="{{ form_prefix }}item-{{ item.pk }}-legend"{% if item.description %} aria-describedby="{{ form_prefix }}item-{{ item.pk }}-description"{% endif %} class="item-with-variations{% if event.settings.show_variations_expanded %} details-open{% endif %}" id="{{ form_prefix }}item-{{ item.pk }}">
<div class="row product-row headline">
<div class="col-md-8 col-sm-6 col-xs-12">
{% if item.picture %}
@@ -32,9 +43,9 @@
</a>
{% endif %}
<div class="product-description {% if item.picture %}with-picture{% endif %}">
<h4 id="item-{{ item.pk }}-legend">{{ item.name }}</h4>
<h4 id="{{ form_prefix }}item-{{ item.pk }}-legend">{{ item.name }}</h4>
{% if item.description %}
<div id="item-{{ item.pk }}-description" class="product-description">
<div id="{{ form_prefix }}item-{{ item.pk }}-description" class="product-description">
{{ item.description|localize|rich_text }}
</div>
{% endif %}
@@ -101,14 +112,14 @@
</div>
<div class="variations {% if not event.settings.show_variations_expanded %}variations-collapsed{% endif %}">
{% for var in item.available_variations %}
<article aria-labelledby="item-{{ item.pk }}-{{ var.pk }}-legend"{% if var.description %} aria-describedby="item-{{ item.pk }}-{{ var.pk }}-description"{% endif %} class="row product-row variation" id="item-{{ item.pk }}-{{ var.pk }}"
<article aria-labelledby="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-legend"{% if var.description %} aria-describedby="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-description"{% endif %} class="row product-row variation" id="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}"
{% if not item.free_price %}
data-price="{% if event.settings.display_net_prices %}{{ var.display_price.net|unlocalize }}{% else %}{{ var.display_price.gross|unlocalize }}{% endif %}"
{% endif %}>
<div class="col-md-8 col-sm-6 col-xs-12">
<h5 id="item-{{ item.pk }}-{{ var.pk }}-legend">{{ var }}</h5>
<h5 id="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-legend">{{ var }}</h5>
{% if var.description %}
<div id="item-{{ item.pk }}-{{ var.pk }}-description" class="variation-description">
<div id="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-description" class="variation-description">
{{ var.description|localize|rich_text }}
</div>
{% endif %}
@@ -139,11 +150,11 @@
<div class="input-group input-group-price">
<span class="input-group-addon">{{ event.currency }}</span>
<input type="number" class="form-control input-item-price"
id="price-variation-{{ item.pk }}-{{ var.pk }}"
id="{{ form_prefix }}price-variation-{{ item.pk }}-{{ var.pk }}"
{% if not ev.presale_is_running %}disabled{% endif %}
placeholder="0"
min="{% if event.settings.display_net_prices %}{{ var.display_price.net|money_numberfield:event.currency }}{% else %}{{ var.display_price.gross|money_numberfield:event.currency }}{% endif %}"
name="price_{{ item.id }}_{{ var.id }}"
name="{{ form_prefix }}price_{{ item.id }}_{{ var.id }}"
{% if var.suggested_price.gross != var.display_price.gross %}
{% if event.settings.display_net_prices %}
title="{% blocktrans trimmed with item=var.value price=var.display_price.net|money:event.currency %}Modify price for {{ item }}, at least {{ price }}{% endblocktrans %}"
@@ -200,16 +211,16 @@
data-checked-onchange="price-variation-{{ item.pk }}-{{ var.pk }}"
{% endif %}
{% if not ev.presale_is_running %}disabled{% endif %}
id="variation_{{ item.id }}_{{ var.id }}"
name="variation_{{ item.id }}_{{ var.id }}"
id="{{ form_prefix }}variation_{{ item.id }}_{{ var.id }}"
name="{{ form_prefix }}variation_{{ item.id }}_{{ var.id }}"
aria-label="{% blocktrans with item=item.name var=var %}Add {{ item }}, {{ var }} to cart{% endblocktrans %}"
{% if var.description %} aria-describedby="item-{{ item.pk }}-{{ var.pk }}-description"{% endif %}>
{% if var.description %} aria-describedby="{{ form_prefix }}item-{{ item.pk }}-{{ var.pk }}-description"{% endif %}>
<i class="fa fa-shopping-cart" aria-hidden="true"></i>
{% trans "Select" context "checkbox" %}
</label>
{% else %}
<div class="input-item-count-group">
<button type="button" data-step="-1" data-controls="variation_{{ item.id }}_{{ var.id }}" class="btn btn-default input-item-count-dec" aria-label="{% trans "Decrease quantity" %}"
<button type="button" data-step="-1" data-controls="{{ form_prefix }}variation_{{ item.id }}_{{ var.id }}" class="btn btn-default input-item-count-dec" aria-label="{% trans "Decrease quantity" %}"
{% if not ev.presale_is_running %}disabled{% endif %}>-</button>
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
{% if not ev.presale_is_running %}disabled{% endif %}
@@ -217,10 +228,10 @@
data-checked-onchange="price-variation-{{ item.pk }}-{{ var.pk }}"
{% endif %}
max="{{ var.order_max }}"
id="variation_{{ item.id }}_{{ var.id }}"
name="variation_{{ item.id }}_{{ var.id }}"
id="{{ form_prefix }}variation_{{ item.id }}_{{ var.id }}"
name="{{ form_prefix }}variation_{{ item.id }}_{{ var.id }}"
aria-label="{% blocktrans with item=item.name var=var.name %}Quantity of {{ item }}, {{ var }} to order{% endblocktrans %}">
<button type="button" data-step="1" data-controls="variation_{{ item.id }}_{{ var.id }}" class="btn btn-default input-item-count-inc" aria-label="{% trans "Increase quantity" %}"
<button type="button" data-step="1" data-controls="{{ form_prefix }}variation_{{ item.id }}_{{ var.id }}" class="btn btn-default input-item-count-inc" aria-label="{% trans "Increase quantity" %}"
{% if not ev.presale_is_running %}disabled{% endif %}>+</button>
</div>
{% endif %}
@@ -234,7 +245,7 @@
</div>
</article>
{% else %}
<article aria-labelledby="item-{{ item.pk }}-legend"{% if item.description %} aria-describedby="item-{{ item.pk }}-description"{% endif %} class="row product-row simple" id="item-{{ item.pk }}"
<article aria-labelledby="{{ form_prefix }}item-{{ item.pk }}-legend"{% if item.description %} aria-describedby="{{ form_prefix }}item-{{ item.pk }}-description"{% endif %} class="row product-row simple" id="{{ form_prefix }}item-{{ item.pk }}"
{% if not item.free_price %}
data-price="{% if event.settings.display_net_prices %}{{ item.display_price.net|unlocalize }}{% else %}{{ item.display_price.gross|unlocalize }}{% endif %}"
{% endif %}>
@@ -250,9 +261,9 @@
</a>
{% endif %}
<div class="product-description {% if item.picture %}with-picture{% endif %}">
<h4 id="item-{{ item.pk }}-legend">{{ item.name }}</h4>
<h4 id="{{ form_prefix }}item-{{ item.pk }}-legend">{{ item.name }}</h4>
{% if item.description %}
<div id="item-{{ item.pk }}-description" class="product-description">
<div id="{{ form_prefix }}item-{{ item.pk }}-description" class="product-description">
{{ item.description|localize|rich_text }}
</div>
{% endif %}
@@ -293,10 +304,10 @@
<label class="sr-only" for="price-item-{{ item.pk }}">{% blocktrans trimmed with item=item.name currency=event.currency %}Set price in {{ currency }} for {{ item }}{% endblocktrans %}</label>
<span class="input-group-addon" aria-hidden="true">{{ event.currency }}</span>
<input type="number" class="form-control input-item-price" placeholder="0"
id="price-item-{{ item.pk }}"
id="{{ form_prefix }}price-item-{{ item.pk }}"
{% if not ev.presale_is_running %}disabled{% endif %}
min="{% if event.settings.display_net_prices %}{{ item.display_price.net|money_numberfield:event.currency }}{% else %}{{ item.display_price.gross|money_numberfield:event.currency }}{% endif %}"
name="price_{{ item.id }}"
name="{{ form_prefix }}price_{{ item.id }}"
{% if item.suggested_price.gross != item.display_price.gross %}
{% if event.settings.display_net_prices %}
title="{% blocktrans trimmed with item=item.name price=item.display_price.net|money:event.currency %}Modify price for {{ item }}, at least {{ price }}{% endblocktrans %}"
@@ -352,15 +363,15 @@
data-checked-onchange="price-item-{{ item.pk }}"
{% endif %}
{% if not ev.presale_is_running %}disabled{% endif %}
name="item_{{ item.id }}" id="item_{{ item.id }}"
name="{{ form_prefix }}item_{{ item.id }}" id="{{ form_prefix }}item_{{ item.id }}"
aria-label="{% blocktrans with item=item.name %}Add {{ item }} to cart{% endblocktrans %}"
{% if item.description %} aria-describedby="item-{{ item.id }}-description"{% endif %}>
{% if item.description %} aria-describedby="{{ form_prefix }}item-{{ item.id }}-description"{% endif %}>
<i class="fa fa-shopping-cart" aria-hidden="true"></i>
{% trans "Select" context "checkbox" %}
</label>
{% else %}
<div class="input-item-count-group">
<button type="button" data-step="-1" data-controls="item_{{ item.id }}" class="btn btn-default input-item-count-dec" aria-label="{% trans "Decrease quantity" %}"
<button type="button" data-step="-1" data-controls="{{ form_prefix }}item_{{ item.id }}" class="btn btn-default input-item-count-dec" aria-label="{% trans "Decrease quantity" %}"
{% if not ev.presale_is_running %}disabled{% endif %}>-</button>
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
{% if not ev.presale_is_running %}disabled{% endif %}
@@ -369,11 +380,11 @@
data-checked-onchange="price-item-{{ item.pk }}"
{% endif %}
max="{{ item.order_max }}"
name="item_{{ item.id }}"
id="item_{{ item.id }}"
name="{{ form_prefix }}item_{{ item.id }}"
id="{{ form_prefix }}item_{{ item.id }}"
aria-label="{% blocktrans with item=item.name %}Quantity of {{ item }} to order{% endblocktrans %}"
{% if item.description %} aria-describedby="item-{{ item.id }}-description"{% endif %}>
<button type="button" data-step="1" data-controls="item_{{ item.id }}" class="btn btn-default input-item-count-inc" aria-label="{% trans "Increase quantity" %}"
{% if item.description %} aria-describedby="{{ form_prefix }}item-{{ item.id }}-description"{% endif %}>
<button type="button" data-step="1" data-controls="{{ form_prefix }}item_{{ item.id }}" class="btn btn-default input-item-count-inc" aria-label="{% trans "Increase quantity" %}"
{% if not ev.presale_is_running %}disabled{% endif %}>+</button>
</div>
{% endif %}
@@ -386,4 +397,4 @@
{% endif %}
{% endfor %}
</section>
{% endfor %}
{% endwith %}{% endfor %}

View File

@@ -100,23 +100,10 @@ def get_customer(request):
request._cached_customer = None
else:
session_hash = session.get(hash_session_key)
session_auth_hash = customer.get_session_auth_hash()
session_hash_verified = session_hash and constant_time_compare(
session_hash,
session_auth_hash,
customer.get_session_auth_hash()
)
if not session_hash_verified:
# If the current secret does not verify the session, try
# with the fallback secrets and stop when a matching one is
# found.
if session_hash and any(
constant_time_compare(session_hash, fallback_auth_hash)
for fallback_auth_hash in customer.get_session_auth_fallback_hash()
):
request.session.cycle_key()
request.session[hash_session_key] = session_auth_hash
session_hash_verified = True
if session_hash_verified:
request._cached_customer = customer
else:

View File

@@ -318,14 +318,14 @@ def cart_exists(request):
def get_cart(request):
from pretix.presale.views.cart import get_or_create_cart_id
qqs = request.event.questions.all()
qqs = qqs.filter(ask_during_checkin=False, hidden=False)
if not hasattr(request, '_cart_cache'):
cart_id = get_or_create_cart_id(request, create=False)
if not cart_id:
request._cart_cache = CartPosition.objects.none()
else:
qqs = request.event.questions.all()
qqs = qqs.filter(ask_during_checkin=False, hidden=False)
request._cart_cache = CartPosition.objects.filter(
cart_id=cart_id, event=request.event
).annotate(

View File

@@ -151,104 +151,114 @@ class CartActionMixin:
except InvoiceAddress.DoesNotExist:
return InvoiceAddress()
def _item_from_post_value(self, key, value, voucher=None, voucher_ignore_if_redeemed=False):
if value.strip() == '' or '_' not in key:
return
if not key.startswith('item_') and not key.startswith('variation_') and not key.startswith('seat_'):
return
parts = key.split("_")
price = self.request.POST.get('price_' + "_".join(parts[1:]), "")
subevent = None
if 'subevent' in self.request.POST:
try:
subevent = int(self.request.POST.get('subevent'))
except ValueError:
pass
if key.startswith('seat_'):
try:
return {
'item': int(parts[1]),
'variation': int(parts[2]) if len(parts) > 2 else None,
'count': 1,
'seat': value,
'price': price,
'voucher': voucher,
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
'subevent': subevent
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
def _item_from_post_value(request, key, value, voucher=None, voucher_ignore_if_redeemed=False):
if value.strip() == '' or '_' not in key:
return
subevent = None
if key.startswith('subevent_'):
try:
amount = int(value)
parts = key.split('_', 2)
subevent = int(parts[1])
key = parts[2]
except ValueError:
pass
elif 'subevent' in request.POST:
try:
subevent = int(request.POST.get('subevent'))
except ValueError:
raise CartError(_('Please enter numbers only.'))
if amount < 0:
raise CartError(_('Please enter positive numbers only.'))
elif amount == 0:
return
if key.startswith('item_'):
try:
return {
'item': int(parts[1]),
'variation': None,
'count': amount,
'price': price,
'voucher': voucher,
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
'subevent': subevent
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
elif key.startswith('variation_'):
try:
return {
'item': int(parts[1]),
'variation': int(parts[2]),
'count': amount,
'price': price,
'voucher': voucher,
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
'subevent': subevent
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
def _items_from_post_data(self):
"""
Parses the POST data and returns a list of dictionaries
"""
# Compatibility patch that makes the frontend code a lot easier
req_items = list(self.request.POST.lists())
if '_voucher_item' in self.request.POST and '_voucher_code' in self.request.POST:
req_items.append((
'%s' % self.request.POST['_voucher_item'], ('1',)
))
pass
items = []
if 'raw' in self.request.POST:
items += json.loads(self.request.POST.get("raw"))
for key, values in req_items:
for value in values:
try:
item = self._item_from_post_value(key, value, self.request.POST.get('_voucher_code'),
voucher_ignore_if_redeemed=self.request.POST.get('_voucher_ignore_if_redeemed') == 'on')
except CartError as e:
messages.error(self.request, str(e))
return
if item:
items.append(item)
if not key.startswith('item_') and not key.startswith('variation_') and not key.startswith('seat_'):
return
if len(items) == 0:
messages.warning(self.request, _('You did not select any products.'))
return []
return items
parts = key.split("_")
price = request.POST.get('price_' + "_".join(parts[1:]), "")
if key.startswith('seat_'):
try:
return {
'item': int(parts[1]),
'variation': int(parts[2]) if len(parts) > 2 else None,
'count': 1,
'seat': value,
'price': price,
'voucher': voucher,
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
'subevent': subevent
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
try:
amount = int(value)
except ValueError:
raise CartError(_('Please enter numbers only.'))
if amount < 0:
raise CartError(_('Please enter positive numbers only.'))
elif amount == 0:
return
if key.startswith('item_'):
try:
return {
'item': int(parts[1]),
'variation': None,
'count': amount,
'price': price,
'voucher': voucher,
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
'subevent': subevent
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
elif key.startswith('variation_'):
try:
return {
'item': int(parts[1]),
'variation': int(parts[2]),
'count': amount,
'price': price,
'voucher': voucher,
'voucher_ignore_if_redeemed': voucher_ignore_if_redeemed,
'subevent': subevent
}
except ValueError:
raise CartError(_('Please enter numbers only.'))
def _items_from_post_data(request, warn_if_empty=True):
"""
Parses the POST data and returns a list of dictionaries
"""
# Compatibility patch that makes the frontend code a lot easier
req_items = list(request.POST.lists())
if '_voucher_item' in request.POST and '_voucher_code' in request.POST:
req_items.append((
'%s' % request.POST['_voucher_item'], ('1',)
))
pass
items = []
if 'raw' in request.POST:
items += json.loads(request.POST.get("raw"))
for key, values in req_items:
for value in values:
try:
item = _item_from_post_value(request, key, value, request.POST.get('_voucher_code'),
voucher_ignore_if_redeemed=request.POST.get('_voucher_ignore_if_redeemed') == 'on')
except CartError as e:
messages.error(request, str(e))
return
if item:
items.append(item)
if len(items) == 0 and warn_if_empty:
messages.warning(request, _('You did not select any products.'))
return []
return items
@scopes_disabled()
@@ -534,7 +544,7 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
cs = cart_session(request)
widget_data = cs.get('widget_data', {})
items = self._items_from_post_data()
items = _items_from_post_data(self.request)
if items:
return self.do(self.request.event.id, items, cart_id, translation.get_language(),
self.invoice_address.pk, widget_data, self.request.sales_channel.identifier,

View File

@@ -111,7 +111,8 @@ def item_group_by_category(items):
)
def get_grouped_items(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0, base_qs=None, allow_addons=False,
def get_grouped_items(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
@@ -193,7 +194,9 @@ def get_grouped_items(event, *, channel: SalesChannel, subevent=None, voucher=No
)
)
items = base_qs.using(settings.DATABASE_REPLICA).filter_available(channel=channel.identifier, voucher=voucher, allow_addons=allow_addons).select_related(
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(

View File

@@ -156,10 +156,11 @@ class OrderOpen(EventViewMixin, OrderDetailMixin, View):
def get(self, request, *args, **kwargs):
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
if self.order.check_email_confirm_secret(kwargs.get('hash')) and not self.order.email_known_to_work:
self.order.log_action('pretix.event.order.contact.confirmed')
self.order.email_known_to_work = True
self.order.save(update_fields=['email_known_to_work'])
if kwargs.get('hash') == self.order.email_confirm_hash():
if not self.order.email_known_to_work:
self.order.log_action('pretix.event.order.contact.confirmed')
self.order.email_known_to_work = True
self.order.save(update_fields=['email_known_to_work'])
return redirect(self.get_order_url())

View File

@@ -94,13 +94,6 @@ else:
pass # os.chown is not available on Windows
f.write(SECRET_KEY)
SECRET_KEY_FALLBACKS = []
for i in range(10):
if config.has_option('django', f'secret_fallback{i}'):
SECRET_KEY_FALLBACKS.append(config.get('django', f'secret_fallback{i}'))
# Adjustable settings
debug_fallback = "runserver" in sys.argv or "runserver_plus" in sys.argv

View File

@@ -11,7 +11,7 @@
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"rollup": "^2.79.1",
"rollup-plugin-vue": "^5.0.1",
"vue": "^2.7.16",
@@ -1734,13 +1734,14 @@
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz",
"integrity": "sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz",
"integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-builtin-module": "^3.2.1",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
},
@@ -2101,6 +2102,17 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/call-bind": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -2767,6 +2779,20 @@
"node": ">=8"
}
},
"node_modules/is-builtin-module": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz",
"integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==",
"dependencies": {
"builtin-modules": "^3.3.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-core-module": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",
@@ -5136,13 +5162,14 @@
}
},
"@rollup/plugin-node-resolve": {
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz",
"integrity": "sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==",
"version": "15.2.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz",
"integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==",
"requires": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-builtin-module": "^3.2.1",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
}
@@ -5390,6 +5417,11 @@
"update-browserslist-db": "^1.1.0"
}
},
"builtin-modules": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz",
"integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw=="
},
"call-bind": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -5888,6 +5920,14 @@
"binary-extensions": "^2.0.0"
}
},
"is-builtin-module": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz",
"integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==",
"requires": {
"builtin-modules": "^3.3.0"
}
},
"is-core-module": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz",

View File

@@ -7,7 +7,7 @@
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-node-resolve": "^15.3.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"vue": "^2.7.16",
"rollup": "^2.79.1",
"rollup-plugin-vue": "^5.0.1",

View File

@@ -397,6 +397,9 @@ var form_handlers = function (el) {
enabled = !enabled;
}
var $toggling = dependent;
if (dependent.attr("data-disable-dependent")) {
$toggling.attr('disabled', !enabled);
}
if (dependent.get(0).tagName.toLowerCase() !== "div") {
$toggling = dependent.closest('.form-group');
}
@@ -411,8 +414,9 @@ var form_handlers = function (el) {
}
};
update();
dependency.closest('.form-group').find('[name=' + dependency.attr("name") + ']').on("change", update);
dependency.closest('.form-group').find('[name=' + dependency.attr("name") + ']').on("dp.change", update);
dependency.each(function() {
$(this).closest('.form-group').find('[name=' + $(this).attr("name") + ']').on("change dp.change", update);
})
});
el.find("input[data-required-if], select[data-required-if], textarea[data-required-if]").each(function () {

View File

@@ -567,7 +567,7 @@ table td > .checkbox input[type="checkbox"] {
}
}
}
.form-horizontal .big-radio {
.form-horizontal .big-radio, .form-horizontal .big-radio-wrapper .radio {
border: 1px solid #ccc;
border-bottom: 0;
padding: 0;
@@ -588,6 +588,17 @@ table td > .checkbox input[type="checkbox"] {
border-bottom: 1px solid #ccc;
}
}
.form-horizontal .control-label:has(+.big-radio-wrapper),
.form-horizontal .control-label:has(+div > .big-radio) {
padding-top: 16px;
}
.form-horizontal .big-radio-wrapper .radio label {
font-weight: bold;
}
.form-horizontal .big-radio-wrapper .radio label > span {
display: block;
font-weight: normal;
}
.accordion-radio {
display: block;
margin: 0;

View File

@@ -102,6 +102,17 @@
}
}
}
.cross-selling .panel-body h3 {
font-size: 21px;
line-height: inherit;
margin-top: 20px;
}
.cross-selling .panel-body > *:first-child > h3:first-child {
margin-top: 0;
}
.cross-selling .panel-body h3 small {
padding-left: 20px;
}
.panel-confirm {

View File

@@ -807,19 +807,6 @@ def test_event_update_plugins_validation(token_client, organizer, event, item, m
)
assert resp.status_code == 200
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug),
{
"all_sales_channels": False,
"limit_sales_channels": ["web"],
"sales_channels": ["bar"],
},
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == ('{"limit_sales_channels":["If \'limit_sales_channels\' is set, the legacy '
'attribute \'sales_channels\' must not be set or set to the same list."]}')
@pytest.mark.django_db
def test_event_test_mode(token_client, organizer, event):

View File

@@ -128,7 +128,10 @@ TEST_CATEGORY_RES = {
"description": {"en": ""},
"internal_name": None,
"position": 0,
"is_addon": False
"is_addon": False,
"cross_selling_mode": None,
"cross_selling_condition": None,
"cross_selling_match_products": [],
}
@@ -211,6 +214,44 @@ def test_category_update(token_client, organizer, event, team, category):
assert ItemCategory.objects.get(pk=category.pk).name == {"en": "Test"}
@pytest.mark.django_db
def test_category_update_cross_selling_options(token_client, organizer, event, team, category):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/categories/{}/'.format(organizer.slug, event.slug, category.pk),
{
"cross_selling_mode": "both",
},
format='json'
)
assert resp.status_code == 200
with scopes_disabled():
assert ItemCategory.objects.get(pk=category.pk).cross_selling_mode == 'both'
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/categories/{}/'.format(organizer.slug, event.slug, category.pk),
{
"cross_selling_mode": "something",
},
format='json'
)
assert resp.status_code == 400
with scopes_disabled():
assert ItemCategory.objects.get(pk=category.pk).cross_selling_mode == 'both'
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/categories/{}/'.format(organizer.slug, event.slug, category.pk),
{
"is_addon": True,
},
format='json'
)
assert resp.status_code == 400
assert 'mutually exclusive' in str(resp.data)
with scopes_disabled():
assert ItemCategory.objects.get(pk=category.pk).cross_selling_mode == 'both'
assert ItemCategory.objects.get(pk=category.pk).is_addon is False
@pytest.mark.django_db
def test_category_update_wrong_event(token_client, organizer, event2, category):
resp = token_client.patch(

View File

@@ -0,0 +1,897 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import datetime
import re
from decimal import Decimal
from typing import List, Tuple
import pytest
from django.utils.timezone import now
from django_scopes import scopes_disabled
from freezegun import freeze_time
from tests import assert_num_queries
from pretix.base.models import CartPosition, Discount, Event, Organizer
from pretix.base.services.cross_selling import CrossSellingService
@pytest.fixture
def event():
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now()
)
return event
@pytest.fixture
@freeze_time("2020-01-01 10:00:00+01:00")
def eventseries():
o = Organizer.objects.create(name='Dummy', slug='dummy')
start = now()
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=start, has_subevents=True
)
event.subevents.create(name='Date1', date_from=start + datetime.timedelta(hours=1), active=True)
event.subevents.create(name='Date2', date_from=start + datetime.timedelta(hours=2), active=True)
event.subevents.create(name='Date3', date_from=start + datetime.timedelta(hours=3), active=True)
return event
def pattern(regex, **kwargs):
return re.compile(regex), kwargs
cond_suffix = [
pattern(r" in the same subevent$", subevent_mode=Discount.SUBEVENT_MODE_SAME),
pattern(r" in distinct subevents$", subevent_mode=Discount.SUBEVENT_MODE_DISTINCT),
]
cond_patterns = [
pattern(r"^Buy at least (?P<condition_min_count>\d+) of (?P<condition_limit_products>.+)$",
condition_all_products=False),
pattern(r"^Buy at least (?P<condition_min_count>\d+) products$",
condition_all_products=True),
pattern(r"^Spend at least (?P<condition_min_value>\d+)\$$",
condition_all_products=True),
pattern(r"^For every (?P<condition_min_count>\d+) of (?P<condition_limit_products>.+)$",
condition_all_products=False),
pattern(r"^For every (?P<condition_min_count>\d+) products$",
condition_all_products=True),
]
benefit_patterns = [
pattern(r"^get (?P<benefit_discount_matching_percent>\d+)% discount on them\.$",
benefit_same_products=True),
pattern(r"^get (?P<benefit_discount_matching_percent>\d+)% discount on everything\.$",
benefit_same_products=True),
pattern(r"^get (?P<benefit_discount_matching_percent>\d+)% discount on "
r"(?P<benefit_only_apply_to_cheapest_n_matches>\d+) of them\.$",
benefit_same_products=True),
pattern(r"^get (?P<benefit_discount_matching_percent>\d+)% discount on "
r"(?P<benefit_only_apply_to_cheapest_n_matches>\d+) of (?P<benefit_limit_products>.+)\.$",
benefit_same_products=False),
pattern(r"^get (?P<benefit_discount_matching_percent>\d+)% discount on "
r"(?P<benefit_limit_products>.+)\.$",
benefit_same_products=False),
]
def make_discount(description, event: Event):
condition, benefit = description.split(', ')
d = Discount(event=event, internal_name=description)
d.save()
def apply(patterns: List[Tuple[re.Pattern, dict]], input):
for regex, options in patterns:
m = regex.search(input)
if m:
fields = m.groupdict()
for k, v in [*fields.items(), *options.items()]:
if '_limit_products' in k:
getattr(d, k).set([event.items.get(name=v)])
else:
setattr(d, k, v)
input = input[:m.start(0)] + input[m.endpos:]
if input != '':
raise Exception("Unable to parse '{}'".format(input))
apply(cond_suffix + cond_patterns, condition)
apply(benefit_patterns, benefit)
d.full_clean()
d.save()
return d
def validate_discount_rule(
d,
subevent_mode=Discount.SUBEVENT_MODE_MIXED,
condition_all_products=True,
condition_limit_products=[],
condition_apply_to_addons=True,
condition_ignore_voucher_discounted=False,
condition_min_count=0,
condition_min_value=Decimal('0.00'),
benefit_same_products=True,
benefit_limit_products=[],
benefit_discount_matching_percent=Decimal('0.00'),
benefit_only_apply_to_cheapest_n_matches=None,
benefit_apply_to_addons=True,
benefit_ignore_voucher_discounted=False):
assert d.subevent_mode == subevent_mode
assert d.condition_all_products == condition_all_products
assert [str(p.name) for p in d.condition_limit_products.all()] == condition_limit_products
assert d.condition_apply_to_addons == condition_apply_to_addons
assert d.condition_ignore_voucher_discounted == condition_ignore_voucher_discounted
assert d.condition_min_count == condition_min_count
assert d.condition_min_value == condition_min_value
assert d.benefit_same_products == benefit_same_products
assert [str(p.name) for p in d.benefit_limit_products.all()] == benefit_limit_products
assert d.benefit_discount_matching_percent == benefit_discount_matching_percent
assert d.benefit_only_apply_to_cheapest_n_matches == benefit_only_apply_to_cheapest_n_matches
assert d.benefit_apply_to_addons == benefit_apply_to_addons
assert d.benefit_ignore_voucher_discounted == benefit_ignore_voucher_discounted
return d
@scopes_disabled()
@pytest.mark.django_db
def test_rule_parser(event):
# mixed_min_count_matching_percent
validate_discount_rule(
make_discount("Buy at least 3 products, get 20% discount on everything.", event),
subevent_mode=Discount.SUBEVENT_MODE_MIXED,
condition_min_count=3,
benefit_discount_matching_percent=Decimal('20.00')
)
# mixed_min_count_one_free
validate_discount_rule(
make_discount("For every 3 products, get 100% discount on 1 of them.", event),
subevent_mode=Discount.SUBEVENT_MODE_MIXED,
condition_min_count=3,
benefit_discount_matching_percent=Decimal('100.00'),
benefit_only_apply_to_cheapest_n_matches=1,
)
# mixed_min_value_matching_percent
validate_discount_rule(
make_discount("Spend at least 500$, get 20% discount on everything.", event),
subevent_mode=Discount.SUBEVENT_MODE_MIXED,
condition_min_value=Decimal('500.00'),
benefit_discount_matching_percent=Decimal('20.00')
)
# same_min_count_matching_percent
validate_discount_rule(
make_discount("Buy at least 3 products in the same subevent, get 20% discount on everything.", event),
subevent_mode=Discount.SUBEVENT_MODE_SAME,
condition_min_count=3,
benefit_discount_matching_percent=Decimal('20.00')
)
# same_min_count_one_free
validate_discount_rule(
make_discount("For every 3 products in the same subevent, get 100% discount on 1 of them.", event),
subevent_mode=Discount.SUBEVENT_MODE_SAME,
condition_min_count=3,
benefit_discount_matching_percent=Decimal('100.00'),
benefit_only_apply_to_cheapest_n_matches=1,
)
# same_min_value_matching_percent
validate_discount_rule(
make_discount("Spend at least 500$ in the same subevent, get 20% discount on everything.", event),
subevent_mode=Discount.SUBEVENT_MODE_SAME,
condition_min_value=Decimal('500.00'),
benefit_discount_matching_percent=Decimal('20.00')
)
# distinct_min_count_matching_percent
validate_discount_rule(
make_discount("Buy at least 3 products in distinct subevents, get 20% discount on everything.", event),
subevent_mode=Discount.SUBEVENT_MODE_DISTINCT,
condition_min_count=3,
benefit_discount_matching_percent=Decimal('20.00')
)
# distinct_min_count_one_free
validate_discount_rule(
make_discount("For every 3 products in distinct subevents, get 100% discount on 1 of them.", event),
subevent_mode=Discount.SUBEVENT_MODE_DISTINCT,
condition_min_count=3,
benefit_discount_matching_percent=Decimal('100.00'),
benefit_only_apply_to_cheapest_n_matches=1,
)
# distinct_min_count_two_free
validate_discount_rule(
make_discount("For every 3 products in distinct subevents, get 100% discount on 2 of them.", event),
subevent_mode=Discount.SUBEVENT_MODE_DISTINCT,
condition_min_count=3,
benefit_discount_matching_percent=Decimal('100.00'),
benefit_only_apply_to_cheapest_n_matches=2,
)
def setup_items(event, category_name, category_type, cross_selling_condition, *items):
cat = event.categories.create(name=category_name)
cat.category_type = category_type
cat.cross_selling_condition = cross_selling_condition
cat.save()
for name, price in items:
item = cat.items.create(event=event, name=name, default_price=price)
for subevent in event.subevents.all() if event.has_subevents else [None]:
quota = event.quotas.create(subevent=subevent)
quota.items.add(item)
quota.save()
def split_table(txt):
return [
re.split(r"\s{3,}", line.strip())
for line in txt.split("\n")[1:]
if line.strip() != ""
]
def check_cart_behaviour(event, cart_contents, recommendations, expect_num_queries=None):
cart_contents = split_table(cart_contents)
subevent_map = {str(se.name): se.pk for se in event.subevents.all()}
positions = [
CartPosition(
item_id=event.items.get(name=item_name).pk,
subevent_id=subevent_map.get(subevent_name),
line_price_gross=Decimal(regular_price), addon_to=None, is_bundled=False,
listed_price=Decimal(regular_price), price_after_voucher=Decimal(regular_price)
) for (item_name, regular_price, expected_discounted_price, subevent_name) in cart_contents
]
expected_recommendations = split_table(recommendations)
event.organizer.cache.clear()
event.cache.clear()
event.refresh_from_db()
service = CrossSellingService(event, event.organizer.sales_channels.get(identifier='web'), positions, None)
if expect_num_queries:
with assert_num_queries(expect_num_queries):
result = service.get_data()
else:
result = service.get_data()
result_recommendations = [
[str(category.name) + (f' ({category.subevent_name})' if hasattr(category, 'subevent_name') else ''),
str(item.name),
str(item.original_price.gross.quantize(Decimal('0.00'))) if item.original_price else '-',
str(item.display_price.gross.quantize(Decimal('0.00'))),
str(item.order_max),
form_prefix or '-']
for category, items, form_prefix in result
for item in items
]
assert result_recommendations == expected_recommendations
assert [str(price) for price, discount in service._discounted_prices] == [
expected_discounted_price for (item_name, regular_price, expected_discounted_price, form_prefix) in cart_contents]
@scopes_disabled()
@pytest.mark.django_db
def test_2f1r_discount_cross_selling(event):
setup_items(event, 'Tickets', 'both', 'discounts',
('Regular Ticket', '42.00'),
('Reduced Ticket', '23.00'),
)
make_discount('For every 2 of Regular Ticket, get 50% discount on 1 of Reduced Ticket.', event)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Reduced Ticket 23.00 11.50 1 -
'''
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Reduced Ticket 23.00 11.50 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Reduced Ticket 23.00 11.50 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Reduced Ticket 23.00 11.50 2 -
'''
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Reduced Ticket 23.00 11.50 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Reduced Ticket 23.00 11.50 1 -
'''
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Reduced Ticket 23.00 11.50 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Reduced Ticket 23.00 11.50 1 -
'''
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Reduced Ticket 23.00 11.50 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Reduced Ticket 23.00 11.50 2 -
'''
)
@scopes_disabled()
@pytest.mark.django_db
@freeze_time("2020-01-01 10:00:00+01:00")
def test_2f1r_discount_cross_selling_eventseries_mixed(eventseries):
setup_items(eventseries, 'Tickets', 'both', 'discounts',
('Regular Ticket', '42.00'),
('Reduced Ticket', '23.00'),
)
make_discount('For every 2 of Regular Ticket, get 50% discount on 1 of Reduced Ticket.', eventseries)
prefix_date1 = f"subevent_{eventseries.subevents.get(name='Date1').pk}_"
prefix_date2 = f"subevent_{eventseries.subevents.get(name='Date2').pk}_"
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
''',
recommendations=f''' Price Discounted Price Max Count Prefix
Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1}
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date2
''',
recommendations=f''' Price Discounted Price Max Count Prefix
Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1}
Tickets (Date2 - Wed, Jan. 1st, 2020 11:00) Reduced Ticket 23.00 11.50 1 {prefix_date2}
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date2
Reduced Ticket 23.00 11.50 Date1
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date2
Regular Ticket 42.00 42.00 Date1
Reduced Ticket 23.00 11.50 Date1
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date2
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date2
''',
recommendations=f''' Price Discounted Price Max Count Prefix
Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 2 {prefix_date1}
Tickets (Date2 - Wed, Jan. 1st, 2020 11:00) Reduced Ticket 23.00 11.50 2 {prefix_date2}
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Reduced Ticket 23.00 11.50 Date1
''',
recommendations=f''' Price Discounted Price Max Count Prefix
Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1}
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Reduced Ticket 23.00 11.50 Date1
''',
recommendations=f''' Price Discounted Price Max Count Prefix
Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1}
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Reduced Ticket 23.00 11.50 Date1
''',
recommendations=f''' Price Discounted Price Max Count Prefix
Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 2 {prefix_date1}
'''
)
@scopes_disabled()
@pytest.mark.django_db
def test_2f1r_discount_cross_selling_eventseries_same(eventseries):
setup_items(eventseries, 'Tickets', 'both', 'discounts',
('Regular Ticket', '42.00'),
('Reduced Ticket', '23.00'),
)
make_discount('For every 2 of Regular Ticket in the same subevent, get 50% discount on 1 of Reduced Ticket.', eventseries)
prefix_date1 = f"subevent_{eventseries.subevents.get(name='Date1').pk}_"
prefix_date2 = f"subevent_{eventseries.subevents.get(name='Date2').pk}_"
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date2
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date2
''',
recommendations=f''' Price Discounted Price Max Count Prefix
Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1}
Tickets (Date2 - Wed, Jan. 1st, 2020 11:00) Reduced Ticket 23.00 11.50 1 {prefix_date2}
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date2
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date2
Regular Ticket 42.00 42.00 Date2
Regular Ticket 42.00 42.00 Date2
''',
recommendations=f''' Price Discounted Price Max Count Prefix
Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1}
Tickets (Date2 - Wed, Jan. 1st, 2020 11:00) Reduced Ticket 23.00 11.50 2 {prefix_date2}
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Reduced Ticket 23.00 11.50 Date1
''',
recommendations=f''' Price Discounted Price Max Count Prefix
Tickets (Date1 - Wed, Jan. 1st, 2020 10:00) Reduced Ticket 23.00 11.50 1 {prefix_date1}
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date2
Reduced Ticket 23.00 11.50 Date1
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date2
Regular Ticket 42.00 42.00 Date2
Reduced Ticket 23.00 11.50 Date1
''',
recommendations=f''' Price Discounted Price Max Count Prefix
Tickets (Date2 - Wed, Jan. 1st, 2020 11:00) Reduced Ticket 23.00 11.50 1 {prefix_date2}
'''
)
@scopes_disabled()
@pytest.mark.django_db
def test_50percentoff_discount_cross_selling_eventseries_distinct(eventseries):
setup_items(eventseries, 'Tickets', 'both', 'discounts',
('Regular Ticket', '42.00'),
('Reduced Ticket', '23.00'),
)
make_discount('For every 2 of Regular Ticket in distinct subevents, get 50% discount on them.', eventseries)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
Regular Ticket 42.00 42.00 Date1
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 21.00 Date1
Regular Ticket 42.00 21.00 Date2
Regular Ticket 42.00 21.00 Date3
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
check_cart_behaviour(
eventseries,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 21.00 Date1
Regular Ticket 42.00 21.00 Date2
Regular Ticket 42.00 21.00 Date3
Reduced Ticket 23.00 23.00 Date1
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
@scopes_disabled()
@pytest.mark.django_db
@pytest.mark.skip("currently unsupported (cannot give discount to specific product on minimum cart value)")
def test_free_drinks(event):
setup_items(event, 'Tickets', 'normal', None,
('Regular Ticket', '42.00'),
('Reduced Ticket', '23.00'),
)
setup_items(event, 'Free Drinks', 'only', 'discounts',
('Free Drinks', '50.00'),
)
make_discount('Spend at least 100$, get 100% discount on 1 of Free Drinks.', event)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Free Drinks Free Drinks 50.00 0.00 1 -
'''
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Free Drinks 50.00 0.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
@scopes_disabled()
@pytest.mark.django_db
def test_five_tickets_one_free(event):
setup_items(event, 'Tickets', 'both', 'discounts',
('Regular Ticket', '42.00'),
)
make_discount('For every 5 of Regular Ticket, get 100% discount on 1 of them.', event)
# we don't expect a recommendation here, as in the current implementation we only recommend based on discounts
# where the condition is already completely satisfied but no (or not enough) benefitting products are in the
# cart yet
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 0.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 42.00 0
Regular Ticket 42.00 0.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
'''
)
@scopes_disabled()
@pytest.mark.django_db
@pytest.mark.parametrize("itemcount", [3, 10, 50])
def test_query_count_many_items(event, itemcount):
setup_items(event, 'Tickets', 'both', 'discounts',
*[(f'Ticket {n}', '42.00') for n in range(itemcount)]
)
make_discount('For every 5 of Ticket 1, get 100% discount on 1 of Ticket 2.', event)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
''',
expect_num_queries=8,
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Ticket 2 42.00 0.00 1 -
''',
expect_num_queries=9,
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
Ticket 1 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Ticket 2 42.00 0.00 1 -
''',
expect_num_queries=9,
)
@scopes_disabled()
@pytest.mark.django_db
@pytest.mark.parametrize("catcount", [1, 10, 50])
def test_query_count_many_categories_and_discounts(event, catcount):
for n in range(1, catcount + 1):
setup_items(event, f'Category {n}', 'both', 'discounts',
(f'Ticket {n}-A', '42.00'),
(f'Ticket {n}-B', '42.00'),
)
make_discount(f'For every 5 of Ticket {n}-A, get 100% discount on 1 of Ticket {n}-B.', event)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
''',
expect_num_queries=8,
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Category 1 Ticket 1-B 42.00 0.00 1 -
''',
expect_num_queries=9,
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Category 1 Ticket 1-B 42.00 0.00 1 -
''',
expect_num_queries=9,
)
@scopes_disabled()
@pytest.mark.django_db
@pytest.mark.parametrize("catcount", [2, 10, 50])
def test_query_count_many_cartpos(event, catcount):
for n in range(1, catcount + 1):
setup_items(event, f'Category {n}', 'both', 'discounts',
(f'Ticket {n}-A', '42.00'),
(f'Ticket {n}-B', '42.00'),
)
make_discount(f'For every 5 of Ticket {n}-A, get 100% discount on 1 of Ticket {n}-B.', event)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
''',
expect_num_queries=8,
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Category 1 Ticket 1-B 42.00 0.00 1 -
''',
expect_num_queries=9,
)
check_cart_behaviour(
event,
cart_contents=''' Price Discounted Subev
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 1-A 42.00 42.00 0
Ticket 2-A 42.00 42.00 0
Ticket 2-A 42.00 42.00 0
Ticket 2-A 42.00 42.00 0
Ticket 2-A 42.00 42.00 0
Ticket 2-A 42.00 42.00 0
''',
recommendations=''' Price Discounted Price Max Count Prefix
Category 1 Ticket 1-B 42.00 0.00 1 -
Category 2 Ticket 2-B 42.00 0.00 1 -
''',
expect_num_queries=13,
)

View File

@@ -65,6 +65,8 @@ def test_full_clone_same_organizer():
item_meta = event.item_meta_properties.create(name="Bla")
tax_rule = event.tax_rules.create(name="VAT", rate=19)
category = event.categories.create(name="Tickets")
cross_sell_category = event.categories.create(name="Recommendations", cross_selling_mode="only",
cross_selling_condition="products")
q1 = event.quotas.create(name="Quota 1", size=5)
q2 = event.quotas.create(name="Quota 2", size=0, closed=True)
@@ -88,6 +90,7 @@ def test_full_clone_same_organizer():
q1.items.add(item1)
q2.items.add(item2)
q2.variations.add(item2v)
cross_sell_category.cross_selling_match_products.add(item1)
event.discounts.create(internal_name="Fake discount")
question1 = event.questions.create(question="Yes or no", type=Question.TYPE_BOOLEAN)
@@ -156,7 +159,7 @@ def test_full_clone_same_organizer():
copied_item1 = copied_event.items.get(name=item1.name)
copied_item2 = copied_event.items.get(name=item2.name)
assert copied_item1.tax_rule == copied_event.tax_rules.get()
assert copied_item1.category == copied_event.categories.get()
assert copied_item1.category == copied_event.categories.get(name='Tickets')
assert copied_item1.limit_sales_channels.get() == sc
assert copied_item1.meta_data == item1.meta_data
assert copied_item2.variations.get().meta_data == item2v.meta_data
@@ -166,7 +169,7 @@ def test_full_clone_same_organizer():
assert copied_item2.variations.get().limit_sales_channels.get() == sc
assert copied_item2.require_membership_types.count() == 1
assert copied_item2.require_membership_types.get() == membership_type
assert copied_item1.addons.get().addon_category == copied_event.categories.get()
assert copied_item1.addons.get().addon_category == copied_event.categories.get(name='Tickets')
assert copied_item1.bundles.get().bundled_item == copied_item2
assert copied_item1.bundles.get().bundled_variation == copied_item2.variations.get()
assert copied_item2.hidden_if_item_available == copied_item1
@@ -174,6 +177,9 @@ def test_full_clone_same_organizer():
assert copied_q2.items.get() == copied_item2
assert copied_q2.variations.get() == copied_item2.variations.get()
copied_cross_sell_category = copied_event.categories.get(name=cross_sell_category.name)
assert copied_cross_sell_category.cross_selling_match_products.get() == copied_item1
copied_question1 = copied_event.questions.get(type=question1.type)
copied_question2 = copied_event.questions.get(type=question2.type)
assert copied_question2.dependency_question == copied_question1

View File

@@ -782,59 +782,3 @@ def test_custom_rules_country_rate_subtract_from_gross(event):
rate=Decimal('100.00'),
name='',
)
@pytest.mark.django_db
def test_no_negative_due_to_subtract_from_gross(event):
tr = TaxRule(
event=event,
rate=Decimal("19.00"),
price_includes_tax=True,
)
assert tr.tax(Decimal('100.00'), subtract_from_gross=Decimal('200.00')).gross == Decimal("0.00")
tr = TaxRule(
event=event,
rate=Decimal("0.00"),
price_includes_tax=True,
)
assert tr.tax(Decimal('100.00'), subtract_from_gross=Decimal('200.00')).gross == Decimal("0.00")
tr = TaxRule(
event=event,
rate=Decimal("19.00"),
price_includes_tax=False,
)
assert tr.tax(Decimal('100.00'), subtract_from_gross=Decimal('200.00')).gross == Decimal("0.00")
tr = TaxRule(
event=event,
rate=Decimal("19.00"),
price_includes_tax=True,
)
assert tr.tax(Decimal('100.00'), subtract_from_gross=Decimal('200.00')).gross == Decimal("0.00")
@pytest.mark.django_db
def test_allow_negative(event):
tr = TaxRule(
event=event,
rate=Decimal("19.00"),
price_includes_tax=True,
)
assert tr.tax(Decimal('-100.00')).gross == Decimal("-100.00")
tr = TaxRule(
event=event,
rate=Decimal("0.00"),
price_includes_tax=True,
)
assert tr.tax(Decimal('-100.00')).gross == Decimal("-100.00")
tr = TaxRule(
event=event,
rate=Decimal("19.00"),
price_includes_tax=False,
)
assert tr.tax(Decimal('-100.00')).gross == Decimal("-119.00")
tr = TaxRule(
event=event,
rate=Decimal("19.00"),
price_includes_tax=True,
)
assert tr.tax(Decimal('-100.00')).gross == Decimal("-100.00")

View File

@@ -41,7 +41,7 @@ from django.contrib.auth.tokens import (
PasswordResetTokenGenerator, default_token_generator,
)
from django.core import mail as djmail
from django.test import RequestFactory, TestCase, override_settings
from django.test import TestCase, override_settings
from django.utils.timezone import now
from django_otp.oath import TOTP
from django_otp.plugins.otp_totp.models import TOTPDevice
@@ -50,7 +50,6 @@ from webauthn.authentication.verify_authentication_response import (
)
from pretix.base.models import Organizer, Team, U2FDevice, User
from pretix.control.views.auth import process_login
from pretix.helpers import security
@@ -893,19 +892,6 @@ class SessionTimeOutTest(TestCase):
response = self.client.get('/control/')
self.assertEqual(response.status_code, 302)
def test_plugin_auth_updates_auth_last_used(self):
session = self.client.session
session['pretix_auth_long_session'] = True
session['pretix_auth_login_time'] = int(time.time()) - 3600 * 5
session['pretix_auth_last_used'] = int(time.time()) - 3600 * 3 - 60
session.save()
request = RequestFactory().get("/")
request.session = self.client.session
process_login(request, self.user, keep_logged_in=True)
assert request.session['pretix_auth_last_used'] >= int(time.time()) - 60
def test_update_session_activity(self):
t1 = int(time.time()) - 5
session = self.client.session

View File

@@ -21,8 +21,6 @@
#
import copy
import json
from datetime import timedelta
from decimal import Decimal
import pytest
from django.core.files.base import ContentFile
@@ -30,9 +28,7 @@ from django.utils.timezone import now
from django_scopes import scopes_disabled
from rest_framework.test import APIClient
from pretix.base.models import (
Event, Item, Order, OrderPosition, Organizer, Team,
)
from pretix.base.models import Event, Item, Organizer, Team
from pretix.plugins.ticketoutputpdf.models import TicketLayoutItem
@@ -43,35 +39,14 @@ def env():
organizer=o, name='Dummy', slug='dummy',
date_from=now(), plugins='pretix.plugins.banktransfer'
)
t = Team.objects.create(organizer=event.organizer, can_view_orders=True)
t = Team.objects.create(organizer=event.organizer)
t.limit_events.add(event)
item1 = Item.objects.create(event=event, name="Ticket", default_price=23)
tl = event.ticket_layouts.create(
name="Foo",
default=True,
layout='[{"type": "poweredby", "left": "0", "bottom": "0", "size": "1.00", "content": "dark"}]',
)
tl = event.ticket_layouts.create(name="Foo", default=True, layout='[{"a": 2}]')
TicketLayoutItem.objects.create(layout=tl, item=item1, sales_channel=o.sales_channels.get(identifier="web"))
return event, tl, item1
@pytest.fixture
def position(env):
item = env[0].items.create(name="Ticket", default_price=3, admission=True)
order = Order.objects.create(
code='FOO', event=env[0], email='dummy@dummy.test',
status=Order.STATUS_PAID, locale='en',
datetime=now() - timedelta(days=4),
expires=now() - timedelta(hours=4) + timedelta(days=10),
total=Decimal('23.00'),
sales_channel=env[0].organizer.sales_channels.get(identifier="web"),
)
return OrderPosition.objects.create(
order=order, item=item, variation=None,
price=Decimal("23.00"), attendee_name_parts={"full_name": "Peter"}, positionid=1
)
@pytest.fixture
def client():
return APIClient()
@@ -90,7 +65,7 @@ RES_LAYOUT = {
'name': 'Foo',
'default': True,
'item_assignments': [{'item': 1, 'sales_channel': 'web'}],
'layout': [{"type": "poweredby", "left": "0", "bottom": "0", "size": "1.00", "content": "dark"}],
'layout': [{'a': 2}],
'background': 'http://example.com/static/pretixpresale/pdf/ticket_default_a4.pdf'
}
@@ -238,92 +213,3 @@ def test_api_delete(env, token_client):
)
assert resp.status_code == 204
assert not env[0].ticket_layouts.exists()
@pytest.mark.django_db
def test_renderer_batch_valid(env, token_client, position):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/ticketpdfrenderer/render_batch/'.format(env[0].slug, env[0].slug),
{
"parts": [
{
"orderposition": position.pk,
},
{
"orderposition": position.pk,
"override_channel": "web",
},
{
"orderposition": position.pk,
"override_layout": env[1].pk,
},
]
},
format='json',
)
assert resp.status_code == 202
assert "download" in resp.data
resp = token_client.get("/" + resp.data["download"].split("/", 3)[3])
assert resp.status_code == 200
assert resp["Content-Type"] == "application/pdf"
@pytest.mark.django_db
def test_renderer_batch_invalid(env, token_client, position):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/ticketpdfrenderer/render_batch/'.format(env[0].slug, env[0].slug),
{
"parts": [
{
"orderposition": -2,
},
]
},
format='json',
)
assert resp.status_code == 400
assert resp.data == {"parts": [{"orderposition": ["Invalid pk \"-2\" - object does not exist."]}]}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/ticketpdfrenderer/render_batch/'.format(env[0].slug, env[0].slug),
{
"parts": [
{
"orderposition": position.pk,
"override_layout": -2,
},
]
},
format='json',
)
assert resp.status_code == 400
assert resp.data == {"parts": [{"override_layout": ["Invalid pk \"-2\" - object does not exist."]}]}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/ticketpdfrenderer/render_batch/'.format(env[0].slug, env[0].slug),
{
"parts": [
{
"orderposition": position.pk,
"override_channel": "magic",
},
]
},
format='json',
)
assert resp.status_code == 400
assert resp.data == {"parts": [{"override_channel": ["Object with identifier=magic does not exist."]}]}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/ticketpdfrenderer/render_batch/'.format(env[0].slug, env[0].slug),
{
"parts": [
{
"orderposition": position.pk,
}
] * 1002
},
format='json',
)
assert resp.status_code == 400
assert resp.data == {"parts": ["Please do not submit more than 1000 parts."]}

View File

@@ -3654,31 +3654,6 @@ class CartBundleTest(CartTestMixin, TestCase):
assert cp.price == Decimal('0.00')
assert b.price == Decimal('1.50')
@classscope(attr='orga')
def test_voucher_apply_multiple_reduce_beyond_designated_price_no_tax_rules(self):
self.ticket.tax_rule = None
self.ticket.save()
self.trans.tax_rule = None
self.trans.save()
cp = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=21.5, expires=now() + timedelta(minutes=10)
)
b = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.trans, addon_to=cp,
price=1.5, expires=now() + timedelta(minutes=10), is_bundled=True
)
v = Voucher.objects.create(
event=self.event, price_mode='set', value=Decimal('0.00'), max_usages=100
)
self.cm.apply_voucher(v.code)
self.cm.commit()
cp.refresh_from_db()
b.refresh_from_db()
assert cp.price == Decimal('0.00')
assert b.price == Decimal('1.50')
@classscope(attr='orga')
def test_voucher_apply_affect_bundled(self):
cp = CartPosition.objects.create(

View File

@@ -628,7 +628,7 @@ def test_change_email(env, client):
@pytest.mark.django_db
def test_change_pw(env, client, client2):
def test_change_pw(env, client):
with scopes_disabled():
customer = env[0].customers.create(email='john@example.org', is_verified=True)
customer.set_password('foo')
@@ -640,12 +640,6 @@ def test_change_pw(env, client, client2):
})
assert r.status_code == 302
r = client2.post('/bigevents/account/login', {
'email': 'john@example.org',
'password': 'foo',
})
assert r.status_code == 302
r = client.post('/bigevents/account/password', {
'password_current': 'invalid',
'password': 'aYLBRNg4',
@@ -664,13 +658,6 @@ def test_change_pw(env, client, client2):
customer.refresh_from_db()
assert customer.check_password('aYLBRNg4')
r = client.get('/bigevents/account/password')
assert r.status_code == 200
# Client 2 got logged out
r = client2.post('/bigevents/account/password')
assert r.status_code == 302
@pytest.mark.django_db
def test_login_per_org(env, client):

View File

@@ -221,7 +221,7 @@ class OrdersTest(BaseOrdersTest):
assert not self.order.email_known_to_work
response = self.client.get(
'/%s/%s/order/%s/%s/open/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret, self.order.email_confirm_secret())
'/%s/%s/order/%s/%s/open/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret, self.order.email_confirm_hash())
)
assert response.status_code == 302
self.order.refresh_from_db()