mirror of
https://github.com/pretix/pretix.git
synced 2026-01-01 18:32:27 +00:00
Compare commits
85 Commits
pdf-layout
...
cross-sell
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2848d85511 | ||
|
|
21707f8407 | ||
|
|
711479bfed | ||
|
|
c401e54831 | ||
|
|
27cfd4dbdd | ||
|
|
e5ab1b08a2 | ||
|
|
7d22fe1a54 | ||
|
|
6c52cc8157 | ||
|
|
0191d258ab | ||
|
|
b312a21e5e | ||
|
|
f9ca9a781e | ||
|
|
a314d219b8 | ||
|
|
9a6756ce5d | ||
|
|
b3ca02d8e5 | ||
|
|
88936b5e7a | ||
|
|
6b9eefd231 | ||
|
|
6587cca608 | ||
|
|
3856095088 | ||
|
|
a84a27cc0b | ||
|
|
7d6b2d6df8 | ||
|
|
d4f997c345 | ||
|
|
bef88bf0d0 | ||
|
|
159717c19f | ||
|
|
5e9b5a9c24 | ||
|
|
52849f8fdd | ||
|
|
4f1ee82c4f | ||
|
|
d5e480b7fd | ||
|
|
fd6ae65f23 | ||
|
|
94733135f0 | ||
|
|
e51927c4e0 | ||
|
|
11c7c950cb | ||
|
|
0695365526 | ||
|
|
0947476b41 | ||
|
|
7a4aead22d | ||
|
|
44de4bb26b | ||
|
|
d879637b73 | ||
|
|
b5fc227fca | ||
|
|
1fb1696863 | ||
|
|
939d50061b | ||
|
|
c1a5e8d912 | ||
|
|
106026045e | ||
|
|
badbb64f4f | ||
|
|
537a0993b0 | ||
|
|
9337ad1f70 | ||
|
|
5087e654e2 | ||
|
|
dac2209243 | ||
|
|
9cb708cf6f | ||
|
|
e18c699529 | ||
|
|
9c3150ccde | ||
|
|
923798ea5f | ||
|
|
b8d2372cf6 | ||
|
|
e01e9151c3 | ||
|
|
09398ad7c7 | ||
|
|
d1de8f5863 | ||
|
|
bee0eaa2fa | ||
|
|
ac771b8ca8 | ||
|
|
cb635b2c37 | ||
|
|
3fe6919bef | ||
|
|
8cfb69c265 | ||
|
|
77fc13605e | ||
|
|
a95976ed50 | ||
|
|
2e3a611498 | ||
|
|
6bf16f1510 | ||
|
|
d29b183801 | ||
|
|
188ef5f463 | ||
|
|
a7e292ea58 | ||
|
|
d04b855cce | ||
|
|
01b535a0af | ||
|
|
d9f31aae8c | ||
|
|
715347cb35 | ||
|
|
32cc45f19a | ||
|
|
cadf8dd39d | ||
|
|
b136ac37c8 | ||
|
|
8627eefebc | ||
|
|
e71d3e21ca | ||
|
|
a18adb8a88 | ||
|
|
f56d67ec9c | ||
|
|
c156581ad1 | ||
|
|
8791280d0b | ||
|
|
97925e2d77 | ||
|
|
a0d865cf4f | ||
|
|
2cd5d87da4 | ||
|
|
e5c7c85e75 | ||
|
|
3e0992a7a7 | ||
|
|
f19e5bef72 |
@@ -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``.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
""""""""""""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
232
src/pretix/base/services/cross_selling.py
Normal file
232
src/pretix/base/services/cross_selling.py
Normal 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
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = '{} <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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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 they’re 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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
54
src/pretix/static/npm_dir/package-lock.json
generated
54
src/pretix/static/npm_dir/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
897
src/tests/base/test_cross_selling.py
Normal file
897
src/tests/base/test_cross_selling.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."]}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user