Compare commits

..

7 Commits

Author SHA1 Message Date
Raphael Michel
ab4ecd0b6b Payment and confirm 2025-01-05 13:51:58 +01:00
Raphael Michel
8f13c03245 Add fields 2025-01-04 17:18:37 +01:00
Raphael Michel
3b664f8b76 .. 2025-01-04 17:18:37 +01:00
Raphael Michel
92eb5e3ece cart add 2025-01-04 17:18:37 +01:00
Raphael Michel
ad38a7a407 Settings 2025-01-04 17:18:37 +01:00
Raphael Michel
51bdb274bd add missing packages 2025-01-04 17:18:37 +01:00
Raphael Michel
8cba60dd93 Initial steps 2025-01-04 17:18:37 +01:00
62 changed files with 3229 additions and 1204 deletions

View File

@@ -54,6 +54,23 @@
</p>
</div>
</div>
<div class="sectionbox">
<div class="icon">
<a href="storefrontapi/index.html">
<span class="fa fa-shopping-cart fa-fw"></span>
</a>
</div>
<div class="text">
<a href="storefrontapi/index.html">
<strong>Storefront API</strong>
</a>
<p>
Documentation and reference of the headless shopping API exposed by pretix for building a custom
storefront.
</p>
</div>
</div>
<div class="clearfix"></div>
<div class="sectionbox">
<div class="icon">
<a href="development/index.html">
@@ -68,7 +85,6 @@
pretix.</p>
</div>
</div>
<div class="clearfix"></div>
<div class="sectionbox">
<div class="icon">
<a href="plugins/index.html">
@@ -82,19 +98,6 @@
<p>Documentation and details on plugins that ship with pretix or are officially supported.</p>
</div>
</div>
<div class="sectionbox">
<div class="icon">
<a href="contents.html">
<span class="fa fa-list fa-fw"></span>
</a>
</div>
<div class="text">
<a href="contents.html">
<strong>Table of contents</strong>
</a>
<p>Detailled overview of everything contained in this documentation.</p>
</div>
</div>
<div class="clearfix"></div>
<h2>Useful links</h2>

View File

@@ -156,6 +156,8 @@ Field specific input errors include the name of the offending fields as keys in
If you see errors of type ``429 Too Many Requests``, you should read our documentation on :ref:`rest-ratelimit`.
.. _`rest-types`:
Data types
----------

View File

@@ -7,6 +7,7 @@ Table of contents
user/index
admin/index
api/index
storefrontapi/index
development/index
plugins/index
license/faq

View File

@@ -0,0 +1,114 @@
Basic concepts
==============
This page describes basic concepts and definition that you need to know to interact
with our Storefront API, such as authentication, pagination and similar definitions.
.. _`storefront-auth`:
Authentication
--------------
The storefront API requires authentication with an API key. You receive two kinds of API keys for the storefront API:
Publishable keys and private keys. Publishable keys should be used when your website directly connects to the API.
Private keys should be used only on server-to-server connections.
Localization
------------
The storefront API will return localized and translated strings in many cases if you set an ``Accept-Language`` header.
The selected locale will only be respected if it is active for the organizer or event in question.
.. _`storefront-compat`:
Compatibility
-------------
.. note::
The storefront API is currently considered experimental and may change without notice.
Once we declare the API stable, the following compatibility policy will apply.
We try to avoid any breaking changes to our API to avoid hassle on your end. If possible, we'll
build new features in a way that keeps all pre-existing API usage unchanged. In some cases,
this might not be possible or only possible with restrictions. In these case, any
backwards-incompatible changes will be prominently noted in the "Changes to the REST API"
section of our release notes. If possible, we will announce them multiple releases in advance.
We treat the following types of changes as *backwards-compatible* so we ask you to make sure
that your clients can deal with them properly:
* Support of new API endpoints
* Support of new HTTP methods for a given API endpoint
* Support of new query parameters for a given API endpoint
* New fields contained in API responses
* New possible values of enumeration-like fields
* Response body structure or message texts on failed requests (``4xx``, ``5xx`` response codes)
We treat the following types of changes as *backwards-incompatible*:
* Type changes of fields in API responses
* New required input fields for an API endpoint
* New required type for input fields of an API endpoint
* Removal of endpoints, API methods or fields
Pagination
----------
Most lists of objects returned by pretix' API will be paginated. The response will take
the form of:
.. sourcecode:: javascript
{
"count": 117,
"next": "https://pretix.eu/api/v1/organizers/?page=2",
"previous": null,
"results": [],
}
As you can see, the response contains the total number of results in the field ``count``.
The fields ``next`` and ``previous`` contain links to the next and previous page of results,
respectively, or ``null`` if there is no such page. You can use those URLs to retrieve the
respective page.
The field ``results`` contains a list of objects representing the first results. For most
objects, every page contains 50 results. You can specify a lower pagination size using the
``page_size`` query parameter, but no more than 50.
Errors
------
Error responses (of type 400-499) are returned in one of the following forms, depending on
the type of error. General errors look like:
.. sourcecode:: http
HTTP/1.1 405 Method Not Allowed
Content-Type: application/json
Content-Length: 42
{"detail": "Method 'DELETE' not allowed."}
Field specific input errors include the name of the offending fields as keys in the response:
.. sourcecode:: http
HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: 94
{"amount": ["A valid integer is required."], "description": ["This field may not be blank."]}
If you see errors of type ``429 Too Many Requests``, you should read our documentation on :ref:`rest-ratelimit`.
Time Machine
------------
Just like our shop frontend, the API allows simulating responses at a different point in time using the
``X-Storefront-Time-Machine-Date`` header. This mechanism only works when the shop is in test mode.
Data types
----------
See :ref:`data types <rest-types>` of the REST API.

View File

@@ -0,0 +1,17 @@
.. _`storefront-api`:
Storefront API
==============
This part of the documentation contains information about the headless e-commerce
API exposed by pretix that can be used to build a custom checkout experience.
.. note::
The storefront API is currently considered experimental and may change without notice.
.. toctree::
:maxdepth: 2
fundamentals
reference/index

View File

@@ -0,0 +1,7 @@
API Reference
=============
.. toctree::
:maxdepth: 2
foo

View File

@@ -72,7 +72,7 @@ dependencies = [
"packaging",
"paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*",
"PyJWT==2.10.*",
"PyJWT==2.9.*",
"phonenumberslite==8.13.*",
"Pillow==11.1.*",
"pretix-plugin-build",

View File

@@ -24,7 +24,6 @@ from pathlib import Path
import setuptools
sys.path.append(str(Path.cwd() / 'src'))

View File

@@ -44,6 +44,7 @@ INSTALLED_APPS = [
'pretix.presale',
'pretix.multidomain',
'pretix.api',
'pretix.storefrontapi',
'pretix.helpers',
'rest_framework',
'djangoformsetjs',
@@ -94,7 +95,6 @@ ALL_LANGUAGES = [
('el', _('Greek')),
('id', _('Indonesian')),
('it', _('Italian')),
('ja', _('Japanese')),
('lv', _('Latvian')),
('nb-no', _('Norwegian Bokmål')),
('pl', _('Polish')),

View File

@@ -619,7 +619,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
def availability(self, request, *args, **kwargs):
quota = self.get_object()
qa = QuotaAvailability(full_results=True)
qa = QuotaAvailability()
qa.queue(quota)
qa.compute()
avail = qa.results[quota]

View File

@@ -1,23 +0,0 @@
# Generated by Django 4.2.17 on 2025-01-13 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0274_tax_codes"),
]
operations = [
migrations.AlterField(
model_name="question",
name="valid_number_max",
field=models.DecimalField(decimal_places=6, max_digits=30, null=True),
),
migrations.AlterField(
model_name="question",
name="valid_number_min",
field=models.DecimalField(decimal_places=6, max_digits=30, null=True),
),
]

View File

@@ -0,0 +1,62 @@
# Generated by Django 4.2.17 on 2025-01-01 20:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0274_tax_codes"),
]
operations = [
migrations.CreateModel(
name="CheckoutSession",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("cart_id", models.CharField(max_length=255, unique=True)),
("created", models.DateTimeField(auto_now_add=True)),
("testmode", models.BooleanField(default=False)),
("session_data", models.JSONField(default=dict)),
(
"customer",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="checkout_sessions",
to="pretixbase.customer",
),
),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.event",
),
),
(
"sales_channel",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.saleschannel",
),
),
],
),
migrations.AddField(
model_name="invoiceaddress",
name="checkout_session",
field=models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="invoice_address",
to="pretixbase.checkoutsession",
),
),
]

View File

@@ -1718,10 +1718,10 @@ class Question(LoggedModel):
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
)
dependency_values = MultiStringField(default=[])
valid_number_min = models.DecimalField(decimal_places=6, max_digits=30, null=True, blank=True,
valid_number_min = models.DecimalField(decimal_places=6, max_digits=16, null=True, blank=True,
verbose_name=_('Minimum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_number_max = models.DecimalField(decimal_places=6, max_digits=30, null=True, blank=True,
valid_number_max = models.DecimalField(decimal_places=6, max_digits=16, null=True, blank=True,
verbose_name=_('Maximum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_date_min = models.DateField(null=True, blank=True,

View File

@@ -55,7 +55,7 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db.models import (
Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
Case, Exists, F, Max, OuterRef, Prefetch, Q, Subquery, Sum, Value, When,
)
from django.db.models.functions import Coalesce, Greatest
from django.db.models.signals import post_delete
@@ -1087,7 +1087,7 @@ class Order(LockModel, LoggedModel):
for i, op in enumerate(positions):
if op.seat:
if not op.seat.is_available(ignore_orderpos=op, sales_channel=self.sales_channel.identifier):
if not op.seat.is_available(ignore_orderpos=op):
raise Quota.QuotaExceededException(error_messages['seat_unavailable'].format(seat=op.seat))
if force:
continue
@@ -3063,6 +3063,64 @@ class Transaction(models.Model):
return self.tax_value * self.count
class CheckoutSession(models.Model):
"""
A checkout session optionally bundles cart positions with additional information. This is historically
not required in pretix and currently only used in the Storefront API.
"""
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
related_name="checkout_sessions",
on_delete=models.CASCADE,
)
cart_id = models.CharField(
max_length=255, unique=True,
verbose_name=_("Cart ID (e.g. session key)"),
)
created = models.DateTimeField(
verbose_name=_("Date"),
auto_now_add=True,
)
customer = models.ForeignKey(
Customer,
related_name='checkout_sessions',
null=True, blank=True,
on_delete=models.SET_NULL,
)
sales_channel = models.ForeignKey(
"SalesChannel",
on_delete=models.CASCADE,
)
testmode = models.BooleanField(default=False)
session_data = models.JSONField(default=dict)
def get_cart_positions(self, prefetch_questions=False):
qs = CartPosition.objects.filter(event=self.event, cart_id=self.cart_id).select_related(
"item", "variation", "subevent",
)
if prefetch_questions:
qqs = self.event.questions.filter(ask_during_checkin=False, hidden=False)
qs = qs.prefetch_related(
Prefetch("answers",
QuestionAnswer.objects.prefetch_related("options"),
to_attr="answerlist"),
Prefetch("item__questions",
qqs.prefetch_related(
Prefetch("options", QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
"question",
Question.objects.none(),
to_attr="dummy"
)))
).select_related("dependency_question"),
to_attr="questions_to_ask")
)
return qs
class CartPosition(AbstractPosition):
"""
A cart position is similar to an order line, except that it is not
@@ -3245,6 +3303,13 @@ class CartPosition(AbstractPosition):
class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True)
checkout_session = models.OneToOneField(
CheckoutSession,
null=True,
blank=True,
related_name='invoice_address',
on_delete=models.CASCADE
)
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
customer = models.ForeignKey(
Customer,

View File

@@ -722,6 +722,10 @@ class BasePaymentProvider:
"""
return ""
def storefrontapi_prepare(self, session_data, total, info):
# TODO: docstring
return True
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]:
"""
Will be called after the user selects this provider as their payment method.
@@ -1447,6 +1451,28 @@ class GiftCardPayment(BasePaymentProvider):
}
)
def storefrontapi_prepare(self, session_data, total, info):
# todo: validate gift card not paid with gift card
try:
gc = self.event.organizer.accepted_gift_cards.get(
secret=info.get("giftcard").strip()
)
try:
self._add_giftcard_to_cart(session_data, gc)
return True
except ValidationError as e:
raise PaymentException(str(e.message))
except GiftCard.DoesNotExist:
if self.event.vouchers.filter(code__iexact=info.get("giftcard")).exists():
raise PaymentException(
_("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "
"the product selection.")
)
else:
raise PaymentException(_("This gift card is not known."))
except GiftCard.MultipleObjectsReturned:
raise PaymentException(_("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event."))
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]:
for p in get_cart(request):
if p.item.issue_giftcard:

View File

@@ -185,104 +185,43 @@ BEFORE_AFTER_CHOICE = (
)
reldatetimeparts = namedtuple('reldatetimeparts', (
"status", # 0
"absolute", # 1
"rel_days_number", # 2
"rel_mins_relationto", # 3
"rel_days_timeofday", # 4
"rel_mins_number", # 5
"rel_days_relationto", # 6
"rel_mins_relation", # 7
"rel_days_relation" # 8
))
reldatetimeparts.indizes = reldatetimeparts(*range(9))
class RelativeDateTimeWidget(forms.MultiWidget):
template_name = 'pretixbase/forms/widgets/reldatetime.html'
parts = reldatetimeparts
def __init__(self, *args, **kwargs):
self.status_choices = kwargs.pop('status_choices')
base_choices = kwargs.pop('base_choices')
widgets = reldatetimeparts(
status=forms.RadioSelect(choices=self.status_choices),
absolute=forms.DateTimeInput(
widgets = (
forms.RadioSelect(choices=self.status_choices),
forms.DateTimeInput(
attrs={'class': 'datetimepicker'}
),
rel_days_number=forms.NumberInput(),
rel_mins_relationto=forms.Select(choices=base_choices),
rel_days_timeofday=forms.TimeInput(attrs={'placeholder': _('Time'), 'class': 'timepickerfield'}),
rel_mins_number=forms.NumberInput(),
rel_days_relationto=forms.Select(choices=base_choices),
rel_mins_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
forms.NumberInput(),
forms.Select(choices=base_choices),
forms.TimeInput(attrs={'placeholder': _('Time'), 'class': 'timepickerfield'}),
forms.NumberInput(),
forms.Select(choices=base_choices),
forms.Select(choices=BEFORE_AFTER_CHOICE),
forms.Select(choices=BEFORE_AFTER_CHOICE),
)
super().__init__(widgets=widgets, *args, **kwargs)
def decompress(self, value):
if isinstance(value, str):
value = RelativeDateWrapper.from_string(value)
if isinstance(value, reldatetimeparts):
return value
if not value:
return reldatetimeparts(
status="unset",
absolute=None,
rel_days_number=1,
rel_mins_relationto="date_from",
rel_days_timeofday=None,
rel_mins_number=0,
rel_days_relationto="date_from",
rel_mins_relation="before",
rel_days_relation="before"
)
return ['unset', None, 1, 'date_from', None, 0, "date_from", "before", "before"]
elif isinstance(value.data, (datetime.datetime, datetime.date)):
return reldatetimeparts(
status="absolute",
absolute=value.data,
rel_days_number=1,
rel_mins_relationto="date_from",
rel_days_timeofday=None,
rel_mins_number=0,
rel_days_relationto="date_from",
rel_mins_relation="before",
rel_days_relation="before"
)
return ['absolute', value.data, 1, 'date_from', None, 0, "date_from", "before", "before"]
elif value.data.minutes is not None:
return reldatetimeparts(
status="relative_minutes",
absolute=None,
rel_days_number=None,
rel_mins_relationto=value.data.base_date_name,
rel_days_timeofday=None,
rel_mins_number=value.data.minutes,
rel_days_relationto=value.data.base_date_name,
rel_mins_relation="after" if value.data.is_after else "before",
rel_days_relation="after" if value.data.is_after else "before"
)
return reldatetimeparts(
status="relative",
absolute=None,
rel_days_number=value.data.days,
rel_mins_relationto=value.data.base_date_name,
rel_days_timeofday=value.data.time,
rel_mins_number=0,
rel_days_relationto=value.data.base_date_name,
rel_mins_relation="after" if value.data.is_after else "before",
rel_days_relation="after" if value.data.is_after else "before"
)
return ['relative_minutes', None, None, value.data.base_date_name, None, value.data.minutes, value.data.base_date_name,
"after" if value.data.is_after else "before", "after" if value.data.is_after else "before"]
return ['relative', None, value.data.days, value.data.base_date_name, value.data.time, 0, value.data.base_date_name,
"after" if value.data.is_after else "before", "after" if value.data.is_after else "before"]
def get_context(self, name, value, attrs):
ctx = super().get_context(name, value, attrs)
ctx['required'] = self.status_choices[0][0] == 'unset'
ctx['rendered_subwidgets'] = self.parts(*(
self._render(w['template_name'], {**ctx, 'widget': w})
for w in ctx['widget']['subwidgets']
))._asdict()
return ctx
@@ -300,36 +239,36 @@ class RelativeDateTimeField(forms.MultiValueField):
choices = BASE_CHOICES
if not kwargs.get('required', True):
status_choices.insert(0, ('unset', _('Not set')))
fields = reldatetimeparts(
status=forms.ChoiceField(
fields = (
forms.ChoiceField(
choices=status_choices,
required=True
),
absolute=forms.DateTimeField(
forms.DateTimeField(
required=False
),
rel_days_number=forms.IntegerField(
forms.IntegerField(
required=False
),
rel_mins_relationto=forms.ChoiceField(
forms.ChoiceField(
choices=choices,
required=False
),
rel_days_timeofday=forms.TimeField(
forms.TimeField(
required=False,
),
rel_mins_number=forms.IntegerField(
forms.IntegerField(
required=False
),
rel_days_relationto=forms.ChoiceField(
forms.ChoiceField(
choices=choices,
required=False
),
rel_mins_relation=forms.ChoiceField(
forms.ChoiceField(
choices=BEFORE_AFTER_CHOICE,
required=False
),
rel_days_relation=forms.ChoiceField(
forms.ChoiceField(
choices=BEFORE_AFTER_CHOICE,
required=False
),
@@ -343,36 +282,32 @@ class RelativeDateTimeField(forms.MultiValueField):
)
def set_event(self, event):
self.widget.widgets[reldatetimeparts.indizes.rel_days_relationto].choices = [
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
]
self.widget.widgets[reldatetimeparts.indizes.rel_mins_relationto].choices = [
self.widget.widgets[3].choices = [
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
]
def compress(self, data_list):
if not data_list:
return None
data = reldatetimeparts(*data_list)
if data.status == 'absolute':
return RelativeDateWrapper(data.absolute)
elif data.status == 'unset':
if data_list[0] == 'absolute':
return RelativeDateWrapper(data_list[1])
elif data_list[0] == 'unset':
return None
elif data.status == 'relative_minutes':
elif data_list[0] == 'relative_minutes':
return RelativeDateWrapper(RelativeDate(
days=0,
base_date_name=data.rel_mins_relationto,
base_date_name=data_list[3],
time=None,
minutes=data.rel_mins_number,
is_after=data.rel_mins_relation == "after",
minutes=data_list[5],
is_after=data_list[7] == "after",
))
else:
return RelativeDateWrapper(RelativeDate(
days=data.rel_days_number,
base_date_name=data.rel_days_relationto,
time=data.rel_days_timeofday,
days=data_list[2],
base_date_name=data_list[6],
time=data_list[4],
minutes=None,
is_after=data.rel_days_relation == "after",
is_after=data_list[8] == "after",
))
def has_changed(self, initial, data):
@@ -381,41 +316,29 @@ class RelativeDateTimeField(forms.MultiValueField):
return super().has_changed(initial, data)
def clean(self, value):
data = reldatetimeparts(*value)
if data.status == 'absolute' and not data.absolute:
if value[0] == 'absolute' and not value[1]:
raise ValidationError(self.error_messages['incomplete'])
elif data.status == 'relative' and (data.rel_days_number is None or not data.rel_days_relationto):
elif value[0] == 'relative' and (value[2] is None or not value[3]):
raise ValidationError(self.error_messages['incomplete'])
elif data.status == 'relative_minutes' and (data.rel_mins_number is None or not data.rel_mins_relationto):
elif value[0] == 'relative_minutes' and (value[5] is None or not value[3]):
raise ValidationError(self.error_messages['incomplete'])
return super().clean(value)
reldateparts = namedtuple('reldateparts', (
"status", # 0
"absolute", # 1
"rel_days_number", # 2
"rel_days_relationto", # 3
"rel_days_relation", # 4
))
reldateparts.indizes = reldateparts(*range(5))
class RelativeDateWidget(RelativeDateTimeWidget):
template_name = 'pretixbase/forms/widgets/reldate.html'
parts = reldateparts
def __init__(self, *args, **kwargs):
self.status_choices = kwargs.pop('status_choices')
widgets = reldateparts(
status=forms.RadioSelect(choices=self.status_choices),
absolute=forms.DateInput(
widgets = (
forms.RadioSelect(choices=self.status_choices),
forms.DateInput(
attrs={'class': 'datepickerfield'}
),
rel_days_number=forms.NumberInput(),
rel_days_relationto=forms.Select(choices=kwargs.pop('base_choices')),
rel_days_relation=forms.Select(choices=BEFORE_AFTER_CHOICE),
forms.NumberInput(),
forms.Select(choices=kwargs.pop('base_choices')),
forms.Select(choices=BEFORE_AFTER_CHOICE),
)
forms.MultiWidget.__init__(self, widgets=widgets, *args, **kwargs)
@@ -423,30 +346,10 @@ class RelativeDateWidget(RelativeDateTimeWidget):
if isinstance(value, str):
value = RelativeDateWrapper.from_string(value)
if not value:
return reldateparts(
status="unset",
absolute=None,
rel_days_number=1,
rel_days_relationto="date_from",
rel_days_relation="before"
)
if isinstance(value, reldateparts):
return value
return ['unset', None, 1, 'date_from', 'before']
elif isinstance(value.data, (datetime.datetime, datetime.date)):
return reldateparts(
status="absolute",
absolute=value.data,
rel_days_number=1,
rel_days_relationto="date_from",
rel_days_relation="before"
)
return reldateparts(
status="relative",
absolute=None,
rel_days_number=value.data.days,
rel_days_relationto=value.data.base_date_name,
rel_days_relation="after" if value.data.is_after else "before"
)
return ['absolute', value.data, 1, 'date_from', 'before']
return ['relative', None, value.data.days, value.data.base_date_name, "after" if value.data.is_after else "before"]
class RelativeDateField(RelativeDateTimeField):
@@ -458,22 +361,22 @@ class RelativeDateField(RelativeDateTimeField):
]
if not kwargs.get('required', True):
status_choices.insert(0, ('unset', _('Not set')))
fields = reldateparts(
status=forms.ChoiceField(
fields = (
forms.ChoiceField(
choices=status_choices,
required=True
),
absolute=forms.DateField(
forms.DateField(
required=False
),
rel_days_number=forms.IntegerField(
forms.IntegerField(
required=False
),
rel_days_relationto=forms.ChoiceField(
forms.ChoiceField(
choices=BASE_CHOICES,
required=False
),
rel_days_relation=forms.ChoiceField(
forms.ChoiceField(
choices=BEFORE_AFTER_CHOICE,
required=False
),
@@ -484,35 +387,28 @@ class RelativeDateField(RelativeDateTimeField):
self, fields=fields, require_all_fields=False, *args, **kwargs
)
def set_event(self, event):
self.widget.widgets[reldateparts.indizes.rel_days_relationto].choices = [
(k, v) for k, v in BASE_CHOICES if getattr(event, k, None)
]
def compress(self, data_list):
if not data_list:
return None
data = reldateparts(*data_list)
if data.status == 'absolute':
return RelativeDateWrapper(data.absolute)
elif data.status == 'unset':
if data_list[0] == 'absolute':
return RelativeDateWrapper(data_list[1])
elif data_list[0] == 'unset':
return None
else:
return RelativeDateWrapper(RelativeDate(
days=data.rel_days_number,
base_date_name=data.rel_days_relationto,
days=data_list[2],
base_date_name=data_list[3],
time=None, minutes=None,
is_after=data.rel_days_relation == "after"
is_after=data_list[4] == "after"
))
def clean(self, value):
data = reldateparts(*value)
if data.status == 'absolute' and not data.absolute:
if value[0] == 'absolute' and not value[1]:
raise ValidationError(self.error_messages['incomplete'])
elif data.status == 'relative' and (data.rel_days_number is None or not data.rel_days_relationto):
elif value[0] == 'relative' and (value[2] is None or not value[3]):
raise ValidationError(self.error_messages['incomplete'])
return forms.MultiValueField.clean(self, value)
return super().clean(value)
class ModelRelativeDateTimeField(models.CharField):

View File

@@ -23,6 +23,7 @@ from datetime import timedelta
from django.conf import settings
from django.core.management import call_command
from django.db.models import Exists, OuterRef
from django.dispatch import receiver
from django.utils.timezone import now
from django_scopes import scopes_disabled
@@ -32,6 +33,7 @@ from pretix.base.models.customers import CustomerSSOGrant
from ..models import CachedFile, CartPosition, InvoiceAddress
from ..models.auth import UserKnownLoginSource
from ..models.orders import CheckoutSession
from ..signals import periodic_task
@@ -42,6 +44,10 @@ def clean_cart_positions(sender, **kwargs):
cp.delete()
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=True):
cp.delete()
for cs in CheckoutSession.objects.filter(created__lt=now() - timedelta(days=14)).exclude(
Exists(CartPosition.objects.filter(cart_id=OuterRef("cart_id")))
):
cs.delete()
for ia in InvoiceAddress.objects.filter(order__isnull=True, customer__isnull=True, last_modified__lt=now() - timedelta(days=14)):
ia.delete()

View File

@@ -29,7 +29,7 @@ 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
from pretix.base.storelogic.products import get_items_for_product_list
class DummyCategory:
@@ -161,7 +161,7 @@ class CrossSellingService:
]
def _prepare_items(self, subevent, items_qs, discount_info):
items, _btn = get_grouped_items(
items, _btn = get_items_for_product_list(
self.event,
subevent=subevent,
voucher=None,

View File

@@ -3557,8 +3557,8 @@ PERSON_NAME_SCHEMES = OrderedDict([
str(p) for p in [d.get('family_name', ''), d.get('given_name', '')] if p
),
'sample': {
'family_name': '',
'given_name': '',
'given_name': '泽东',
'family_name': '',
'_scheme': 'family_nospace_given',
},
}),
@@ -3609,8 +3609,8 @@ PERSON_NAME_SCHEMES = OrderedDict([
'concatenation': lambda d: str(d.get('full_name', '')),
'concatenation_all_components': lambda d: str(d.get('full_name', '')) + " (" + d.get('latin_transcription', '') + ")",
'sample': {
'full_name': '山田花子',
'latin_transcription': 'Yamada Hanako',
'full_name': '庄司',
'latin_transcription': 'Shōji',
'_scheme': 'full_transcription',
},
}),
@@ -3701,7 +3701,6 @@ COUNTRIES_WITH_STATE_IN_ADDRESS = {
'BR': (['State'], 'short'),
'CA': (['Province', 'Territory'], 'short'),
# 'CN': (['Province', 'Autonomous region', 'Munincipality'], 'long'),
'JP': (['Prefecture'], 'long'),
'MY': (['State', 'Federal territory'], 'long'),
'MX': (['State', 'Federal district'], 'short'),
'US': (['State', 'Outlying area', 'District'], 'short'),

View File

@@ -0,0 +1,2 @@
class IncompleteError(Exception):
pass

View File

@@ -0,0 +1,118 @@
import copy
from collections import defaultdict
from pretix.base.models.tax import TaxedPrice
from pretix.base.storelogic.products import get_items_for_product_list
def addons_is_completed(cart_positions):
for cartpos in cart_positions.filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__item'
):
a = cartpos.addons.all()
for iao in cartpos.item.addons.all():
found = len([1 for p in a if p.item.category_id == iao.addon_category_id and not p.is_bundled])
if found < iao.min_count or found > iao.max_count:
return False
return True
def addons_is_applicable(cart_positions):
return cart_positions.filter(item__addons__isnull=False).exists()
def get_addon_groups(event, sales_channel, customer, cart_positions):
quota_cache = {}
item_cache = {}
groups = []
for cartpos in sorted(cart_positions.filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation',
), key=lambda c: c.sort_key):
groupentry = {
'pos': cartpos,
'item': cartpos.item,
'variation': cartpos.variation,
'categories': []
}
current_addon_products = defaultdict(list)
for a in cartpos.addons.all():
if not a.is_bundled:
current_addon_products[a.item_id, a.variation_id].append(a)
for iao in cartpos.item.addons.all():
ckey = '{}-{}'.format(cartpos.subevent.pk if cartpos.subevent else 0, iao.addon_category.pk)
if ckey not in item_cache:
# Get all items to possibly show
items, _btn = get_items_for_product_list(
event,
subevent=cartpos.subevent,
voucher=None,
channel=sales_channel,
base_qs=iao.addon_category.items,
allow_addons=True,
quota_cache=quota_cache,
memberships=(
customer.usable_memberships(
for_event=cartpos.subevent or event,
testmode=event.testmode
)
if customer else None
),
)
item_cache[ckey] = items
else:
# We can use the cache to prevent a database fetch, but we need separate Python objects
# or our things below like setting `i.initial` will do the wrong thing.
items = [copy.copy(i) for i in item_cache[ckey]]
for i in items:
i.available_variations = [copy.copy(v) for v in i.available_variations]
for i in items:
i.allow_waitinglist = False
if i.has_variations:
for v in i.available_variations:
v.initial = len(current_addon_products[i.pk, v.pk])
if v.initial and i.free_price:
a = current_addon_products[i.pk, v.pk][0]
v.initial_price = TaxedPrice(
net=a.price - a.tax_value,
gross=a.price,
tax=a.tax_value,
name=a.item.tax_rule.name if a.item.tax_rule else "",
rate=a.tax_rate,
code=a.item.tax_rule.code if a.item.tax_rule else None,
)
else:
v.initial_price = v.suggested_price
i.expand = any(v.initial for v in i.available_variations)
else:
i.initial = len(current_addon_products[i.pk, None])
if i.initial and i.free_price:
a = current_addon_products[i.pk, None][0]
i.initial_price = TaxedPrice(
net=a.price - a.tax_value,
gross=a.price,
tax=a.tax_value,
name=a.item.tax_rule.name if a.item.tax_rule else "",
rate=a.tax_rate,
code=a.item.tax_rule.code if a.item.tax_rule else None,
)
else:
i.initial_price = i.suggested_price
if items:
groupentry['categories'].append({
'category': iao.addon_category,
'price_included': iao.price_included or (cartpos.voucher_id and cartpos.voucher.all_addons_included),
'multi_allowed': iao.multi_allowed,
'min_count': iao.min_count,
'max_count': iao.max_count,
'iao': iao,
'items': items
})
if groupentry['categories']:
groups.append(groupentry)
return groups

View File

@@ -0,0 +1,271 @@
from django.core.exceptions import ValidationError
from django.core.validators import EmailValidator
from django.utils.translation import gettext_lazy as _
from pretix.base.models import CartPosition, Question
from pretix.base.services.checkin import _save_answers
from pretix.base.storelogic import IncompleteError
from pretix.presale.signals import question_form_fields
class Field:
@property
def identifier(self):
raise NotImplementedError()
@property
def label(self):
raise NotImplementedError()
@property
def help_text(self):
raise NotImplementedError()
@property
def type(self):
raise NotImplementedError()
@property
def required(self):
return True
@property
def validation_hints(self):
raise {}
def validate_input(self, value):
return value
class PositionField(Field):
def save_input(self, position, value):
raise NotImplementedError()
def current_value(self, position):
raise NotImplementedError()
class SessionField(Field):
def save_input(self, session_data, value):
raise NotImplementedError()
def current_value(self, session_data):
raise NotImplementedError()
class QuestionField(PositionField):
def __init__(self, question: Question):
self.question = question
@property
def label(self):
return self.question.question
@property
def help_text(self):
return self.question.help_text
@property
def type(self):
return self.question.type
@property
def identifier(self):
return f"question_{self.question.identifier}"
def validate_input(self, value):
return self.question.clean_answer(value)
def required(self, value):
return self.question.required
def validation_hints(self):
d = {
"valid_number_min": self.question.valid_number_min,
"valid_number_max": self.question.valid_number_max,
"valid_date_min": self.question.valid_date_min,
"valid_date_max": self.question.valid_date_max,
"valid_datetime_min": self.question.valid_datetime_min,
"valid_datetime_max": self.question.valid_datetime_max,
"valid_string_length_max": self.question.valid_string_length_max,
"dependency_on": f"question_{self.question.dependency_question.identifier}" if self.question.dependency_question_id else None,
"dependency_values": self.question.dependency_values,
}
if self.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
d["choices"] = [
{
"identifier": opt.identifier,
"label": str(opt.answer)
}
for opt in self.question.options.all()
]
return d
def save_input(self, position, value):
answers = [a for a in position.answerlist if a.question_id == self.question.id]
if answers:
answers = {self.question: answers[0]}
else:
answers = {}
_save_answers(position, answers, {self.question: value})
def current_value(self, position):
answers = [a for a in position.answerlist if a.question_id == self.question.id]
if answers:
if self.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE):
return ",".join([a.idenitifer for a in answers[0].options.all()])
else:
return answers[0].answer
class SyntheticSessionField(SessionField):
def __init__(self, label, help_text, type, identifier, required, save_func, get_func, validate_func):
self._label = label
self._help_text = help_text
self._type = type
self._identifier = identifier
self._required = required
self._save_func = save_func
self._get_func = get_func
self._validate_func = validate_func
super().__init__()
@property
def label(self):
return self._label
@property
def help_text(self):
return self._help_text
@property
def type(self):
return self._type
@property
def required(self):
return self._required
@property
def identifier(self):
return self._identifier
def validation_hints(self):
return {}
def save_input(self, session_data, value):
self._save_func(session_data, value)
def current_value(self, session_data):
return self._get_func(session_data)
def validate_input(self, value):
return self._validate_func(value)
def get_checkout_fields(event):
fields = []
# TODO: support contact_form_fields
# TODO: support contact_form_fields_override
# email
fields.append(SyntheticSessionField(
label=_("Email"),
help_text=None,
type=Question.TYPE_STRING, # TODO: Add a type?
identifier="email",
required=True,
get_func=lambda session_data: session_data.get("email"),
save_func=lambda session_data, value: session_data.update({"email": value}),
validate_func=lambda value: EmailValidator()(value) or value,
))
# TODO: phone
# TODO: invoice address
return fields
def get_position_fields(event, pos: CartPosition):
# TODO: support override sets
fields = []
for q in pos.item.questions_to_ask:
fields.append(QuestionField(q))
return fields
def ensure_fields_are_completed(event, positions, cart_session, invoice_address, all_optional, cart_is_free):
try:
emailval = EmailValidator()
if not cart_session.get('email') and not all_optional:
raise IncompleteError(_('Please enter a valid email address.'))
if cart_session.get('email'):
emailval(cart_session.get('email'))
except ValidationError:
raise IncompleteError(_('Please enter a valid email address.'))
address_asked = (
event.settings.invoice_address_asked and (not event.settings.invoice_address_not_asked_free or not cart_is_free)
)
if not all_optional:
if address_asked:
if event.settings.invoice_address_required and (not invoice_address or not invoice_address.street):
raise IncompleteError(_('Please enter your invoicing address.'))
if event.settings.invoice_name_required and (not invoice_address or not invoice_address.name):
raise IncompleteError(_('Please enter your name.'))
for cp in positions:
answ = {
aw.question_id: aw for aw in cp.answerlist
}
question_cache = {
q.pk: q for q in cp.item.questions_to_ask
}
def question_is_visible(parentid, qvals):
if parentid not in question_cache:
return False
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id,
parentq.dependency_values):
return False
if parentid not in answ:
return False
return (
('True' in qvals and answ[parentid].answer == 'True')
or ('False' in qvals and answ[parentid].answer == 'False')
or (any(qval in [o.identifier for o in answ[parentid].options.all()] for qval in qvals))
)
def question_is_required(q):
return (
q.required and
(not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values))
)
if not all_optional:
for q in cp.item.questions_to_ask:
if question_is_required(q) and q.id not in answ:
raise IncompleteError(_('Please fill in answers to all required questions.'))
if cp.item.ask_attendee_data and event.settings.get('attendee_names_required', as_type=bool) \
and not cp.attendee_name_parts:
raise IncompleteError(_('Please fill in answers to all required questions.'))
if cp.item.ask_attendee_data and event.settings.get('attendee_emails_required', as_type=bool) \
and cp.attendee_email is None:
raise IncompleteError(_('Please fill in answers to all required questions.'))
if cp.item.ask_attendee_data and event.settings.get('attendee_company_required', as_type=bool) \
and cp.company is None:
raise IncompleteError(_('Please fill in answers to all required questions.'))
if cp.item.ask_attendee_data and event.settings.get('attendee_addresses_required', as_type=bool) \
and (cp.street is None and cp.city is None and cp.country is None):
raise IncompleteError(_('Please fill in answers to all required questions.'))
responses = question_form_fields.send(sender=event, position=cp)
form_data = cp.meta_info_data.get('question_form_data', {})
for r, response in sorted(responses, key=lambda r: str(r[0])):
for key, value in response.items():
if value.required and not form_data.get(key):
raise IncompleteError(_('Please fill in answers to all required questions.'))

View File

@@ -0,0 +1,132 @@
import copy
import uuid
from decimal import Decimal
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext as _
from pretix.base.storelogic import IncompleteError
from pretix.base.templatetags.money import money_filter
def payment_is_applicable(event, total, cart_positions, invoice_address, cart_session, request):
for cartpos in cart_positions:
if cartpos.requires_approval(invoice_address=invoice_address):
if 'payments' in cart_session:
del cart_session['payments']
return False
used_providers = {p['provider'] for p in cart_session.get('payments', [])}
for provider in event.get_payment_providers().values():
if provider.is_implicit(request) if callable(provider.is_implicit) else provider.is_implicit:
# TODO: do we need a different is_allowed for storefrontapi?
if provider.is_allowed(request, total=total):
cart_session['payments'] = [
{
'id': str(uuid.uuid4()),
'provider': provider.identifier,
'multi_use_supported': False,
'min_value': None,
'max_value': None,
'info_data': {},
}
]
return False
elif provider.identifier in used_providers:
# is_allowed might have changed, e.g. after add-on selection
cart_session['payments'] = [p for p in cart_session['payments'] if
p['provider'] != provider.identifier]
return True
def current_selected_payments(event, total, cart_session, total_includes_payment_fees=False, fail=False):
def _remove_payment(payment_id):
cart_session['payments'] = [p for p in cart_session['payments'] if p.get('id') != payment_id]
raw_payments = copy.deepcopy(cart_session.get('payments', []))
payments = []
total_remaining = total
for p in raw_payments:
# This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base:
# - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees
# - pretix.presale.storelogic.payment.current_selected_payments
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
_remove_payment(p['id'])
if fail:
raise IncompleteError(
_('Your selected payment method can only be used for a payment of at least {amount}.').format(
amount=money_filter(Decimal(p['min_value']), event.currency)
)
)
continue
to_pay = total_remaining
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
pprov = event.get_payment_providers(cached=True).get(p['provider'])
if not pprov:
_remove_payment(p['id'])
continue
if not total_includes_payment_fees:
fee = pprov.calculate_fee(to_pay)
total_remaining += fee
to_pay += fee
else:
fee = Decimal('0.00')
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
p['payment_amount'] = to_pay
p['provider_name'] = pprov.public_name
p['pprov'] = pprov
p['fee'] = fee
total_remaining -= to_pay
payments.append(p)
return payments
def ensure_payment_is_completed(event, total, cart_session, request):
def _remove_payment(payment_id):
cart_session['payments'] = [p for p in cart_session['payments'] if p.get('id') != payment_id]
if not cart_session.get('payments'):
raise IncompleteError(_('Please select a payment method to proceed.'))
selected = current_selected_payments(event, total, cart_session, fail=True, total_includes_payment_fees=True)
if sum(p['payment_amount'] for p in selected) != total:
raise IncompleteError(_('Please select a payment method to proceed.'))
if len([p for p in selected if not p['multi_use_supported']]) > 1:
raise ImproperlyConfigured('Multiple non-multi-use providers in session, should never happen')
for p in selected:
# TODO: do we need a different is_allowed for storefrontapi?
if not p['pprov'] or not p['pprov'].is_enabled or not p['pprov'].is_allowed(request, total=total):
_remove_payment(p['id'])
if p['payment_amount']:
raise IncompleteError(_('Please select a payment method to proceed.'))
if not p['multi_use_supported'] and not p['pprov'].payment_is_valid_session(request):
raise IncompleteError(_('The payment information you entered was incomplete.'))
def current_payments_valid(cart_session, amount):
singleton_payments = [p for p in cart_session.get('payments', []) if not p.get('multi_use_supported')]
if len(singleton_payments) > 1:
return False
matched = Decimal('0.00')
for p in cart_session.get('payments', []):
if p.get('min_value') and (amount - matched) < Decimal(p['min_value']):
continue
if p.get('max_value') and (amount - matched) > Decimal(p['max_value']):
matched += Decimal(p['max_value'])
else:
matched = Decimal('0.00')
return matched == Decimal('0.00'), amount - matched

View File

@@ -0,0 +1,396 @@
import sys
from django.conf import settings
from django.db.models import (
Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value,
)
from django.db.models.lookups import Exact
from pretix.base.models import (
ItemVariation, Quota, SalesChannel, SeatCategoryMapping,
)
from pretix.base.models.items import (
ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
)
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timemachine import time_machine_now
from pretix.presale.signals import item_description
def item_group_by_category(items):
return sorted(
[
# a group is a tuple of a category and a list of items
(cat, [i for i in items if i.category == cat])
for cat in set([i.category for i in items])
# insert categories into a set for uniqueness
# a set is unsorted, so sort again by category
],
key=lambda group: (group[0].position, group[0].id) if (group[0] is not None and group[0].id is not None) else (0, 0)
)
def get_items_for_product_list(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0,
base_qs=None, allow_addons=False, allow_cross_sell=False,
quota_cache=None, filter_items=None, filter_categories=None, memberships=None,
ignore_hide_sold_out_for_item_ids=None):
base_qs_set = base_qs is not None
base_qs = base_qs if base_qs is not None else event.items
requires_seat = Exists(
SeatCategoryMapping.objects.filter(
product_id=OuterRef('pk'),
subevent=subevent
)
)
if not event.settings.seating_choice:
requires_seat = Value(0, output_field=IntegerField())
variation_q = (
Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info')) &
Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info'))
)
if not voucher or not voucher.show_hidden_items:
variation_q &= Q(hide_without_voucher=False)
if memberships is not None:
prefetch_membership_types = ['require_membership_types']
else:
prefetch_membership_types = []
prefetch_var = Prefetch(
'variations',
to_attr='available_variations',
queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).annotate(
subevent_disabled=Exists(
SubEventItemVariation.objects.filter(
Q(disabled=True)
| (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now()))
| (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())),
variation_id=OuterRef('pk'),
subevent=subevent,
)
),
).filter(
variation_q,
Q(all_sales_channels=True) | Q(limit_sales_channels=channel),
active=True,
quotas__isnull=False,
subevent_disabled=False
).prefetch_related(
*prefetch_membership_types,
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
subevent=subevent).select_related("subevent"))
).distinct()
)
prefetch_quotas = Prefetch(
'quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent).select_related("subevent")
)
prefetch_bundles = Prefetch(
'bundles',
queryset=ItemBundle.objects.using(settings.DATABASE_REPLICA).prefetch_related(
Prefetch('bundled_item',
queryset=event.items.using(settings.DATABASE_REPLICA).select_related(
'tax_rule').prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
subevent=subevent)),
)),
Prefetch('bundled_variation',
queryset=ItemVariation.objects.using(
settings.DATABASE_REPLICA
).select_related('item', 'item__tax_rule').filter(item__event=event).prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
subevent=subevent)),
)),
)
)
items = base_qs.using(settings.DATABASE_REPLICA).filter_available(
channel=channel.identifier, voucher=voucher, allow_addons=allow_addons, allow_cross_sell=allow_cross_sell
).select_related(
'category', 'tax_rule', # for re-grouping
'hidden_if_available',
).prefetch_related(
*prefetch_membership_types,
Prefetch(
'hidden_if_item_available',
queryset=event.items.annotate(
has_variations=Count('variations'),
).prefetch_related(
prefetch_var,
prefetch_quotas,
prefetch_bundles,
)
),
prefetch_quotas,
prefetch_var,
prefetch_bundles,
).annotate(
quotac=Count('quotas'),
has_variations=Count('variations'),
subevent_disabled=Exists(
SubEventItem.objects.filter(
Q(disabled=True)
| (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now()))
| (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())),
item_id=OuterRef('pk'),
subevent=subevent,
)
),
mandatory_priced_addons=Exists(
ItemAddOn.objects.filter(
base_item_id=OuterRef('pk'),
min_count__gte=1,
price_included=False
)
),
requires_seat=requires_seat,
).filter(
quotac__gt=0, subevent_disabled=False,
).order_by('category__position', 'category_id', 'position', 'name')
if require_seat:
items = items.filter(requires_seat__gt=0)
elif require_seat is not None:
items = items.filter(requires_seat=0)
if filter_items:
items = items.filter(pk__in=[a for a in filter_items if a.isdigit()])
if filter_categories:
items = items.filter(category_id__in=[a for a in filter_categories if a.isdigit()])
display_add_to_cart = False
quota_cache_key = f'item_quota_cache:{subevent.id if subevent else 0}:{channel.identifier}:{bool(require_seat)}'
quota_cache = quota_cache or event.cache.get(quota_cache_key) or {}
quota_cache_existed = bool(quota_cache)
if subevent:
item_price_override = subevent.item_price_overrides
var_price_override = subevent.var_price_overrides
else:
item_price_override = {}
var_price_override = {}
restrict_vars = set()
if voucher and voucher.quota_id:
# If a voucher is set to a specific quota, we need to filter out on that level
restrict_vars = set(voucher.quota.variations.all())
quotas_to_compute = []
for item in items:
assert item.event_id == event.pk
item.event = event # save a database query if this is looked up
if item.has_variations:
for v in item.available_variations:
for q in v._subevent_quotas:
if q.pk not in quota_cache:
quotas_to_compute.append(q)
else:
for q in item._subevent_quotas:
if q.pk not in quota_cache:
quotas_to_compute.append(q)
if quotas_to_compute:
qa = QuotaAvailability()
qa.queue(*quotas_to_compute)
qa.compute()
quota_cache.update({q.pk: r for q, r in qa.results.items()})
for item in items:
if voucher and voucher.item_id and voucher.variation_id:
# Restrict variations if the voucher only allows one
item.available_variations = [v for v in item.available_variations
if v.pk == voucher.variation_id]
if channel.type_instance.unlimited_items_per_order:
max_per_order = sys.maxsize
else:
max_per_order = item.max_per_order or int(event.settings.max_items_per_order)
if item.hidden_if_available:
q = item.hidden_if_available.availability(_cache=quota_cache)
if q[0] == Quota.AVAILABILITY_OK:
item._remove = True
continue
if item.hidden_if_item_available:
if item.hidden_if_item_available.has_variations:
dependency_available = any(
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK
for var in item.hidden_if_item_available.available_variations
)
else:
q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
dependency_available = q[0] == Quota.AVAILABILITY_OK
if dependency_available:
item._remove = True
continue
if item.require_membership and item.require_membership_hidden:
if not memberships or not any([m.membership_type in item.require_membership_types.all() for m in memberships]):
item._remove = True
continue
item.current_unavailability_reason = item.unavailability_reason(has_voucher=voucher, subevent=subevent)
item.description = str(item.description)
for recv, resp in item_description.send(sender=event, item=item, variation=None, subevent=subevent):
if resp:
item.description += ("<br/>" if item.description else "") + resp
if not item.has_variations:
item._remove = False
if not bool(item._subevent_quotas):
item._remove = True
continue
if voucher and (voucher.allow_ignore_quota or voucher.block_quota):
item.cached_availability = (
Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed
)
else:
item.cached_availability = list(
item.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
)
if not (
ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids
) and event.settings.hide_sold_out and item.cached_availability[0] < Quota.AVAILABILITY_RESERVED:
item._remove = True
continue
item.order_max = min(
item.cached_availability[1]
if item.cached_availability[1] is not None else sys.maxsize,
max_per_order
)
original_price = item_price_override.get(item.pk, item.default_price)
voucher_reduced = False
if voucher:
price = voucher.calculate_price(original_price)
voucher_reduced = price < original_price
include_bundled = not voucher.all_bundles_included
else:
price = original_price
include_bundled = True
item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
if item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled)
else:
item.suggested_price = item.display_price
if price != original_price:
item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True)
else:
item.original_price = (
item.tax(item.original_price, currency=event.currency, include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
if item.original_price else None
)
if not display_add_to_cart:
display_add_to_cart = not item.requires_seat and item.order_max > 0
else:
for var in item.available_variations:
if var.require_membership and var.require_membership_hidden:
if not memberships or not any([m.membership_type in var.require_membership_types.all() for m in memberships]):
var._remove = True
continue
var.description = str(var.description)
for recv, resp in item_description.send(sender=event, item=item, variation=var, subevent=subevent):
if resp:
var.description += ("<br/>" if var.description else "") + resp
if voucher and (voucher.allow_ignore_quota or voucher.block_quota):
var.cached_availability = (
Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed
)
else:
var.cached_availability = list(
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
)
var.order_max = min(
var.cached_availability[1]
if var.cached_availability[1] is not None else sys.maxsize,
max_per_order
)
original_price = var_price_override.get(var.pk, var.price)
voucher_reduced = False
if voucher:
price = voucher.calculate_price(original_price)
voucher_reduced = price < original_price
include_bundled = not voucher.all_bundles_included
else:
price = original_price
include_bundled = True
var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
if item.free_price and var.free_price_suggestion is not None and not voucher_reduced:
var.suggested_price = item.tax(max(price, var.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
elif item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
var.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
else:
var.suggested_price = var.display_price
if price != original_price:
var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True)
else:
var.original_price = (
var.tax(var.original_price or item.original_price, currency=event.currency,
include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
) if var.original_price or item.original_price else None
if not display_add_to_cart:
display_add_to_cart = not item.requires_seat and var.order_max > 0
var.current_unavailability_reason = var.unavailability_reason(has_voucher=voucher, subevent=subevent)
item.original_price = (
item.tax(item.original_price, currency=event.currency, include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
if item.original_price else None
)
item.available_variations = [
v for v in item.available_variations if v._subevent_quotas and (
not voucher or not voucher.quota_id or v in restrict_vars
) and not getattr(v, '_remove', False)
]
if not (ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids) and event.settings.hide_sold_out:
item.available_variations = [v for v in item.available_variations
if v.cached_availability[0] >= Quota.AVAILABILITY_RESERVED]
if voucher and voucher.variation_id:
item.available_variations = [v for v in item.available_variations
if v.pk == voucher.variation_id]
if len(item.available_variations) > 0:
item.min_price = min([v.display_price.net if event.settings.display_net_prices else
v.display_price.gross for v in item.available_variations])
item.max_price = max([v.display_price.net if event.settings.display_net_prices else
v.display_price.gross for v in item.available_variations])
item.best_variation_availability = max([v.cached_availability[0] for v in item.available_variations])
item._remove = not bool(item.available_variations)
if not quota_cache_existed and not voucher and not allow_addons and not base_qs_set and not filter_items and not filter_categories:
event.cache.set(quota_cache_key, quota_cache, 5)
items = [item for item in items
if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove]
return items, display_add_to_cart

View File

@@ -9,11 +9,12 @@
{{ selopt.label }}
</label>
{% if selopt.value == "absolute" %}
{{ rendered_subwidgets.absolute }}
{% include widget.subwidgets.1.template_name with widget=widget.subwidgets.1 %}
{% elif selopt.value == "relative" %}
{% blocktrans trimmed with number=rendered_subwidgets.rel_days_number relation=rendered_subwidgets.rel_days_relation relation_to=rendered_subwidgets.rel_days_relationto %}
{{ number }} days {{ relation }} {{ relation_to }}
{% endblocktrans %}
{% include widget.subwidgets.2.template_name with widget=widget.subwidgets.2 %}
{% trans "days" %}
{% include widget.subwidgets.4.template_name with widget=widget.subwidgets.4 %}
{% include widget.subwidgets.3.template_name with widget=widget.subwidgets.3 %}
{% endif %}
</div>
{% endfor %}

View File

@@ -9,15 +9,19 @@
{{ selopt.label }}
</label>
{% if selopt.value == "absolute" %}
{{ rendered_subwidgets.absolute }}
{% include widget.subwidgets.1.template_name with widget=widget.subwidgets.1 %}
{% elif selopt.value == "relative_minutes" %}
{% blocktrans trimmed with number=rendered_subwidgets.rel_mins_number relation=rendered_subwidgets.rel_mins_relation relation_to=rendered_subwidgets.rel_mins_relationto %}
{{ number }} minutes {{ relation }} {{ relation_to }}
{% endblocktrans %}
{% include widget.subwidgets.5.template_name with widget=widget.subwidgets.5 %}
{% trans "minutes" %}
{% include widget.subwidgets.7.template_name with widget=widget.subwidgets.7 %}
{% include widget.subwidgets.3.template_name with widget=widget.subwidgets.3 %}
{% elif selopt.value == "relative" %}
{% blocktrans trimmed with number=rendered_subwidgets.rel_days_number relation=rendered_subwidgets.rel_days_relation relation_to=rendered_subwidgets.rel_days_relationto time_of_day=rendered_subwidgets.rel_days_timeofday %}
{{ number }} days {{ relation }} {{ relation_to }} at {{ time_of_day }}
{% endblocktrans %}
{% include widget.subwidgets.2.template_name with widget=widget.subwidgets.2 %}
{% trans "days" %}
{% include widget.subwidgets.8.template_name with widget=widget.subwidgets.8 %}
{% include widget.subwidgets.6.template_name with widget=widget.subwidgets.6 %}
{% trans "at" %}
{% include widget.subwidgets.4.template_name with widget=widget.subwidgets.4 %}
{% endif %}
</div>
{% endfor %}

View File

@@ -67,6 +67,7 @@ class EventSlugBanlistValidator(BanlistValidator):
'_global',
'__debug__',
'api',
'storefrontapi',
'events',
'csp_report',
'widget',
@@ -91,6 +92,7 @@ class OrganizerSlugBanlistValidator(BanlistValidator):
'__debug__',
'about',
'api',
'storefrontapi',
'csp_report',
'widget',
'lead',

View File

@@ -40,5 +40,5 @@ class PretixControlConfig(AppConfig):
label = 'pretixcontrol'
def ready(self):
from .views import dashboards # noqa
from . import logdisplay # noqa
from .views import dashboards # noqa

View File

@@ -4,8 +4,8 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-16 14:20+0000\n"
"PO-Revision-Date: 2025-01-14 09:56+0000\n"
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n"
"PO-Revision-Date: 2024-11-04 13:44+0000\n"
"Last-Translator: Katrine Tella <ktl@hjoerring.dk>\n"
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix/da/"
">\n"
"Language: da\n"
@@ -13,7 +13,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.9.2\n"
"X-Generator: Weblate 5.8.1\n"
#: pretix/_base_settings.py:79
msgid "English"
@@ -20841,7 +20841,7 @@ msgstr "Nej, gå tilbage"
#: pretix/control/templates/pretixcontrol/order/approve.html:25
msgid "Yes, approve order"
msgstr "Ja, godkend bestilling"
msgstr "Ja, annullér bestilling"
#: pretix/control/templates/pretixcontrol/order/cancel.html:6
#: pretix/control/templates/pretixcontrol/order/cancel.html:10

View File

@@ -5,8 +5,8 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-16 14:20+0000\n"
"PO-Revision-Date: 2025-01-10 10:00+0000\n"
"Last-Translator: Martin Gross <gross@rami.io>\n"
"PO-Revision-Date: 2024-12-16 16:15+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/de/"
">\n"
"Language: de\n"
@@ -14,7 +14,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.9.2\n"
"X-Generator: Weblate 5.8.4\n"
"X-Poedit-Bookmarks: -1,-1,904,-1,-1,-1,-1,-1,-1,-1\n"
#: pretix/_base_settings.py:79
@@ -19542,7 +19542,7 @@ msgid ""
msgstr ""
"Wenn Sie komplexere Funktionen wie zusätzliche Produkte, Produktvarianten "
"oder freie Kontingentstrukturen nutzen wollen, können Sie dies später über "
"den Konfigurationsbereich Produkte in der linken Navigationsleiste tun. "
"den Konfigurationsbereich \"Produkte\" in der linken Navigationsleiste tun. "
"Keine Sorge, alles was Sie hier eingeben, können Sie später noch ändern."
#: pretix/control/templates/pretixcontrol/event/quick_setup.html:132
@@ -32366,7 +32366,7 @@ msgstr "Wir versuchen nun, diese Zusatzprodukte für Sie zu buchen!"
#: pretix/presale/templates/pretixpresale/event/checkout_addons.html:28
msgid "Additional options for"
msgstr "Zusätzliche Optionen für"
msgstr "Zusätzliche Einstellungen für"
#: pretix/presale/templates/pretixpresale/event/checkout_addons.html:64
msgid "More recommendations"

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-16 14:20+0000\n"
"PO-Revision-Date: 2025-01-10 10:00+0000\n"
"Last-Translator: Martin Gross <gross@rami.io>\n"
"PO-Revision-Date: 2024-12-16 16:28+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German (informal) <https://translate.pretix.eu/projects/"
"pretix/pretix/de_Informal/>\n"
"Language: de_Informal\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.9.2\n"
"X-Generator: Weblate 5.8.4\n"
#: pretix/_base_settings.py:79
msgid "English"
@@ -19511,7 +19511,7 @@ msgstr ""
"Wenn du komplexere Funktionen wie zusätzliche Produkte, Produktvarianten "
"oder freie Kontingentstrukturen nutzen willst, kannst du dies später über "
"den Konfigurationsbereich \"Produkte\" in der linken Navigationsleiste tun. "
"Keine Sorge, alles was du hier eingibst, kannst du später noch ändern."
"Keine Sorge, alles was du hier gibst, kannst du später noch ändern."
#: pretix/control/templates/pretixcontrol/event/quick_setup.html:132
#: pretix/control/views/event.py:357
@@ -19524,7 +19524,7 @@ msgid ""
"your event, but if you're in a hurry and want to get started quickly, here's "
"a short version:"
msgstr ""
"Wir empfehlen, dass du dir die Zeit nimmst und die Einstellungen deiner "
"Wir empfehlen, dass du dich die Zeit nimmst und die Einstellungen deiner "
"Veranstaltungen in Ruhe durchgehst. Wenn du aber schnell loslegen willst, "
"ist hier die Kurzversion:"
@@ -32309,7 +32309,7 @@ msgstr "Wir versuchen nun, diese Zusatzprodukte für dich zu buchen!"
#: pretix/presale/templates/pretixpresale/event/checkout_addons.html:28
msgid "Additional options for"
msgstr "Zusätzliche Optionen für"
msgstr "Zusätzliche Einstellungen für"
#: pretix/presale/templates/pretixpresale/event/checkout_addons.html:64
msgid "More recommendations"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-16 14:20+0000\n"
"PO-Revision-Date: 2025-01-04 19:00+0000\n"
"PO-Revision-Date: 2025-01-03 14:22+0000\n"
"Last-Translator: Hector <hector@demandaeventos.es>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix/"
"es/>\n"
@@ -500,19 +500,19 @@ msgstr "Pago confirmado"
#: pretix/api/webhooks.py:305
msgid "Order approved"
msgstr "Pedido aprobado"
msgstr "Perdido aprobado"
#: pretix/api/webhooks.py:309
msgid "Order denied"
msgstr "Pedido denegado"
msgstr "Perdido denegado"
#: pretix/api/webhooks.py:313
msgid "Order deleted"
msgstr "Pedido eliminado"
msgstr "Perdido borrado"
#: pretix/api/webhooks.py:317
msgid "Ticket checked in"
msgstr "Entrada escaneada"
msgstr "Entrada comprobada"
#: pretix/api/webhooks.py:321
msgid "Ticket check-in reverted"
@@ -533,7 +533,7 @@ msgstr "Evento eliminado"
#: pretix/api/webhooks.py:337
msgctxt "subevent"
msgid "Event series date added"
msgstr "Fechas de la serie de eventos añadidas"
msgstr "Fecha de inicio de la serie de eventos creada"
#: pretix/api/webhooks.py:341
msgctxt "subevent"
@@ -571,15 +571,15 @@ msgstr "Modo de pruebas de la tienda ha sido desactivado"
#: pretix/api/webhooks.py:370
msgid "Waiting list entry added"
msgstr "Elemento agregado a la lista de espera"
msgstr "Agregado a la lista de espera"
#: pretix/api/webhooks.py:374
msgid "Waiting list entry changed"
msgstr "Elemento de la lista de espera cambiado"
msgstr "Entrada a la lista de espera"
#: pretix/api/webhooks.py:378
msgid "Waiting list entry deleted"
msgstr "Elemento de la lista de espera eliminado"
msgstr "Entrada a la lista de espera eliminada"
#: pretix/api/webhooks.py:382
msgid "Waiting list entry received voucher"
@@ -612,11 +612,11 @@ msgstr "Este campo es obligatorio."
#: pretix/base/addressvalidation.py:213
msgid "Enter a postal code in the format XXX."
msgstr "Ingresa el código postal en el formato XXXXX."
msgstr "Ingresa el código postal en el formato XXX."
#: pretix/base/addressvalidation.py:222 pretix/base/addressvalidation.py:224
msgid "Enter a postal code in the format XXXX."
msgstr "Ingresa un código postal en el formato XXXXX."
msgstr "Ingresa un código postal en el formato XXX."
#: pretix/base/auth.py:146
#, python-brace-format
@@ -657,7 +657,7 @@ msgstr "Contraseña"
#: pretix/base/auth.py:176 pretix/base/auth.py:183
msgid "Your password must contain both numeric and alphabetic characters."
msgstr "Su contraseña debe contener caracteres alfanuméricos."
msgstr "Su contraseña debe contener caracteres numéricos y alfabéticos."
#: pretix/base/auth.py:202 pretix/base/auth.py:212
#, python-format
@@ -689,7 +689,7 @@ msgstr ""
#: pretix/base/context.py:45
#, python-brace-format
msgid "powered by {name} based on <a {a_attr}>pretix</a>"
msgstr "desarrollado por {name} basado en <a {a_attr}>pretix</a>"
msgstr "hecho posible por {name} basado en <a {a_attr}>pretix</a>"
#: pretix/base/context.py:52
#, python-format
@@ -703,7 +703,7 @@ msgstr "Código fuente"
#: pretix/base/customersso/oidc.py:61
#, python-brace-format
msgid "Configuration option \"{name}\" is missing."
msgstr "La opción de configuración \"{name}\" falta."
msgstr "La opción de configuración \"{name}\" no existe."
#: pretix/base/customersso/oidc.py:69 pretix/base/customersso/oidc.py:74
#, python-brace-format
@@ -723,7 +723,7 @@ msgstr "Proveedor SSO incompatible: \"{error}\"."
#: pretix/base/customersso/oidc.py:111
#, python-brace-format
msgid "You are not requesting \"{scope}\"."
msgstr "No estas solicitando \"{scope}\"."
msgstr "No esta solicitando \"{scope}\"."
#: pretix/base/customersso/oidc.py:117
#, python-brace-format
@@ -731,8 +731,8 @@ msgid ""
"You are requesting scope \"{scope}\" but provider only supports these: "
"{scopes}."
msgstr ""
"Estás solicitando la funcionalidad \"{scope}\", pero el proveedor solo "
"admite estos: {scopes}."
"Está solicitando el scope \"{scope}\", pero el proveedor solo admite estos: "
"{scopes}."
#: pretix/base/customersso/oidc.py:127
#, python-brace-format
@@ -740,7 +740,7 @@ msgid ""
"You are requesting field \"{field}\" but provider only supports these: "
"{fields}."
msgstr ""
"Estás solicitando el campo \"{field}\", pero el proveedor solo admite estos: "
"Está solicitando el campo \"{field}\", pero el proveedor solo admite estos: "
"{fields}."
#: pretix/base/customersso/oidc.py:137
@@ -759,7 +759,7 @@ msgstr ""
#: pretix/presale/views/customer.py:862
#, python-brace-format
msgid "Login was not successful. Error message: \"{error}\"."
msgstr "El inicio de sesión no tuvo éxito. Mensaje de error: \"{error}\"."
msgstr "El inicio de sesión no fue exitoso. Mensaje de error: \"{error}\"."
#: pretix/base/customersso/oidc.py:236
msgid ""
@@ -810,7 +810,7 @@ msgstr "Excel combinado (.xlsx)"
#: pretix/base/exporters/answers.py:54
msgid "Question answer file uploads"
msgstr "Subida de archivo de preguntas y respuestas"
msgstr "Cargas de archivos de respuestas a preguntas"
#: pretix/base/exporters/answers.py:55 pretix/base/exporters/json.py:52
#: pretix/base/exporters/mail.py:53 pretix/base/exporters/orderlist.py:87
@@ -893,12 +893,12 @@ msgstr "Todas las fechas"
#: pretix/base/exporters/customers.py:49 pretix/control/navigation.py:606
#: pretix/control/templates/pretixcontrol/organizers/edit.html:132
msgid "Customer accounts"
msgstr "Cuentas de clientes"
msgstr "Cuenta de cliente"
#: pretix/base/exporters/customers.py:51
msgctxt "export_category"
msgid "Customer accounts"
msgstr "Cuentas de clientes"
msgstr "Cuenta de cliente"
#: pretix/base/exporters/customers.py:52
msgid "Download a spreadsheet of all currently registered customer accounts."
@@ -912,7 +912,7 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/event/checkout_customer.html:36
#: pretix/presale/templates/pretixpresale/organizers/customer_base.html:37
msgid "Customer ID"
msgstr "ID de cliente"
msgstr "ID Cliente"
#: pretix/base/exporters/customers.py:65
#: pretix/control/templates/pretixcontrol/organizers/customer.html:32
@@ -1109,7 +1109,7 @@ msgstr ""
#: pretix/base/exporters/dekodi.py:105
#, python-brace-format
msgid "Event ticket {event}-{code}"
msgstr "Entrada para el evento {event}-{code}"
msgstr "Entrada para evento {event}-{code}"
#: pretix/base/exporters/dekodi.py:234 pretix/base/exporters/invoices.py:74
#: pretix/base/exporters/orderlist.py:128
@@ -1204,12 +1204,12 @@ msgstr "Hora de admisión"
#: pretix/base/exporters/events.py:65 pretix/base/models/event.py:598
#: pretix/base/models/event.py:1484 pretix/control/forms/subevents.py:93
msgid "Start of presale"
msgstr "Inicio de la preventa"
msgstr "Inicio de preventa"
#: pretix/base/exporters/events.py:66 pretix/base/models/event.py:592
#: pretix/base/models/event.py:1478 pretix/control/forms/subevents.py:99
msgid "End of presale"
msgstr "Finalización de la preventa"
msgstr "Finalización de preventa"
#: pretix/base/exporters/events.py:67 pretix/base/exporters/invoices.py:351
#: pretix/base/models/event.py:604 pretix/base/models/event.py:1490
@@ -1219,7 +1219,7 @@ msgstr "Ubicación"
#: pretix/base/exporters/events.py:68 pretix/base/models/event.py:607
#: pretix/base/models/event.py:1493
msgid "Latitude"
msgstr "Latitud"
msgstr "Lalitud"
#: pretix/base/exporters/events.py:69 pretix/base/models/event.py:615
#: pretix/base/models/event.py:1501
@@ -1246,7 +1246,7 @@ msgstr "Comentario interno"
#: pretix/control/templates/pretixcontrol/orders/refunds.html:50
#: pretix/control/templates/pretixcontrol/search/payments.html:93
msgid "Payment provider"
msgstr "Proveedor de pago"
msgstr "Proveedor de pagos"
#: pretix/base/exporters/invoices.py:84 pretix/base/exporters/invoices.py:86
#: pretix/control/forms/filter.py:206 pretix/control/forms/filter.py:1020
@@ -1272,7 +1272,7 @@ msgstr "Todas las facturas"
#: pretix/base/exporters/invoices.py:127
msgid "Download all invoices created by the system as a ZIP file of PDF files."
msgstr ""
"Descargue todas las facturas creadas por el sistema en un archivo ZIP de "
"Descargue todas las facturas creadas por el sistema como un archivo ZIP de "
"archivos PDF."
#: pretix/base/exporters/invoices.py:178
@@ -1446,7 +1446,7 @@ msgstr "País"
#: pretix/base/exporters/invoices.py:211 pretix/base/exporters/invoices.py:337
msgid "Tax ID"
msgstr "CIF"
msgstr "IVA-ID"
#: pretix/base/exporters/invoices.py:212 pretix/base/exporters/invoices.py:220
#: pretix/base/exporters/invoices.py:338 pretix/base/exporters/invoices.py:346
@@ -1490,7 +1490,7 @@ msgstr "Destinatario de la factura:"
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html:83
#: pretix/presale/templates/pretixpresale/event/order.html:307
msgid "Company"
msgstr "Empresa"
msgstr "Compañía"
#: pretix/base/exporters/invoices.py:215 pretix/base/exporters/invoices.py:341
msgid "Street address"
@@ -4567,13 +4567,13 @@ msgstr "Transacción manual"
#, python-format
msgctxt "invoice"
msgid "Tax ID: %s"
msgstr "CIF: %s"
msgstr "IVA-ID: %s"
#: pretix/base/models/invoices.py:191 pretix/base/services/invoices.py:139
#, python-format
msgctxt "invoice"
msgid "VAT-ID: %s"
msgstr "CIF: %s"
msgstr "IVA-ID: %s"
#: pretix/base/models/items.py:93
msgid "Category name"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-16 14:20+0000\n"
"PO-Revision-Date: 2025-01-11 21:00+0000\n"
"PO-Revision-Date: 2025-01-02 07:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/"
"ja/>\n"
@@ -539,7 +539,7 @@ msgstr ""
#: pretix/api/webhooks.py:354
msgid "Shop taken live"
msgstr "ショップが公開中になりました"
msgstr "ショップが営業中になりました"
#: pretix/api/webhooks.py:358
msgid "Shop taken offline"
@@ -2012,7 +2012,7 @@ msgstr "税率{rate}%での税金"
#: pretix/base/exporters/orderlist.py:276
#, python-brace-format
msgid "Net at {rate} % tax"
msgstr "税率{rate}%の税抜価格"
msgstr "税率{rate}%のネット"
#: pretix/base/exporters/orderlist.py:277
#, python-brace-format
@@ -2713,7 +2713,7 @@ msgstr "全て"
#: pretix/base/exporters/orderlist.py:1306 pretix/control/forms/filter.py:1417
msgid "Live"
msgstr "公開中"
msgstr "営業中"
#: pretix/base/exporters/orderlist.py:1315 pretix/control/forms/filter.py:1425
#: pretix/control/templates/pretixcontrol/pdf/index.html:252
@@ -3268,7 +3268,7 @@ msgstr "税率"
#: pretix/base/invoice.py:623
msgctxt "invoice"
msgid "Net"
msgstr "税抜"
msgstr "ネット"
#: pretix/base/invoice.py:624
msgctxt "invoice"
@@ -3320,7 +3320,7 @@ msgstr "残高"
#: pretix/base/invoice.py:769
msgctxt "invoice"
msgid "Net value"
msgstr "税抜価格"
msgstr "純資産価値"
#: pretix/base/invoice.py:770
msgctxt "invoice"
@@ -3362,7 +3362,7 @@ msgstr "デフォルトの請求書レンダラー(ヨーロッパスタイル
#: pretix/base/invoice.py:947
msgctxt "invoice"
msgid "(Please quote at all times.)"
msgstr "問い合わせ時には、この番号をお知らせください。)"
msgstr "すべてのアイテムについて見積もりをしてください。)"
#: pretix/base/invoice.py:994
msgid "Simplified invoice renderer"
@@ -3667,7 +3667,7 @@ msgstr "タグ"
#: pretix/base/modelimport_vouchers.py:334 pretix/base/models/vouchers.py:297
msgid "Shows hidden products that match this voucher"
msgstr "このクーポンに適合する隠された商品を表示する"
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"
@@ -4336,7 +4336,7 @@ msgid ""
"If checked, an event can only be taken live if the property is set. In event "
"series, its always optional to set a value for individual dates"
msgstr ""
"チェックされているとき、プロパティが設定されている場合にのみ、イベントを公開"
"チェックされているとき、プロパティが設定されている場合にのみ、イベントを営業"
"中にすることができます。イベントシリーズでは、個々の日付に値を設定することは"
"常にオプションです"
@@ -4552,7 +4552,7 @@ msgstr "アドオンカテゴリ"
#: pretix/base/models/items.py:222 pretix/base/models/items.py:278
msgid "Disable product for this date"
msgstr "この日付の製品を無効にする"
msgstr "この日付の製品を無効にしてください"
#: pretix/base/models/items.py:226 pretix/base/models/items.py:282
#: pretix/base/models/items.py:560
@@ -4588,7 +4588,7 @@ msgstr "申し訳ございません。情報が利用できません"
#: pretix/base/models/items.py:452 pretix/base/models/items.py:772
msgid "Don't use re-usable media, use regular one-off tickets"
msgstr "再利用可能なメディアを使用せず、通常の一回りのチケットを使用してください"
msgstr "再利用可能なメディアを使用せず、通常の一回りのチケットを使用してください"
#: pretix/base/models/items.py:453
msgid "Require an existing medium to be re-used"
@@ -4678,11 +4678,11 @@ msgstr "このイベントで待ちリストが有効になっている場合の
#: pretix/base/models/items.py:544 pretix/base/settings.py:1350
#: pretix/control/forms/event.py:1677
msgid "Show number of tickets left"
msgstr "チケットの残り枚数を表示す"
msgstr "チケットの残り枚数を表示します"
#: pretix/base/models/items.py:545
msgid "Publicly show how many tickets are still available."
msgstr "利用可能なチケットの数を公表します。"
msgstr "公に利用可能なチケットの数を表示してください。"
#: pretix/base/models/items.py:552 pretix/control/forms/item.py:622
msgid "Product picture"
@@ -4822,7 +4822,7 @@ msgstr ""
#: pretix/base/models/items.py:672
msgid "Only sell tickets for this product on the selected sales channels."
msgstr "この製品のチケットは選択した販売チャネルでのみ販売します。"
msgstr "この製品のチケットは選択した販売チャネルでのみ販売してください。"
#: pretix/base/models/items.py:677
msgid ""
@@ -5362,7 +5362,7 @@ msgstr "総容量"
#: pretix/base/models/items.py:2036 pretix/control/forms/item.py:448
msgid "Leave empty for an unlimited number of tickets."
msgstr "空欄にすると、チケットの数が無制限になります。"
msgstr "チケットの数に制限はありません。"
#: pretix/base/models/items.py:2040 pretix/base/models/orders.py:1487
#: pretix/base/models/orders.py:2982
@@ -5638,7 +5638,7 @@ msgstr "支払い済みの注文"
#: pretix/base/models/orders.py:418
msgid "canceled (paid fee)"
msgstr "キャンセル済み(支払い済み手数料"
msgstr "キャンセル(料金支払い済み)"
#: pretix/base/models/orders.py:1031
msgid ""
@@ -6245,11 +6245,11 @@ msgid ""
"option after consulting a tax counsel. No warranty given for correct tax "
"calculation. USE AT YOUR OWN RISK."
msgstr ""
"非推奨です。ほとんどのイベントはリバースチャージ制度の対象とならず、イベント"
"の場所が課税の場所となります。このオプションを有効にすると、EU外のすべての顧"
"客および有効なEUのVAT番号を入力した異なるEU諸国の事業顧客に対してVATを請求し"
"ません。税務顧問と相談した後にのみこのオプションを有効にしてください。正しい"
"税金の計算については保証されません。自己責任でご利用ください。"
"非推奨です。ほとんどのイベントは逆転納税の対象とならないため、課税の場所はイ"
"ベントの場所となります。このオプションを有効にすると、EU外のすべての顧客およ"
"び有効なEUのVAT番号を入力した異なるEU諸国の事業顧客に対してVATを請求しません"
"。税務顧問と相談した後にのみこのオプションを有効にしてください。正しい税金の"
"計算については保証されません。自己責任でご利用ください。"
#: pretix/base/models/tax.py:374 pretix/plugins/stripe/payment.py:299
msgid "Merchant country"
@@ -6362,8 +6362,8 @@ msgstr "このバウチャーが有効になると、影響を受ける製品の
msgid ""
"If activated, a holder of this voucher code can buy tickets, even if there "
"are none left."
msgstr "このバウチャーコードを持っている人は、売り切れた場合でも、チケットを購入す"
"ことができます。"
msgstr "このバウチャーコードを持っている人は、有効になった場合でも、チケットを購入す"
"ことができます。"
#: pretix/base/models/vouchers.py:254 pretix/control/forms/vouchers.py:69
msgid ""
@@ -6587,7 +6587,7 @@ msgstr "購入した製品"
#: pretix/base/services/placeholders.py:393
#: pretix/base/templates/pretixbase/email/order_details.html:147
msgid "View order details"
msgstr "注文の詳細を表示す"
msgstr "注文の詳細を表示します"
#: pretix/base/notifications.py:234
#, python-brace-format
@@ -6655,7 +6655,7 @@ msgstr "払い戻しをリクエストしました"
#: pretix/base/notifications.py:300
#, python-brace-format
msgid "You have been requested to issue a refund for {order.code}."
msgstr "{order.code}の返金手続きがリクエストされました。"
msgstr "{order.code}の返金手続きを依頼されました。"
#: pretix/base/payment.py:86
msgctxt "payment"
@@ -7048,7 +7048,7 @@ msgstr "アドオンを含む価格"
#: pretix/base/services/placeholders.py:669
#: pretix/base/services/placeholders.py:678 pretix/control/views/event.py:797
msgid "John Doe"
msgstr "山田 太郎"
msgstr "John Doe"
#: pretix/base/pdf.py:177
#: pretix/control/templates/pretixcontrol/order/index.html:549
@@ -7408,7 +7408,7 @@ msgstr "イベント終了"
#: pretix/base/reldate.py:37
msgid "Event admission"
msgstr "イベントの入場"
msgstr "イベントの入場"
#: pretix/base/reldate.py:38
msgid "Presale start"
@@ -7635,7 +7635,7 @@ msgstr ""
#: pretix/base/services/cart.py:175
#, python-format
msgid "This voucher code can only be redeemed %d more times."
msgstr "このバウチャーコードは、あと%d回しか引き換えることができません。"
msgstr "このバウチャーコードは、もう%d回しか引き換えることができません。"
#: pretix/base/services/cart.py:176
msgid ""
@@ -8621,7 +8621,7 @@ msgstr "プラグイン: %s"
#: pretix/base/services/vouchers.py:56 pretix/control/logdisplay.py:484
#, python-brace-format
msgid "The voucher has been sent to {recipient}."
msgstr "バウチャーは{recipient}あてに送信されました。"
msgstr "そのバウチャーは{recipient}に送信されました。"
#: pretix/base/settings.py:125
msgid "Allow usage of restricted plugins"
@@ -9238,7 +9238,7 @@ msgstr "開始日を表示します"
#: pretix/base/settings.py:1025
msgid "Show the presale start date before presale has started."
msgstr "プレセールが始まる前に、プレセール開始日を表示します。"
msgstr "プレセールの開始日を表示する前に、プレセール開始される前です。"
#: pretix/base/settings.py:1040 pretix/base/settings.py:1051
msgid "Do not generate invoices"
@@ -9467,7 +9467,7 @@ msgstr "全ての売り切れ商品を非表示にしてください"
#: pretix/base/settings.py:1351 pretix/control/forms/event.py:1678
msgid "Publicly show how many tickets of a certain type are still available."
msgstr "特定の種類のチケットの残り枚数を公開する。"
msgstr "特定の種類のチケットの残り枚数を公開してください。"
#: pretix/base/settings.py:1360
msgid "Ask search engines not to index the ticket shop"
@@ -9696,8 +9696,8 @@ msgid ""
"from the page as soon as they clicked a link in the email. Does not affect "
"orders performed through other sales channels."
msgstr ""
"オンになっている場合、チケットは購入直後に直接ダウンロードできません。ファイ"
"ルサイズが大きくなければ、支払い確認のメールに添付され、顧客はメール内のリン"
"電源が入っている場合、チケットは購入直後に直接ダウンロードできません。ファイ"
"ルサイズが大きくない場合、支払い確認のメールに添付され、顧客はメール内のリン"
"クをクリックするとすぐにページからダウンロードできます。他の販売チャネルを通"
"じて行われた注文には影響しません。"
@@ -10162,7 +10162,7 @@ msgstr ""
#: pretix/control/templates/pretixcontrol/event/mail.html:39
#: pretix/control/templates/pretixcontrol/organizers/mail.html:40
msgid "Sender address"
msgstr "送信元のアドレス"
msgstr "送り主の住所"
#: pretix/base/settings.py:2137 pretix/control/forms/mailsetup.py:36
#: pretix/control/forms/mailsetup.py:109
@@ -10312,8 +10312,8 @@ msgstr ""
"こんにちは、\n"
"\n"
"{event}の注文を承りました。\n"
"イベント主催者の承認が必要な商品をご注文いただいたため、\n"
"次のメールをお待ちいただくようお願い申し上げます。\n"
"イベント主催者の承認が必要な商品をご注文いただいたため、次のメールをお待ちい"
"ただくようお願い申し上げます。\n"
"\n"
"注文の詳細の変更と確認は、以下のURLから可能です。\n"
"{url}\n"
@@ -11460,7 +11460,7 @@ msgstr "氏Surname/Family Name"
#: pretix/base/settings.py:3685
msgctxt "person_name_sample"
msgid "John"
msgstr "太郎"
msgstr "ジョン"
#: pretix/base/settings.py:3467 pretix/base/settings.py:3483
#: pretix/base/settings.py:3499 pretix/base/settings.py:3515
@@ -11469,7 +11469,7 @@ msgstr "太郎"
#: pretix/base/settings.py:3655 pretix/base/settings.py:3686
msgctxt "person_name_sample"
msgid "Doe"
msgstr "山田"
msgstr "Doe"
#: pretix/base/settings.py:3473 pretix/base/settings.py:3489
#: pretix/base/settings.py:3521 pretix/base/settings.py:3640
@@ -11497,7 +11497,7 @@ msgstr "ミドルネーム"
#: pretix/control/forms/organizer.py:651
msgctxt "person_name_sample"
msgid "John Doe"
msgstr "山田 太郎"
msgstr "John Doe"
#: pretix/base/settings.py:3593
msgid "Calling name"
@@ -11831,7 +11831,7 @@ msgstr "ここをクリックして、通知設定を表示および変更"
#: pretix/base/templates/pretixbase/email/notification.html:60
msgid "Click here disable all notifications immediately."
msgstr "ここをクリックしてすべての通知を直ちに無効にする。"
msgstr "ここをクリックしてすぐにすべての通知を無効にしてください。"
#: pretix/base/templates/pretixbase/email/notification.txt:15
msgid "Click here to view and change your notification settings:"
@@ -12183,7 +12183,7 @@ msgstr "あなたのイベントは終了します"
#: pretix/base/timeline.py:76
msgctxt "timeline"
msgid "Admissions for your event start"
msgstr "イベントの入場始まります"
msgstr "イベントの入場始まります"
#: pretix/base/timeline.py:84
msgctxt "timeline"
@@ -13214,7 +13214,7 @@ msgstr "支払い期限"
#: pretix/control/forms/filter.py:1196 pretix/control/forms/filter.py:1671
msgid "Shop live and presale running"
msgstr "公開中かつ予約販売が開催中です"
msgstr "営業中かつプレセールが開催中です"
#: pretix/control/forms/filter.py:1197 pretix/control/forms/filter.py:2013
msgid "Inactive"
@@ -13314,7 +13314,7 @@ msgstr "有効なメンバーシップがあります"
#: pretix/control/forms/filter.py:1670
msgid "Shop live"
msgstr "ショップが公開中"
msgstr "お店が営業中"
#: pretix/control/forms/filter.py:1672
msgid "Shop not live"
@@ -13657,8 +13657,8 @@ msgid ""
"as-a-Service company)."
msgstr ""
"私は他のイベント主催者のチケットを販売するためにPretixを使用しています"
"チケット販売会社)、または他の人にoretixの機能を提供していますSaaS会社"
")。"
"チケット販売会社)、または他の人にPretixの機能を提供していますSoftware-"
"as-a-Service会社)。"
#: pretix/control/forms/global_settings.py:162
msgid "I'm not sure which option applies."
@@ -14325,7 +14325,7 @@ msgstr ""
#: pretix/control/forms/orders.py:714 pretix/plugins/sendmail/forms.py:196
msgid "Attach tickets"
msgstr "チケットを添付す"
msgstr "チケットを添付します"
#: pretix/control/forms/orders.py:715 pretix/plugins/sendmail/forms.py:197
msgid ""
@@ -14347,8 +14347,8 @@ msgstr "受取人"
#: pretix/control/forms/orders.py:777
msgid ""
"Cancel the order. All tickets will no longer work. This can not be reverted."
msgstr "注文をキャンセルす。すべてのチケットはもはや有効ではありません。これは元"
"戻すことができません。"
msgstr "注文をキャンセルします。すべてのチケットはもはや有効ではありません。これは元"
"戻すことができません。"
#: pretix/control/forms/orders.py:778
msgid ""
@@ -15525,8 +15525,8 @@ msgstr "未確認のタイプの電子メールが送信されました。"
msgid ""
"The email has been sent without attached tickets since they would have been "
"too large to be likely to arrive."
msgstr "チケットサイズが大きすぎて届かない可能性があるため、チケットを添付せずにメ"
"ールが送信されました。"
msgstr "チケットを添付すると、サイズが大きすぎて届可能性が低いため、メールはチケッ"
"トを添付せずに送信されました。"
#: pretix/control/logdisplay.py:422
msgid "A custom email has been sent."
@@ -15753,7 +15753,7 @@ msgstr "そのバウチャーは作成されました。"
#: pretix/control/logdisplay.py:485
msgid "The voucher has been created and sent to a person on the waiting list."
msgstr "バウチャー作成され、空席待ちリストの人に送信されました。"
msgstr "そのバウチャー作成され、空席待ちリストの人に送信されました。"
#: pretix/control/logdisplay.py:486
msgid ""
@@ -16528,8 +16528,8 @@ msgstr "お帰りなさい!"
msgid ""
"You configured your account to require authentication with a second medium, "
"e.g. your phone. Please enter your verification code here:"
msgstr "アカウントの設定で、第二の認証手段(例:携帯電話)が必要になるように構成され"
"ています。こちらに確認コードを入力してください:"
msgstr "アカウントの設定で、第二の認証手段(例:携帯電話)が必要になるように構成しま"
"した。こちらに確認コードを入力してください:"
#: pretix/control/templates/pretixcontrol/auth/login_2fa.html:14
msgid "Token"
@@ -16835,7 +16835,7 @@ msgstr "セキュリティ上の理由から、続行する前にパスワード
#: pretix/control/templates/pretixcontrol/base.html:454
#, python-format
msgid "Times displayed in %(tz)s"
msgstr "表示される時間帯 %(tz)s"
msgstr "%(tz)sで表示される時間"
#: pretix/control/templates/pretixcontrol/base.html:460
msgid "running in development mode"
@@ -18253,7 +18253,7 @@ msgstr "チケットショップを公開するためには、まず以下の問
#: pretix/control/templates/pretixcontrol/event/live.html:51
#: pretix/control/templates/pretixcontrol/event/live.html:65
msgid "Go live"
msgstr "オンラインにする"
msgstr "営業を開始します"
#: pretix/control/templates/pretixcontrol/event/live.html:59
msgid "If you want to, you can publish your ticket shop now."
@@ -18420,7 +18420,7 @@ msgstr "支払い済みの注文"
#: pretix/control/templates/pretixcontrol/event/mail.html:96
msgid "Free order"
msgstr "無料の注文"
msgstr "自由な順序"
#: pretix/control/templates/pretixcontrol/event/mail.html:99
#: pretix/control/templates/pretixcontrol/order/index.html:248
@@ -18446,7 +18446,7 @@ msgstr "注文のカスタム電子メール"
#: pretix/control/templates/pretixcontrol/event/mail.html:120
msgid "Reminder to download tickets"
msgstr "チケットダウンロードをリマインドする"
msgstr "チケットダウンロードすることをお知らせします"
#: pretix/control/templates/pretixcontrol/event/mail.html:123
msgid "Order approval process"
@@ -20044,9 +20044,8 @@ msgid ""
"tickets\" or \"buy 2 tickets, get 1 free\"."
msgstr ""
"自動割引を使用すると、特定の条件に基づいて顧客の購入に自動的に割引を適用する"
"ことができます。たとえば、「3枚以上のチケットを購入すると20パーセント割引」や"
"「2枚のチケットを購入すると1枚無料」などのグループ割引を作成することができま"
"す。"
"ことができます。たとえば、「3枚以上のチケットを購入すると20割引」や「2枚の"
"チケットを購入すると1枚無料」などのグループ割引を作成することができます。"
#: pretix/control/templates/pretixcontrol/items/discounts.html:15
msgid ""
@@ -20159,7 +20158,7 @@ msgstr "新しい商品を作成"
#: pretix/control/templates/pretixcontrol/items/index.html:101
msgid "Personalized admission ticket"
msgstr "記名式入場券"
msgstr "サンプル用チケット"
#: pretix/control/templates/pretixcontrol/items/index.html:103
msgid "Admission ticket without personalization"
@@ -20197,7 +20196,7 @@ msgstr "<strong>plus</strong> %(rate)s%% %(taxname)s"
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html:409
#, python-format
msgid "incl. %(rate)s%% %(taxname)s"
msgstr "%(taxname)s%(rate)s パーセントを含む"
msgstr "%(rate)s%% %(taxname)sを含む"
#: pretix/control/templates/pretixcontrol/items/question.html:6
#: pretix/control/templates/pretixcontrol/items/question.html:9
@@ -20405,7 +20404,7 @@ msgstr "このクォータの実際の結果を変更する可能性があるプ
#: pretix/control/templates/pretixcontrol/items/quota.html:86
#, python-format
msgid "This quota is currently overbooked by %(num)s tickets."
msgstr "このクォータは現在%(num)s枚のチケット過剰予約されています。"
msgstr "このクォータは現在%(num)s枚のチケット過剰予約されています。"
#: pretix/control/templates/pretixcontrol/items/quota.html:93
msgid ""
@@ -21126,7 +21125,7 @@ msgstr "エントリーのスキャン: %(date)s"
#: pretix/control/templates/pretixcontrol/order/index.html:422
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html:54
msgid "Voucher code used:"
msgstr "使用するバウチャーコード:"
msgstr "割引コード使用済:"
#: pretix/control/templates/pretixcontrol/order/index.html:424
#, python-format
@@ -21887,7 +21886,7 @@ msgstr "保留中(確認済み)"
#: pretix/control/templates/pretixcontrol/orders/fragment_order_status.html:26
#: pretix/presale/templates/pretixpresale/event/fragment_order_status.html:19
msgid "Canceled (paid fee)"
msgstr "キャンセル済み(支払い済み手数料"
msgstr "キャンセル(料金支払い済み)"
#: pretix/control/templates/pretixcontrol/orders/import_process.html:6
#: pretix/control/templates/pretixcontrol/orders/import_process.html:8
@@ -22487,7 +22486,7 @@ msgstr "デバイスを接続"
#: pretix/control/templates/pretixcontrol/organizers/devices.html:81
msgid "Hardware model"
msgstr "ハードウェアの機種"
msgstr "ハードウェアモデル"
#: pretix/control/templates/pretixcontrol/organizers/devices.html:139
msgid "Not yet initialized"
@@ -25010,7 +25009,7 @@ msgstr "選択したリストは削除されました。"
#: pretix/control/views/dashboards.py:115
msgid "Attendees (ordered)"
msgstr "参加者(注文済み"
msgstr "出席者(順番に"
#: pretix/control/views/dashboards.py:125
msgid "Attendees (paid)"
@@ -25048,11 +25047,11 @@ msgstr "ここをクリックして変更してください"
#: pretix/control/views/dashboards.py:273
msgid "live"
msgstr "公開中"
msgstr "営業中"
#: pretix/control/views/dashboards.py:274
msgid "live and in test mode"
msgstr "公開中かつテストモード"
msgstr "営業中かつテストモード"
#: pretix/control/views/dashboards.py:275
msgid "not yet public"
@@ -25189,7 +25188,7 @@ msgstr "無効なチケット出力タイプをリクエストしました。"
#: pretix/control/views/event.py:972
msgid "Your shop is live now!"
msgstr "あなたのショップは公開中です!"
msgstr "あなたのショップは営業中です!"
#: pretix/control/views/event.py:980
msgid "We've taken your shop down. You can re-enable it whenever you want!"
@@ -25550,7 +25549,7 @@ msgstr "検証コードが間違っています。もう一度お試しくださ
#: pretix/control/views/mailsetup.py:221
msgid "Sender address verification"
msgstr "送信元のアドレス確認"
msgstr "送り主の住所確認"
#: pretix/control/views/mailsetup.py:277
#, python-format
@@ -27349,7 +27348,7 @@ msgstr "払い戻しをエクスポート"
#: pretix/plugins/banktransfer/signals.py:123
msgid "The invoice was sent to the designated email address."
msgstr "請求書は指定されたメールアドレスあてに送信されました。"
msgstr "請求書は指定されたメールアドレスに送信されました。"
#: pretix/plugins/banktransfer/signals.py:132
#, python-brace-format
@@ -29515,7 +29514,7 @@ msgstr "ストライプアカウント"
#: pretix/plugins/stripe/payment.py:261
msgctxt "stripe"
msgid "Live"
msgstr "公開中"
msgstr "営業中"
#: pretix/plugins/stripe/payment.py:262
msgctxt "stripe"
@@ -30754,7 +30753,7 @@ msgstr "このショップは現在あなたとあなたのチームだけが見
#: pretix/presale/templates/pretixpresale/event/base.html:34
msgid "Take it live now"
msgstr "今すぐ公開する"
msgstr "今すぐ営業を始める"
#: pretix/presale/templates/pretixpresale/event/base.html:44
#: pretix/presale/templates/pretixpresale/event/base.html:105
@@ -30819,10 +30818,9 @@ msgid ""
"href=\"%(time_machine_link)s\"><span class=\"fa fa-clock-o\" aria-"
"hidden=\"true\"></span>time machine</a>."
msgstr ""
"<a href=\"%(time_machine_link)s\"><span class=\"fa fa-clock-o\" aria-hidden="
"\"true\""
"></span>タイムマシン</a>を有効にすることで、異なる時刻におけるあなたのショッ"
"プを表示することができます。"
"異なる時点であなたのショップを表示するには、<a href=\"%(time_machine_link)s\""
"><span class=\"fa fa-clock-o\" aria-hidden=\"true\""
"></span>タイムマシン</a>を有効にすることができます。"
#: pretix/presale/templates/pretixpresale/event/base.html:163
#: pretix/presale/templates/pretixpresale/event/base.html:210
@@ -31011,7 +31009,7 @@ msgstr "注文を確定"
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html:209
msgid "Submit registration"
msgstr "予約を送信する"
msgstr "登録を送信してください"
#: pretix/presale/templates/pretixpresale/event/checkout_customer.html:18
msgid "Log in with a customer account"
@@ -31345,7 +31343,7 @@ msgstr "<strong>plus</strong> %(rate)s%% %(name)s"
#: pretix/presale/templates/pretixpresale/event/voucher.html:366
#, python-format
msgid "incl. %(rate)s%% %(name)s"
msgstr "%(name)s %(rate)sパーセントを含む"
msgstr "%(rate)s%% %(name)s を含む"
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:200
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:207
@@ -31566,7 +31564,7 @@ msgstr[0] "%(num)s 個の製品"
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html:465
#, python-format
msgid "incl. %(tax_sum)s taxes"
msgstr "%(tax_sum)sを含む"
msgstr "incl. %(tax_sum)s 税金"
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html:482
#, python-format
@@ -31596,7 +31594,7 @@ msgstr "チェックアウトを続行します"
#: pretix/presale/templates/pretixpresale/event/fragment_cart_box.html:63
msgid "Empty cart"
msgstr "カートを空にする"
msgstr "空のカート"
#: pretix/presale/templates/pretixpresale/event/fragment_cart_box.html:68
#: pretix/presale/templates/pretixpresale/event/index.html:236
@@ -31763,7 +31761,7 @@ msgstr "注文が確定しました"
#: pretix/presale/templates/pretixpresale/event/fragment_downloads.html:14
msgid "Please check your email account, we've sent you your tickets."
msgstr "メールが届いているかご確認ください。チケットを送信しました。"
msgstr "メールアカウントを確認してください。チケットを送信しました。"
#: pretix/presale/templates/pretixpresale/event/fragment_downloads.html:16
msgid "Please check your email account, we've sent you an email."
@@ -31878,7 +31876,7 @@ msgstr "%(item)sのフルサイズの画像を表示"
#: pretix/presale/templates/pretixpresale/event/voucher.html:359
#, python-format
msgid "%(value)s incl. taxes"
msgstr "税込%(value)s"
msgstr "%(value)s 税込"
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html:197
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html:350
@@ -32093,12 +32091,12 @@ msgid ""
"your order to be sent to you again."
msgstr ""
"注文の状況や詳細を確認または変更したい場合は、注文手続き中に当社から送られた"
"メールのリンクをクリックしてください。リンクが見つからない場合は、つぎのボタ"
"メールのリンクをクリックしてください。リンクが見つからない場合は、以下のボタ"
"ンをクリックして、注文のリンクを再度送付してもらうこともできます。"
#: pretix/presale/templates/pretixpresale/event/index.html:256
msgid "Resend order link"
msgstr "注文リンクを再送する"
msgstr "注文リンクを再送してください"
#: pretix/presale/templates/pretixpresale/event/offline.html:4
#: pretix/presale/templates/pretixpresale/event/offline.html:8
@@ -32244,7 +32242,7 @@ msgstr "注文された商品を変更します"
#: pretix/presale/templates/pretixpresale/event/order.html:290
#: pretix/presale/templates/pretixpresale/event/position.html:34
msgid "Change details"
msgstr "詳細を変更"
msgstr "変更の詳細"
#: pretix/presale/templates/pretixpresale/event/order.html:262
msgid ""
@@ -32384,7 +32382,7 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/event/order.html:467
msgid "You can cancel this order using the following button."
msgstr "つぎのボタンを使用して、この注文をキャンセルすることができます。"
msgstr "のボタンを使用して、この注文をキャンセルすることができます。"
#: pretix/presale/templates/pretixpresale/event/order_cancel.html:11
#, python-format
@@ -32549,7 +32547,7 @@ msgstr "チケットを変更"
msgid ""
"If you want to make changes to the components of your ticket, you can click "
"on the following button."
msgstr "チケットのコンポーネントを変更したい場合は、つぎのボタンをクリックしてくださ"
msgstr "チケットのコンポーネントを変更したい場合は、以下のボタンをクリックしてくださ"
"い。"
#: pretix/presale/templates/pretixpresale/event/position.html:73
@@ -32589,7 +32587,7 @@ msgstr "支払い済みの注文"
#: pretix/presale/templates/pretixpresale/event/resend_link.html:4
#: pretix/presale/templates/pretixpresale/event/resend_link.html:11
msgid "Resend order links"
msgstr "注文リンクを再送する"
msgstr "注文リンクを再送してください"
#: pretix/presale/templates/pretixpresale/event/resend_link.html:15
msgid ""
@@ -33083,7 +33081,7 @@ msgstr "これからのイベントを見る"
#: pretix/presale/templates/pretixpresale/organizers/index.html:169
msgid "No public upcoming events found."
msgstr "今後一般に公表されるイベントが見つかりません。"
msgstr "公開される今後のイベントが見つかりません。"
#: pretix/presale/templates/pretixpresale/organizers/index.html:169
msgid "Show past events"
@@ -33299,7 +33297,7 @@ msgstr "この注文については、チケットのダウンロードはまだ
#: pretix/presale/views/order.py:1118
msgid "Please click the link we sent you via email to download your tickets."
msgstr "メールで送られたリンクをクリックしてチケットをダウンロードしてください。"
msgstr "メールで送られたリンクをクリックしてチケットをダウンロードしてください。"
#: pretix/presale/views/order.py:1601
#, python-brace-format

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-16 14:20+0000\n"
"PO-Revision-Date: 2025-01-11 21:00+0000\n"
"PO-Revision-Date: 2024-12-31 18:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"Language-Team: Korean <https://translate.pretix.eu/projects/pretix/pretix/ko/"
">\n"
@@ -170,8 +170,10 @@ msgid "Allowed URIs list, space separated"
msgstr "허용된 URI 리스트, 스페이스로 구분"
#: pretix/api/models.py:47
#, fuzzy
#| msgid "Allowed URIs list, space separated"
msgid "Allowed Post Logout URIs list, space separated"
msgstr "공백으로 구분된 허용된 로그아웃 후 URI 목록"
msgstr "허용된 URI 리스트, 스페이스로 구분"
#: pretix/api/models.py:51 pretix/base/models/customers.py:406
#: pretix/plugins/paypal/payment.py:113 pretix/plugins/paypal2/payment.py:110
@@ -17952,8 +17954,10 @@ msgid "December"
msgstr ""
#: pretix/control/templates/pretixcontrol/global_sysreport.html:32
#, fuzzy
#| msgid "Generate tickets"
msgid "Generate report"
msgstr "보고서를 생성"
msgstr "티켓 생성"
#: pretix/control/templates/pretixcontrol/global_update.html:7
msgid "Update check results"
@@ -19891,8 +19895,11 @@ msgstr ""
#: pretix/control/templates/pretixcontrol/orders/bulk_action.html:5
#: pretix/control/templates/pretixcontrol/orders/bulk_action.html:7
#, fuzzy
#| msgid "1 order"
#| msgid_plural "%(s)s orders"
msgid "Modify orders"
msgstr "주문을 수정"
msgstr "%(s)s개 주문"
#: pretix/control/templates/pretixcontrol/orders/bulk_action.html:12
#, python-format
@@ -22084,8 +22091,11 @@ msgid "Begin"
msgstr ""
#: pretix/control/templates/pretixcontrol/subevents/index.html:176
#, fuzzy
#| msgid "1 order"
#| msgid_plural "%(s)s orders"
msgid "Show orders"
msgstr "주문을 표시"
msgstr "%(s)s개 주문"
#: pretix/control/templates/pretixcontrol/subevents/index.html:187
msgctxt "subevent"
@@ -26618,8 +26628,10 @@ msgid ""
msgstr ""
#: pretix/plugins/returnurl/views.py:37
#, fuzzy
#| msgid "Redirection URIs"
msgid "Base redirection URLs"
msgstr "기본 리다이렉션 URL"
msgstr "리다이렉션URI"
#: pretix/plugins/returnurl/views.py:38
msgid ""
@@ -27303,8 +27315,10 @@ msgid "Publishable key"
msgstr ""
#: pretix/plugins/stripe/payment.py:280
#, fuzzy
#| msgid "Generate tickets"
msgid "Generate API keys"
msgstr "API 키 생성"
msgstr "티켓 생성"
#: pretix/plugins/stripe/payment.py:282
msgid ""
@@ -28481,8 +28495,11 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/event/base.html:127
#: pretix/presale/templates/pretixpresale/event/base.html:201
#, fuzzy
#| msgid "This ticket has been used once."
#| msgid_plural "This ticket has been used %(count)s times."
msgid "This ticket shop is currently in test mode."
msgstr "이 티켓 상점은 현재 테스트 모드입니다."
msgstr "티켓이 %(count)s회 사용되었습니다."
#: pretix/presale/templates/pretixpresale/event/base.html:130
#: pretix/presale/templates/pretixpresale/event/base.html:204
@@ -29744,8 +29761,11 @@ msgid "Shop offline"
msgstr ""
#: pretix/presale/templates/pretixpresale/event/offline.html:9
#, fuzzy
#| msgid "This ticket has been used once."
#| msgid_plural "This ticket has been used %(count)s times."
msgid "This ticket shop is currently turned off."
msgstr "이 티켓 상점은 현재 비활성화되어 있습니다."
msgstr "티켓이 %(count)s회 사용되었습니다."
#: pretix/presale/templates/pretixpresale/event/offline.html:10
msgid "It is only accessible to authenticated team members."
@@ -30188,8 +30208,11 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/event/position_modify.html:5
#: pretix/presale/templates/pretixpresale/event/position_modify.html:8
#, fuzzy
#| msgid "1 order"
#| msgid_plural "%(s)s orders"
msgid "Modify ticket"
msgstr "티켓 수정"
msgstr "%(s)s개 주문"
#: pretix/presale/templates/pretixpresale/event/resend_link.html:4
#: pretix/presale/templates/pretixpresale/event/resend_link.html:11

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-16 14:20+0000\n"
"PO-Revision-Date: 2025-01-10 20:00+0000\n"
"Last-Translator: David Vaz <davidmgvaz@gmail.com>\n"
"PO-Revision-Date: 2024-12-13 08:00+0000\n"
"Last-Translator: Vasco Baleia <vb2003.12@gmail.com>\n"
"Language-Team: Portuguese (Portugal) <https://translate.pretix.eu/projects/"
"pretix/pretix/pt_PT/>\n"
"Language: pt_PT\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.9.2\n"
"X-Generator: Weblate 5.8.4\n"
#: pretix/_base_settings.py:79
msgid "English"
@@ -34876,7 +34876,7 @@ msgstr "Nenhuma associação é armazenada em sua conta."
#| msgid "+ %(count)s invited"
msgid "%(counter)s item"
msgid_plural "%(counter)s items"
msgstr[0] "+%(count)s convidado"
msgstr[0] "+%(count)s convidados"
msgstr[1] "+%(count)s convidados"
#: pretix/presale/templates/pretixpresale/organizers/customer_orders.html:78

View File

@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-16 14:20+0000\n"
"PO-Revision-Date: 2025-01-04 01:00+0000\n"
"PO-Revision-Date: 2024-12-30 22:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"Language-Team: Turkish <https://translate.pretix.eu/projects/pretix/pretix/"
"tr/>\n"
@@ -1041,8 +1041,10 @@ msgid "Name"
msgstr "Ad"
#: pretix/base/exporters/customers.py:77 pretix/base/models/customers.py:99
#, fuzzy
#| msgid "This account is inactive."
msgid "Account active"
msgstr "Hesap aktif"
msgstr "Bu hesap aktif değildir."
#: pretix/base/exporters/customers.py:78 pretix/base/models/customers.py:100
#, fuzzy

View File

@@ -8,16 +8,16 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-12-16 14:20+0000\n"
"PO-Revision-Date: 2025-01-04 19:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"Language-Team: Chinese (Traditional Han script) <https://translate.pretix.eu/"
"projects/pretix/pretix/zh_Hant/>\n"
"PO-Revision-Date: 2024-01-22 17:08+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: Chinese (Traditional) <https://translate.pretix.eu/projects/"
"pretix/pretix/zh_Hant/>\n"
"Language: zh_Hant\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 5.9.2\n"
"X-Generator: Weblate 5.3.1\n"
#: pretix/_base_settings.py:79
msgid "English"
@@ -37,11 +37,11 @@ msgstr "阿拉伯語"
#: pretix/_base_settings.py:83
msgid "Basque"
msgstr "巴斯克語"
msgstr ""
#: pretix/_base_settings.py:84
msgid "Catalan"
msgstr "嘉泰羅尼亞語"
msgstr ""
#: pretix/_base_settings.py:85
msgid "Chinese (simplified)"
@@ -85,7 +85,7 @@ msgstr "希臘語"
#: pretix/_base_settings.py:95
msgid "Indonesian"
msgstr "印尼語"
msgstr ""
#: pretix/_base_settings.py:96
msgid "Italian"
@@ -97,7 +97,7 @@ msgstr "拉脫維亞語"
#: pretix/_base_settings.py:98
msgid "Norwegian Bokmål"
msgstr "挪威博克馬爾語"
msgstr ""
#: pretix/_base_settings.py:99
msgid "Polish"
@@ -121,11 +121,11 @@ msgstr "俄語"
#: pretix/_base_settings.py:104
msgid "Slovak"
msgstr "斯洛伐克語"
msgstr ""
#: pretix/_base_settings.py:105
msgid "Swedish"
msgstr "瑞典語"
msgstr ""
#: pretix/_base_settings.py:106
msgid "Spanish"
@@ -582,8 +582,10 @@ msgid "Customer account changed"
msgstr "客戶帳戶電子郵件更改"
#: pretix/api/webhooks.py:394
#, fuzzy
#| msgid "The customer account has been anonymized."
msgid "Customer account anonymized"
msgstr "客戶帳戶已匿名化"
msgstr "客戶帳戶已匿名化"
#: pretix/base/addressvalidation.py:100 pretix/base/addressvalidation.py:103
#: pretix/base/addressvalidation.py:108 pretix/base/forms/questions.py:960
@@ -645,7 +647,7 @@ msgstr "密碼"
#: pretix/base/auth.py:176 pretix/base/auth.py:183
msgid "Your password must contain both numeric and alphabetic characters."
msgstr "您的密碼必須包含數字和字母字元。"
msgstr ""
#: pretix/base/auth.py:202 pretix/base/auth.py:212
#, python-format
@@ -653,7 +655,7 @@ 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] "您的密碼可能與您之前的 %(history_length)s密碼之一不同。"
msgstr[0] ""
#: pretix/base/channels.py:168
msgid "Online shop"
@@ -661,13 +663,13 @@ msgstr "網上商店"
#: 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銷售渠道沒有內建功能但可用於自定義整合。"
msgstr ""
#: pretix/base/context.py:45
#, python-brace-format
@@ -2887,7 +2889,7 @@ msgstr "優惠券代碼"
#: pretix/base/forms/__init__.py:118
#, python-brace-format
msgid "You can use {markup_name} in this field."
msgstr "您可以在此欄位中使用{markup_name}。"
msgstr ""
#: pretix/base/forms/__init__.py:178
#, python-format
@@ -3087,8 +3089,6 @@ msgid ""
"up. Please note: to use literal \"{\" or \"}\", you need to double them as "
"\"{{\" and \"}}\"."
msgstr ""
"您的佔位符語法有誤。 請檢查佔位符上的開頭“{”和結尾“}”花括號是否匹配。 "
"請注意:要使用字面“{”或“}”,您需要將它們加倍為“{{”和“}}”。"
#: pretix/base/forms/validators.py:72 pretix/control/views/event.py:758
#, fuzzy, python-format
@@ -3097,9 +3097,10 @@ msgid "Invalid placeholder: {%(value)s}"
msgstr "無效的佔位元: %(value)ss"
#: pretix/base/forms/widgets.py:68
#, python-format
#, fuzzy, python-format
#| msgid "Sample city"
msgid "Sample: %s"
msgstr "範例: %s"
msgstr "範例城市"
#: pretix/base/forms/widgets.py:71
#, python-brace-format
@@ -3338,7 +3339,7 @@ msgstr ""
#: pretix/base/invoice.py:858
msgid "Default invoice renderer (European-style letter)"
msgstr "預設發票渲染器(歐式信件)"
msgstr ""
#: pretix/base/invoice.py:947
msgctxt "invoice"
@@ -3347,13 +3348,14 @@ msgstr "(請隨時報價)"
#: pretix/base/invoice.py:994
msgid "Simplified invoice renderer"
msgstr "簡化的發票渲染器"
msgstr ""
#: pretix/base/invoice.py:1013
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "Event date range"
msgctxt "invoice"
msgid "Event date: {date_range}"
msgstr "活動日期: {date_range}"
msgstr "活動日期區間"
#: pretix/base/media.py:71
msgid "Barcode / QR-Code"
@@ -3574,8 +3576,10 @@ msgid "Maximum usages"
msgstr "最大使用量"
#: pretix/base/modelimport_vouchers.py:79
#, fuzzy
#| msgid "Maximum number of items per order"
msgid "The maximum number of usages must be set."
msgstr "必須設定最大使用次數。"
msgstr "每項訂單的最大商品數量"
#: pretix/base/modelimport_vouchers.py:88 pretix/base/models/vouchers.py:205
msgid "Minimum usages"
@@ -3610,7 +3614,7 @@ msgstr "優惠券價值"
#: pretix/base/modelimport_vouchers.py:165
msgid "It is pointless to set a value without a price mode."
msgstr "在沒有價格模式的情況下設定值是沒有意義的。"
msgstr ""
#: pretix/base/modelimport_vouchers.py:237 pretix/base/models/items.py:2081
#: pretix/base/models/vouchers.py:272
@@ -3739,18 +3743,18 @@ msgstr ""
#: pretix/base/models/checkin.py:65
msgctxt "checkin"
msgid "Ignore check-ins on this list in statistics"
msgstr "忽略統計中此列表中的簽到"
msgstr ""
#: pretix/base/models/checkin.py:69
msgctxt "checkin"
msgid "Tickets with a check-in on this list should be considered \"used\""
msgstr "此列表中有簽到的門票應被視為「已使用」"
msgstr ""
#: 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 "這與各種情況有關,例如用於決定客戶是否仍然可以取消門票。"
msgstr ""
#: pretix/base/models/checkin.py:74
msgctxt "checkin"
@@ -4061,8 +4065,7 @@ msgid ""
"By default, the discount is applied across the same selection of products "
"than the condition for the discount given above. If you want, you can "
"however also select a different selection of products."
msgstr "預設情況下,折扣適用於與上述折扣條件相同的產品選擇。 "
"然而,如果您願意,您也可以選擇不同的產品。"
msgstr ""
#: pretix/base/models/discount.py:138
#, fuzzy
@@ -4353,7 +4356,7 @@ msgstr "對客戶顯示簽到次數"
msgid ""
"This field will be shown to filter events in the public event list and "
"calendar."
msgstr "將顯示此欄位,以過濾公共活動列表和日曆中的活動。"
msgstr ""
#: pretix/base/models/event.py:1731 pretix/control/forms/organizer.py:269
#: pretix/control/forms/organizer.py:273
@@ -5896,8 +5899,10 @@ msgid "Team members"
msgstr "團隊成員"
#: pretix/base/models/organizer.py:289
#, fuzzy
#| msgid "Do you really want to disable two-factor authentication?"
msgid "Require all members of this team to use two-factor authentication"
msgstr "要求該團隊的所有成員使用雙因素身份驗證"
msgstr "你真的要禁用兩步驟驗證嗎?"
#: pretix/base/models/organizer.py:290
msgid ""
@@ -7817,9 +7822,10 @@ msgid "number of entries before {datetime}"
msgstr "今日條目數數量"
#: pretix/base/services/checkin.py:320
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid "number of entries today"
msgid "number of days with an entry since {datetime}"
msgstr "自 {datetime}以來條目的天數"
msgstr "今日條目數數量"
#: pretix/base/services/checkin.py:321
#, fuzzy, python-brace-format
@@ -13801,8 +13807,10 @@ msgstr ""
"的所有部分VIP區除外。"
#: pretix/control/forms/item.py:664
#, fuzzy
#| msgid "The ordered product \"{item}\" is no longer available."
msgid "Show product with info on why its unavailable"
msgstr "顯示產品,並瞭解為什麼它不可用"
msgstr "訂購的產品“{item}”不再可用"
#: pretix/control/forms/item.py:677
msgid ""
@@ -18873,8 +18881,10 @@ msgid "Calculation"
msgstr "取消"
#: pretix/control/templates/pretixcontrol/event/tax_edit.html:64
#, fuzzy
#| msgid "Reason:"
msgid "Reason"
msgstr "理由"
msgstr "理由"
#: pretix/control/templates/pretixcontrol/event/tax_edit.html:137
#: pretix/control/templates/pretixcontrol/subevents/bulk.html:251
@@ -19946,8 +19956,11 @@ msgid ""
msgstr "目前不可用,因為已設定此產品的有限時間範圍"
#: pretix/control/templates/pretixcontrol/items/discounts.html:111
#, fuzzy
#| msgctxt "discount"
#| msgid "Condition"
msgid "Condition:"
msgstr "條件:"
msgstr "條件"
#: pretix/control/templates/pretixcontrol/items/discounts.html:126
msgid "Applies to:"
@@ -22009,8 +22022,10 @@ msgid "Channel type"
msgstr "掃描"
#: pretix/control/templates/pretixcontrol/organizers/channel_delete.html:5
#, fuzzy
#| msgid "Sales channel"
msgid "Delete sales channel:"
msgstr "銷售管道:"
msgstr "銷售管道"
#: pretix/control/templates/pretixcontrol/organizers/channel_delete.html:10
#, fuzzy
@@ -22029,8 +22044,10 @@ msgid ""
msgstr "無法刪除此成員資格,因為它已在訂單中使用。將其結束日期更改為過去。"
#: pretix/control/templates/pretixcontrol/organizers/channel_edit.html:6
#, fuzzy
#| msgid "Sales channel"
msgid "Sales channel:"
msgstr "銷售管道:"
msgstr "銷售管道"
#: pretix/control/templates/pretixcontrol/organizers/channels.html:8
msgid ""
@@ -26437,8 +26454,10 @@ msgid "Login from new source detected"
msgstr "未檢測到訂購號"
#: pretix/helpers/security.py:170
#, fuzzy
#| msgid "Unknown country code."
msgid "Unknown country"
msgstr "未知國家"
msgstr "未知國家代碼."
#: pretix/multidomain/models.py:36
#, fuzzy
@@ -29466,7 +29485,7 @@ msgstr ""
#: pretix/plugins/stripe/payment.py:350 pretix/plugins/stripe/payment.py:1552
msgid "Alipay"
msgstr "支付寶"
msgstr "Alipay"
#: pretix/plugins/stripe/payment.py:358 pretix/plugins/stripe/payment.py:1564
msgid "Bancontact"
@@ -30051,12 +30070,16 @@ msgid "Enter the entity number, reference number, and amount."
msgstr ""
#: pretix/plugins/stripe/templates/pretixplugins/stripe/pending.html:25
#, fuzzy
#| msgid "Invoice number"
msgid "Entity number:"
msgstr "公司編號"
msgstr "發票編號"
#: pretix/plugins/stripe/templates/pretixplugins/stripe/pending.html:26
#, fuzzy
#| msgid "Reference code"
msgid "Reference number:"
msgstr "參考代碼:"
msgstr "參考代碼"
#: pretix/plugins/stripe/templates/pretixplugins/stripe/pending.html:35
msgid ""
@@ -31133,9 +31156,10 @@ msgstr "新價格:"
#: pretix/presale/templates/pretixpresale/event/voucher.html:176
#: pretix/presale/templates/pretixpresale/event/voucher.html:329
#: pretix/presale/templates/pretixpresale/event/voucher.html:331
#, python-format
#, fuzzy, python-format
#| msgid "Modify price for %(item)s"
msgid "Modify price for %(item)s, at least %(price)s"
msgstr "修改%(item)s的價格,至少 %(price)s"
msgstr "修改%(item)s 的價格"
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:153
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:294
@@ -31239,12 +31263,16 @@ msgid "Enter a voucher code below to buy this product."
msgstr "在下面輸入優惠券代碼以購買此票。"
#: pretix/presale/templates/pretixpresale/event/fragment_availability.html:10
#, fuzzy
#| msgid "Not available"
msgid "Not available yet."
msgstr "尚不可用。"
msgstr "無法使用"
#: pretix/presale/templates/pretixpresale/event/fragment_availability.html:14
#, fuzzy
#| msgid "Not available"
msgid "Not available any more."
msgstr "不再可用。"
msgstr "無法使用"
#: pretix/presale/templates/pretixpresale/event/fragment_availability.html:19
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html:85
@@ -32587,7 +32615,7 @@ msgid ""
" "
msgstr ""
"\n"
" %(start_date)s\n"
" from %(start_date)s\n"
" "
#: pretix/presale/templates/pretixpresale/fragment_calendar_nav.html:12
@@ -33104,8 +33132,10 @@ msgid "This feature is only available in test mode."
msgstr "此禮品卡只能在測試模式下使用。"
#: pretix/presale/views/event.py:985
#, fuzzy
#| msgid "This account is disabled."
msgid "Time machine disabled!"
msgstr "時間機器被禁用"
msgstr "此帳戶已禁用"
#: pretix/presale/views/order.py:368 pretix/presale/views/order.py:433
#: pretix/presale/views/order.py:514
@@ -33236,12 +33266,14 @@ msgid ""
msgstr "你不能將自己添加到候補名單中,因為該商品目前有貨。"
#: pretix/presale/views/waiting.py:180
#, python-brace-format
#, fuzzy, python-brace-format
#| msgid ""
#| "We've added you to the waiting list. You will receive an email as soon as "
#| "this product gets available again."
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 "我們已將您列入候補名單。 "
"一旦該產品再次上市,我們將立即向{email}傳送電子郵件。"
msgstr "我們已將你添加到候補名單一旦該產品再次,您將收到一封電子郵件。"
#: pretix/presale/views/waiting.py:208
msgid "We could not find you on our waiting list."

View File

@@ -289,7 +289,7 @@ def _render_nup(input_files: List[str], num_pages: int, output_file: BytesIO, op
pass
try:
badges_pdf = PdfReader(input_files.pop(0))
badges_pdf = PdfReader(input_files.pop())
offset = 0
for i, chunk_indices in enumerate(_chunks(range(num_pages), badges_per_page * max_nup_pages)):
chunk = []
@@ -298,7 +298,7 @@ def _render_nup(input_files: List[str], num_pages: int, output_file: BytesIO, op
# file has beforehand
if j - offset >= len(badges_pdf.pages):
offset += len(badges_pdf.pages)
badges_pdf = PdfReader(input_files.pop(0))
badges_pdf = PdfReader(input_files.pop())
chunk.append(badges_pdf.pages[j - offset])
# Reset some internal state from pypdf. This will make it a little slower, but will prevent us from
# running out of memory if we process a really large file.

View File

@@ -360,6 +360,9 @@ class BankTransfer(BasePaymentProvider):
}
return template.render(ctx)
def storefrontapi_prepare(self, session_data, total, info):
return True
def checkout_prepare(self, request, total):
form = self.payment_form(request)
if form.is_valid():

View File

@@ -37,7 +37,7 @@ class PayPalEnvironment(VendorPayPalEnvironment):
'payer_id': self.merchant_id
},
key=None,
algorithm="none",
algorithm=None,
)
return ""

View File

@@ -33,8 +33,6 @@
# License for the specific language governing permissions and limitations under the License.
import copy
import inspect
import uuid
from collections import defaultdict
from decimal import Decimal
from django.conf import settings
@@ -42,7 +40,6 @@ from django.contrib import messages
from django.core.cache import caches
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.core.signing import BadSignature, loads
from django.core.validators import EmailValidator
from django.db import models
from django.db.models import Count, F, Q, Sum
from django.db.models.functions import Cast
@@ -61,7 +58,7 @@ from pretix.base.models.items import Question
from pretix.base.models.orders import (
InvoiceAddress, OrderPayment, QuestionAnswer,
)
from pretix.base.models.tax import TaxedPrice, TaxRule
from pretix.base.models.tax import TaxRule
from pretix.base.services.cart import (
CartError, CartManager, add_payment_to_cart, error_messages, get_fees,
set_cart_addons,
@@ -72,6 +69,14 @@ from pretix.base.services.orders import perform_order
from pretix.base.services.tasks import EventTask
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import validate_cart_addons
from pretix.base.storelogic import IncompleteError
from pretix.base.storelogic.addons import (
addons_is_applicable, addons_is_completed, get_addon_groups,
)
from pretix.base.storelogic.fields import ensure_fields_are_completed
from pretix.base.storelogic.payment import (
current_payments_valid, ensure_payment_is_completed, payment_is_applicable,
)
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format
from pretix.base.templatetags.rich_text import rich_text_snippet
@@ -87,7 +92,7 @@ from pretix.presale.forms.customer import AuthenticationForm, RegistrationForm
from pretix.presale.signals import (
checkout_all_optional, checkout_confirm_messages, checkout_flow_steps,
contact_form_fields, contact_form_fields_overrides,
order_api_meta_from_request, order_meta_from_request, question_form_fields,
order_api_meta_from_request, order_meta_from_request,
question_form_fields_overrides,
)
from pretix.presale.utils import customer_login
@@ -98,7 +103,6 @@ from pretix.presale.views.cart import (
_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
@@ -493,7 +497,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
self.request = request
# check whether addons are applicable
if get_cart(request).filter(item__addons__isnull=False).exists():
if addons_is_applicable(get_cart(request)):
return True
# don't re-check whether cross-selling is applicable if we're already past the AddOnsStep
@@ -517,19 +521,9 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
return request._checkoutflow_addons_applicable
def is_completed(self, request, warn=False):
if getattr(self, '_completed', None) is not None:
if getattr(self, '_completed', None) is None:
self._completed = addons_is_completed(get_cart(request))
return self._completed
for cartpos in get_cart(request).filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__item'
):
a = cartpos.addons.all()
for iao in cartpos.item.addons.all():
found = len([1 for p in a if p.item.category_id == iao.addon_category_id and not p.is_bundled])
if found < iao.min_count or found > iao.max_count:
self._completed = False
return False
self._completed = True
return True
@cached_property
def forms(self):
@@ -537,100 +531,12 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
A list of forms with one form for each cart position that can have add-ons.
All forms have a custom prefix, so that they can all be submitted at once.
"""
formset = []
quota_cache = {}
item_cache = {}
for cartpos in sorted(get_cart(self.request).filter(addon_to__isnull=True).prefetch_related(
'item__addons', 'item__addons__addon_category', 'addons', 'addons__variation',
), key=lambda c: c.sort_key):
formsetentry = {
'pos': cartpos,
'item': cartpos.item,
'variation': cartpos.variation,
'categories': []
}
current_addon_products = defaultdict(list)
for a in cartpos.addons.all():
if not a.is_bundled:
current_addon_products[a.item_id, a.variation_id].append(a)
for iao in cartpos.item.addons.all():
ckey = '{}-{}'.format(cartpos.subevent.pk if cartpos.subevent else 0, iao.addon_category.pk)
if ckey not in item_cache:
# Get all items to possibly show
items, _btn = get_grouped_items(
return get_addon_groups(
self.request.event,
subevent=cartpos.subevent,
voucher=None,
channel=self.request.sales_channel,
base_qs=iao.addon_category.items,
allow_addons=True,
quota_cache=quota_cache,
memberships=(
self.request.customer.usable_memberships(
for_event=cartpos.subevent or self.request.event,
testmode=self.request.event.testmode
self.request.sales_channel,
getattr(self.request, 'customer', None),
get_cart(self.request),
)
if getattr(self.request, 'customer', None) else None
),
)
item_cache[ckey] = items
else:
# We can use the cache to prevent a database fetch, but we need separate Python objects
# or our things below like setting `i.initial` will do the wrong thing.
items = [copy.copy(i) for i in item_cache[ckey]]
for i in items:
i.available_variations = [copy.copy(v) for v in i.available_variations]
for i in items:
i.allow_waitinglist = False
if i.has_variations:
for v in i.available_variations:
v.initial = len(current_addon_products[i.pk, v.pk])
if v.initial and i.free_price:
a = current_addon_products[i.pk, v.pk][0]
v.initial_price = TaxedPrice(
net=a.price - a.tax_value,
gross=a.price,
tax=a.tax_value,
name=a.item.tax_rule.name if a.item.tax_rule else "",
rate=a.tax_rate,
code=a.item.tax_rule.code if a.item.tax_rule else None,
)
else:
v.initial_price = v.suggested_price
i.expand = any(v.initial for v in i.available_variations)
else:
i.initial = len(current_addon_products[i.pk, None])
if i.initial and i.free_price:
a = current_addon_products[i.pk, None][0]
i.initial_price = TaxedPrice(
net=a.price - a.tax_value,
gross=a.price,
tax=a.tax_value,
name=a.item.tax_rule.name if a.item.tax_rule else "",
rate=a.tax_rate,
code=a.item.tax_rule.code if a.item.tax_rule else None,
)
else:
i.initial_price = i.suggested_price
if items:
formsetentry['categories'].append({
'category': iao.addon_category,
'price_included': iao.price_included or (cartpos.voucher_id and cartpos.voucher.all_addons_included),
'multi_allowed': iao.multi_allowed,
'min_count': iao.min_count,
'max_count': iao.max_count,
'iao': iao,
'items': items
})
if formsetentry['categories']:
formset.append(formsetentry)
return formset
@cached_property
def cross_selling_is_applicable(self):
@@ -1006,90 +912,19 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
def is_completed(self, request, warn=False):
self.request = request
try:
emailval = EmailValidator()
if not self.cart_session.get('email') and not self.all_optional:
if warn:
messages.warning(request, _('Please enter a valid email address.'))
return False
if self.cart_session.get('email'):
emailval(self.cart_session.get('email'))
except ValidationError:
if warn:
messages.warning(request, _('Please enter a valid email address.'))
return False
if not self.all_optional:
if self.address_asked:
if request.event.settings.invoice_address_required and (not self.invoice_address or not self.invoice_address.street):
messages.warning(request, _('Please enter your invoicing address.'))
return False
if request.event.settings.invoice_name_required and (not self.invoice_address or not self.invoice_address.name):
messages.warning(request, _('Please enter your name.'))
return False
for cp in self._positions_for_questions:
answ = {
aw.question_id: aw for aw in cp.answerlist
}
question_cache = {
q.pk: q for q in cp.item.questions_to_ask
}
def question_is_visible(parentid, qvals):
if parentid not in question_cache:
return False
parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
return False
if parentid not in answ:
return False
return (
('True' in qvals and answ[parentid].answer == 'True')
or ('False' in qvals and answ[parentid].answer == 'False')
or (any(qval in [o.identifier for o in answ[parentid].options.all()] for qval in qvals))
ensure_fields_are_completed(
self.event,
self._positions_for_questions,
self.cart_session,
self.invoice_address,
self.all_optional,
get_cart_is_free(request),
)
def question_is_required(q):
return (
q.required and
(not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values))
)
if not self.all_optional:
for q in cp.item.questions_to_ask:
if question_is_required(q) and q.id not in answ:
except IncompleteError as e:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_names_required', as_type=bool) \
and not cp.attendee_name_parts:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_emails_required', as_type=bool) \
and cp.attendee_email is None:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_company_required', as_type=bool) \
and cp.company is None:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_addresses_required', as_type=bool) \
and (cp.street is None and cp.city is None and cp.country is None):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
responses = question_form_fields.send(sender=self.request.event, position=cp)
form_data = cp.meta_info_data.get('question_form_data', {})
for r, response in sorted(responses, key=lambda r: str(r[0])):
for key, value in response.items():
if value.required and not form_data.get(key):
messages.warning(request, e)
return False
else:
return True
def get_context_data(self, **kwargs):
@@ -1289,20 +1124,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
return singleton_payments[0]
def current_payments_valid(self, amount):
singleton_payments = [p for p in self.cart_session.get('payments', []) if not p.get('multi_use_supported')]
if len(singleton_payments) > 1:
return False
matched = Decimal('0.00')
for p in self.cart_session.get('payments', []):
if p.get('min_value') and (amount - matched) < Decimal(p['min_value']):
continue
if p.get('max_value') and (amount - matched) > Decimal(p['max_value']):
matched += Decimal(p['max_value'])
else:
matched = Decimal('0.00')
return matched == Decimal('0.00'), amount - matched
return current_payments_valid(self.cart_session, amount)
def post(self, request):
self.request = request
@@ -1406,6 +1228,7 @@ class PaymentStep(CartMixin, TemplateFlowStep):
def is_completed(self, request, warn=False):
if not self.cart_session.get('payments'):
# Is also in ensure_payment_is_completed, but saves us performance of cart evaluation
if warn:
messages.error(request, _('Please select a payment method to proceed.'))
return False
@@ -1418,58 +1241,30 @@ class PaymentStep(CartMixin, TemplateFlowStep):
except TaxRule.SaleNotAllowed:
# ignore for now, will fail on order creation
pass
selected = self.current_selected_payments(total, warn=warn, total_includes_payment_fees=True)
if sum(p['payment_amount'] for p in selected) != total:
if warn:
messages.error(request, _('Please select a payment method to proceed.'))
return False
if len([p for p in selected if not p['multi_use_supported']]) > 1:
raise ImproperlyConfigured('Multiple non-multi-use providers in session, should never happen')
for p in selected:
if not p['pprov'] or not p['pprov'].is_enabled or not self._is_allowed(p['pprov'], request):
self._remove_payment(p['id'])
if p['payment_amount']:
try:
ensure_payment_is_completed(
self.event,
total,
self.cart_session,
self.request,
)
except IncompleteError as e:
if warn:
messages.error(request, _('Please select a payment method to proceed.'))
return False
if not p['multi_use_supported'] and not p['pprov'].payment_is_valid_session(request):
if warn:
messages.error(request, _('The payment information you entered was incomplete.'))
messages.warning(self.request, str(e))
return False
return True
def is_applicable(self, request):
self.request = request
for cartpos in get_cart(self.request):
if cartpos.requires_approval(invoice_address=self.invoice_address):
if 'payments' in self.cart_session:
del self.cart_session['payments']
return False
used_providers = {p['provider'] for p in self.cart_session.get('payments', [])}
for provider in self.request.event.get_payment_providers().values():
if provider.is_implicit(request) if callable(provider.is_implicit) else provider.is_implicit:
if self._is_allowed(provider, request):
self.cart_session['payments'] = [
{
'id': str(uuid.uuid4()),
'provider': provider.identifier,
'multi_use_supported': False,
'min_value': None,
'max_value': None,
'info_data': {},
}
]
return False
elif provider.identifier in used_providers:
# is_allowed might have changed, e.g. after add-on selection
self.cart_session['payments'] = [p for p in self.cart_session['payments'] if p['provider'] != provider.identifier]
return True
return payment_is_applicable(
self.event,
self._total_order_value,
get_cart(request),
self.invoice_address,
self.cart_session,
request,
)
def get(self, request):
self.request.pci_dss_payment_page = True

View File

@@ -31,7 +31,6 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import copy
from collections import defaultdict
from datetime import datetime, timedelta
from decimal import Decimal
@@ -44,7 +43,6 @@ from django.db.models import Exists, OuterRef, Prefetch, Sum
from django.utils import translation
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_scopes import scopes_disabled
from pretix.base.i18n import get_language_without_region
@@ -54,7 +52,8 @@ from pretix.base.models import (
QuestionAnswer, QuestionOption, TaxRule,
)
from pretix.base.services.cart import get_fees
from pretix.base.templatetags.money import money_filter
from pretix.base.storelogic import IncompleteError
from pretix.base.storelogic.payment import current_selected_payments
from pretix.helpers.cookies import set_cookie_without_samesite
from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.signals import question_form_fields
@@ -256,52 +255,16 @@ class CartMixin:
}
def current_selected_payments(self, total, warn=False, total_includes_payment_fees=False):
raw_payments = copy.deepcopy(self.cart_session.get('payments', []))
payments = []
total_remaining = total
for p in raw_payments:
# This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base:
# - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees
# - pretix.presale.views.CartMixin.current_selected_payments
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
if warn:
messages.warning(
self.request,
_('Your selected payment method can only be used for a payment of at least {amount}.').format(
amount=money_filter(Decimal(p['min_value']), self.request.event.currency)
try:
return current_selected_payments(
self.request.event,
total,
self.cart_session,
total_includes_payment_fees=total_includes_payment_fees,
fail=warn
)
)
self._remove_payment(p['id'])
continue
to_pay = total_remaining
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
pprov = self.request.event.get_payment_providers(cached=True).get(p['provider'])
if not pprov:
self._remove_payment(p['id'])
continue
if not total_includes_payment_fees:
fee = pprov.calculate_fee(to_pay)
total_remaining += fee
to_pay += fee
else:
fee = Decimal('0.00')
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
p['payment_amount'] = to_pay
p['provider_name'] = pprov.public_name
p['pprov'] = pprov
p['fee'] = fee
total_remaining -= to_pay
payments.append(p)
return payments
except IncompleteError as e:
messages.warning(self.request, str(e))
def _remove_payment(self, payment_id):
self.cart_session['payments'] = [p for p in self.cart_session['payments'] if p.get('id') != payment_id]
@@ -339,8 +302,7 @@ def get_cart(request):
'item__category__position', 'item__category_id', 'item__position', 'item__name', 'variation__value'
).select_related(
'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer',
'item__tax_rule', 'item__category', 'used_membership', 'used_membership__membership_type'
).select_related(
'item__tax_rule', 'item__category', 'used_membership', 'used_membership__membership_type',
'addon_to'
).prefetch_related(
'addons', 'addons__item', 'addons__variation',

View File

@@ -64,6 +64,9 @@ from pretix.base.services.cart import (
CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages,
remove_cart_position,
)
from pretix.base.storelogic.products import (
get_items_for_product_list, item_group_by_category,
)
from pretix.base.timemachine import time_machine_now
from pretix.base.views.tasks import AsyncAction
from pretix.helpers.http import redirect_to_url
@@ -72,9 +75,6 @@ from pretix.presale.views import (
CartMixin, EventViewMixin, allow_cors_if_namespaced,
allow_frame_if_namespaced, iframe_entry_view_wrapper,
)
from pretix.presale.views.event import (
get_grouped_items, item_group_by_category,
)
from pretix.presale.views.robots import NoSearchIndexViewMixin
try:
@@ -613,7 +613,7 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, CartMixin, TemplateView
context['max_times'] = self.voucher.max_usages - self.voucher.redeemed
# Fetch all items
items, display_add_to_cart = get_grouped_items(
items, display_add_to_cart = get_items_for_product_list(
self.request.event,
subevent=self.subevent,
voucher=self.voucher,

View File

@@ -34,7 +34,6 @@
import calendar
import hashlib
import sys
from collections import defaultdict
from datetime import date, datetime, timedelta
from decimal import Decimal
@@ -47,10 +46,7 @@ from django import forms
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db.models import (
Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value,
)
from django.db.models.lookups import Exact
from django.db.models import Count
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator
@@ -64,15 +60,9 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import (
ItemVariation, Quota, SalesChannel, SeatCategoryMapping, Voucher,
)
from pretix.base.models import Quota, Voucher
from pretix.base.models.event import Event, SubEvent
from pretix.base.models.items import (
ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
)
from pretix.base.services.placeholders import PlaceholderContext
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timemachine import (
has_time_machine_permission, time_machine_now,
)
@@ -83,12 +73,15 @@ from pretix.helpers.formats.en.formats import (
from pretix.helpers.http import redirect_to_url
from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.ical import get_public_ical
from pretix.presale.signals import item_description, seatingframe_html_head
from pretix.presale.signals import seatingframe_html_head
from pretix.presale.views.organizer import (
EventListMixin, add_subevents_for_days, days_for_template,
filter_qs_by_attr, has_before_after, weeks_for_template,
)
from ...base.storelogic.products import (
get_items_for_product_list, item_group_by_category,
)
from . import (
CartMixin, EventViewMixin, allow_frame_if_namespaced, get_cart,
iframe_entry_view_wrapper,
@@ -97,386 +90,6 @@ from . import (
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
def item_group_by_category(items):
return sorted(
[
# a group is a tuple of a category and a list of items
(cat, [i for i in items if i.category == cat])
for cat in set([i.category for i in items])
# insert categories into a set for uniqueness
# a set is unsorted, so sort again by category
],
key=lambda group: (group[0].position, group[0].id) if (
group[0] is not None and group[0].id is not None) else (0, 0)
)
def get_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
base_qs = base_qs if base_qs is not None else event.items
requires_seat = Exists(
SeatCategoryMapping.objects.filter(
product_id=OuterRef('pk'),
subevent=subevent
)
)
if not event.settings.seating_choice:
requires_seat = Value(0, output_field=IntegerField())
variation_q = (
Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info')) &
Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info'))
)
if not voucher or not voucher.show_hidden_items:
variation_q &= Q(hide_without_voucher=False)
if memberships is not None:
prefetch_membership_types = ['require_membership_types']
else:
prefetch_membership_types = []
prefetch_var = Prefetch(
'variations',
to_attr='available_variations',
queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).annotate(
subevent_disabled=Exists(
SubEventItemVariation.objects.filter(
Q(disabled=True)
| (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now()))
| (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())),
variation_id=OuterRef('pk'),
subevent=subevent,
)
),
).filter(
variation_q,
Q(all_sales_channels=True) | Q(limit_sales_channels=channel),
active=True,
quotas__isnull=False,
subevent_disabled=False
).prefetch_related(
*prefetch_membership_types,
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
subevent=subevent).select_related("subevent"))
).distinct()
)
prefetch_quotas = Prefetch(
'quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent).select_related("subevent")
)
prefetch_bundles = Prefetch(
'bundles',
queryset=ItemBundle.objects.using(settings.DATABASE_REPLICA).prefetch_related(
Prefetch('bundled_item',
queryset=event.items.using(settings.DATABASE_REPLICA).select_related(
'tax_rule').prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
subevent=subevent)),
)),
Prefetch('bundled_variation',
queryset=ItemVariation.objects.using(
settings.DATABASE_REPLICA
).select_related('item', 'item__tax_rule').filter(item__event=event).prefetch_related(
Prefetch('quotas',
to_attr='_subevent_quotas',
queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(
subevent=subevent)),
)),
)
)
items = base_qs.using(settings.DATABASE_REPLICA).filter_available(
channel=channel.identifier, voucher=voucher, allow_addons=allow_addons, allow_cross_sell=allow_cross_sell
).select_related(
'category', 'tax_rule', # for re-grouping
'hidden_if_available',
).prefetch_related(
*prefetch_membership_types,
Prefetch(
'hidden_if_item_available',
queryset=event.items.annotate(
has_variations=Count('variations'),
).prefetch_related(
prefetch_var,
prefetch_quotas,
prefetch_bundles,
)
),
prefetch_quotas,
prefetch_var,
prefetch_bundles,
).annotate(
quotac=Count('quotas'),
has_variations=Count('variations'),
subevent_disabled=Exists(
SubEventItem.objects.filter(
Q(disabled=True)
| (Exact(OuterRef('available_from_mode'), 'hide') & Q(available_from__gt=time_machine_now()))
| (Exact(OuterRef('available_until_mode'), 'hide') & Q(available_until__lt=time_machine_now())),
item_id=OuterRef('pk'),
subevent=subevent,
)
),
mandatory_priced_addons=Exists(
ItemAddOn.objects.filter(
base_item_id=OuterRef('pk'),
min_count__gte=1,
price_included=False
)
),
requires_seat=requires_seat,
).filter(
quotac__gt=0, subevent_disabled=False,
).order_by('category__position', 'category_id', 'position', 'name')
if require_seat:
items = items.filter(requires_seat__gt=0)
elif require_seat is not None:
items = items.filter(requires_seat=0)
if filter_items:
items = items.filter(pk__in=[a for a in filter_items if a.isdigit()])
if filter_categories:
items = items.filter(category_id__in=[a for a in filter_categories if a.isdigit()])
display_add_to_cart = False
quota_cache_key = f'item_quota_cache:{subevent.id if subevent else 0}:{channel.identifier}:{bool(require_seat)}'
quota_cache = quota_cache or event.cache.get(quota_cache_key) or {}
quota_cache_existed = bool(quota_cache)
if subevent:
item_price_override = subevent.item_price_overrides
var_price_override = subevent.var_price_overrides
else:
item_price_override = {}
var_price_override = {}
restrict_vars = set()
if voucher and voucher.quota_id:
# If a voucher is set to a specific quota, we need to filter out on that level
restrict_vars = set(voucher.quota.variations.all())
quotas_to_compute = []
for item in items:
assert item.event_id == event.pk
item.event = event # save a database query if this is looked up
if item.has_variations:
for v in item.available_variations:
for q in v._subevent_quotas:
if q.pk not in quota_cache:
quotas_to_compute.append(q)
else:
for q in item._subevent_quotas:
if q.pk not in quota_cache:
quotas_to_compute.append(q)
if quotas_to_compute:
qa = QuotaAvailability()
qa.queue(*quotas_to_compute)
qa.compute()
quota_cache.update({q.pk: r for q, r in qa.results.items()})
for item in items:
if voucher and voucher.item_id and voucher.variation_id:
# Restrict variations if the voucher only allows one
item.available_variations = [v for v in item.available_variations
if v.pk == voucher.variation_id]
if channel.type_instance.unlimited_items_per_order:
max_per_order = sys.maxsize
else:
max_per_order = item.max_per_order or int(event.settings.max_items_per_order)
if item.hidden_if_available:
q = item.hidden_if_available.availability(_cache=quota_cache)
if q[0] == Quota.AVAILABILITY_OK:
item._remove = True
continue
if item.hidden_if_item_available:
if item.hidden_if_item_available.has_variations:
dependency_available = any(
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK
for var in item.hidden_if_item_available.available_variations
)
else:
q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
dependency_available = q[0] == Quota.AVAILABILITY_OK
if dependency_available:
item._remove = True
continue
if item.require_membership and item.require_membership_hidden:
if not memberships or not any([m.membership_type in item.require_membership_types.all() for m in memberships]):
item._remove = True
continue
item.current_unavailability_reason = item.unavailability_reason(has_voucher=voucher, subevent=subevent)
item.description = str(item.description)
for recv, resp in item_description.send(sender=event, item=item, variation=None, subevent=subevent):
if resp:
item.description += ("<br/>" if item.description else "") + resp
if not item.has_variations:
item._remove = False
if not bool(item._subevent_quotas):
item._remove = True
continue
if voucher and (voucher.allow_ignore_quota or voucher.block_quota):
item.cached_availability = (
Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed
)
else:
item.cached_availability = list(
item.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
)
if not (
ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids
) and event.settings.hide_sold_out and item.cached_availability[0] < Quota.AVAILABILITY_RESERVED:
item._remove = True
continue
item.order_max = min(
item.cached_availability[1]
if item.cached_availability[1] is not None else sys.maxsize,
max_per_order
)
original_price = item_price_override.get(item.pk, item.default_price)
voucher_reduced = False
if voucher:
price = voucher.calculate_price(original_price)
voucher_reduced = price < original_price
include_bundled = not voucher.all_bundles_included
else:
price = original_price
include_bundled = True
item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
if item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled)
else:
item.suggested_price = item.display_price
if price != original_price:
item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True)
else:
item.original_price = (
item.tax(item.original_price, currency=event.currency, include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
if item.original_price else None
)
if not display_add_to_cart:
display_add_to_cart = not item.requires_seat and item.order_max > 0
else:
for var in item.available_variations:
if var.require_membership and var.require_membership_hidden:
if not memberships or not any([m.membership_type in var.require_membership_types.all() for m in memberships]):
var._remove = True
continue
var.description = str(var.description)
for recv, resp in item_description.send(sender=event, item=item, variation=var, subevent=subevent):
if resp:
var.description += ("<br/>" if var.description else "") + resp
if voucher and (voucher.allow_ignore_quota or voucher.block_quota):
var.cached_availability = (
Quota.AVAILABILITY_OK, voucher.max_usages - voucher.redeemed
)
else:
var.cached_availability = list(
var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)
)
var.order_max = min(
var.cached_availability[1]
if var.cached_availability[1] is not None else sys.maxsize,
max_per_order
)
original_price = var_price_override.get(var.pk, var.price)
voucher_reduced = False
if voucher:
price = voucher.calculate_price(original_price)
voucher_reduced = price < original_price
include_bundled = not voucher.all_bundles_included
else:
price = original_price
include_bundled = True
var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
if item.free_price and var.free_price_suggestion is not None and not voucher_reduced:
var.suggested_price = item.tax(max(price, var.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
elif item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
var.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
else:
var.suggested_price = var.display_price
if price != original_price:
var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True)
else:
var.original_price = (
var.tax(var.original_price or item.original_price, currency=event.currency,
include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
) if var.original_price or item.original_price else None
if not display_add_to_cart:
display_add_to_cart = not item.requires_seat and var.order_max > 0
var.current_unavailability_reason = var.unavailability_reason(has_voucher=voucher, subevent=subevent)
item.original_price = (
item.tax(item.original_price, currency=event.currency, include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
if item.original_price else None
)
item.available_variations = [
v for v in item.available_variations if v._subevent_quotas and (
not voucher or not voucher.quota_id or v in restrict_vars
) and not getattr(v, '_remove', False)
]
if not (ignore_hide_sold_out_for_item_ids and item.pk in ignore_hide_sold_out_for_item_ids) and event.settings.hide_sold_out:
item.available_variations = [v for v in item.available_variations
if v.cached_availability[0] >= Quota.AVAILABILITY_RESERVED]
if voucher and voucher.variation_id:
item.available_variations = [v for v in item.available_variations
if v.pk == voucher.variation_id]
if len(item.available_variations) > 0:
item.min_price = min([v.display_price.net if event.settings.display_net_prices else
v.display_price.gross for v in item.available_variations])
item.max_price = max([v.display_price.net if event.settings.display_net_prices else
v.display_price.gross for v in item.available_variations])
item.best_variation_availability = max([v.cached_availability[0] for v in item.available_variations])
item._remove = not bool(item.available_variations)
if not quota_cache_existed and not voucher and not allow_addons and not base_qs_set and not filter_items and not filter_categories:
event.cache.set(quota_cache_key, quota_cache, 5)
items = [item for item in items
if (len(item.available_variations) > 0 or not item.has_variations) and not item._remove]
return items, display_add_to_cart
@method_decorator(allow_frame_if_namespaced, 'dispatch')
@method_decorator(iframe_entry_view_wrapper, 'dispatch')
class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
@@ -571,7 +184,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
if not self.request.event.has_subevents or self.subevent:
# Fetch all items
items, display_add_to_cart = get_grouped_items(
items, display_add_to_cart = get_items_for_product_list(
self.request.event,
subevent=self.subevent,
filter_items=self.request.GET.getlist('item'),

View File

@@ -82,6 +82,7 @@ from pretix.base.services.orders import (
from pretix.base.services.pricing import get_price
from pretix.base.services.tickets import generate, invalidate_cache
from pretix.base.signals import order_modified, register_ticket_outputs
from pretix.base.storelogic.products import get_items_for_product_list
from pretix.base.templatetags.money import money_filter
from pretix.base.views.mixins import OrderQuestionsViewMixin
from pretix.base.views.tasks import AsyncAction
@@ -95,7 +96,6 @@ from pretix.presale.signals import question_form_fields_overrides
from pretix.presale.views import (
CartMixin, EventViewMixin, iframe_entry_view_wrapper,
)
from pretix.presale.views.event import get_grouped_items
from pretix.presale.views.robots import NoSearchIndexViewMixin
@@ -1372,7 +1372,7 @@ class OrderChangeMixin:
if ckey not in item_cache:
# Get all items to possibly show
items, _btn = get_grouped_items(
items, _btn = get_items_for_product_list(
self.request.event,
subevent=p.subevent,
voucher=None,

View File

@@ -39,9 +39,9 @@ from pretix.presale.views import EventViewMixin, iframe_entry_view_wrapper
from ...base.i18n import get_language_without_region
from ...base.models import Voucher, WaitingListEntry
from ...base.storelogic.products import get_items_for_product_list
from ..forms.waitinglist import WaitingListForm
from . import allow_frame_if_namespaced
from .event import get_grouped_items
@method_decorator(allow_frame_if_namespaced, 'dispatch')
@@ -53,7 +53,7 @@ class WaitingView(EventViewMixin, FormView):
@cached_property
def itemvars(self):
customer = getattr(self.request, 'customer', None)
items, display_add_to_cart = get_grouped_items(
items, display_add_to_cart = get_items_for_product_list(
self.request.event,
subevent=self.subevent,
require_seat=None,

View File

@@ -61,6 +61,9 @@ from pretix.base.models import (
from pretix.base.services.cart import error_messages
from pretix.base.services.placeholders import PlaceholderContext
from pretix.base.settings import GlobalSettingsObject
from pretix.base.storelogic.products import (
get_items_for_product_list, item_group_by_category,
)
from pretix.base.templatetags.rich_text import rich_text
from pretix.helpers.daterange import daterange
from pretix.helpers.thumb import get_thumbnail
@@ -68,9 +71,6 @@ from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.forms.organizer import meta_filtersets
from pretix.presale.style import get_theme_vars_css
from pretix.presale.views.cart import get_or_create_cart_id
from pretix.presale.views.event import (
get_grouped_items, item_group_by_category,
)
from pretix.presale.views.organizer import (
EventListMixin, add_events_for_days, add_subevents_for_days,
days_for_template, filter_qs_by_attr, weeks_for_template,
@@ -270,7 +270,7 @@ class WidgetAPIProductList(EventListMixin, View):
).values_list('item_id', flat=True)
)
items, display_add_to_cart = get_grouped_items(
items, display_add_to_cart = get_items_for_product_list(
self.request.event,
subevent=self.subevent,
voucher=self.voucher,

View File

@@ -439,6 +439,7 @@ CORE_MODULES = {
"pretix.base",
"pretix.presale",
"pretix.control",
"pretix.storefrontapi",
"pretix.plugins.checkinlists",
"pretix.plugins.reports",
}
@@ -460,6 +461,7 @@ MIDDLEWARE = [
'pretix.base.middleware.SecurityMiddleware',
'pretix.presale.middleware.EventMiddleware',
'pretix.api.middleware.ApiScopeMiddleware',
'pretix.storefrontapi.middleware.ApiMiddleware',
]
try:

View File

@@ -169,7 +169,7 @@ pre[lang=is], input[lang=is], textarea[lang=is], div[lang=is] { background-image
pre[lang=it], input[lang=it], textarea[lang=it], div[lang=it] { background-image: url(static('pretixbase/img/flags/it.png')); }
pre[lang=jm], input[lang=jm], textarea[lang=jm], div[lang=jm] { background-image: url(static('pretixbase/img/flags/jm.png')); }
pre[lang=jo], input[lang=jo], textarea[lang=jo], div[lang=jo] { background-image: url(static('pretixbase/img/flags/jo.png')); }
pre[lang=ja], input[lang=ja], textarea[lang=ja], div[lang=ja] { background-image: url(static('pretixbase/img/flags/jp.png')); }
pre[lang=jp], input[lang=jp], textarea[lang=jp], div[lang=jp] { background-image: url(static('pretixbase/img/flags/jp.png')); }
pre[lang=ke], input[lang=ke], textarea[lang=ke], div[lang=ke] { background-image: url(static('pretixbase/img/flags/ke.png')); }
pre[lang=kg], input[lang=kg], textarea[lang=kg], div[lang=kg] { background-image: url(static('pretixbase/img/flags/kg.png')); }
pre[lang=kh], input[lang=kh], textarea[lang=kh], div[lang=kh] { background-image: url(static('pretixbase/img/flags/kh.png')); }

View File

View File

@@ -0,0 +1,30 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.apps import AppConfig
class PretixStorefrontApiConfig(AppConfig):
name = "pretix.storefrontapi"
label = "pretixstorefrontapi"
def ready(self):
from . import signals # noqa

View File

@@ -0,0 +1,700 @@
import logging
from celery.result import AsyncResult
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.utils import translation
from django.utils.translation import gettext as _
from rest_framework import serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response
from rest_framework.reverse import reverse
from pretix.base.models import Item, ItemVariation, SubEvent, TaxRule
from pretix.base.models.orders import CartPosition, CheckoutSession, OrderFee
from pretix.base.services.cart import (
add_items_to_cart, add_payment_to_cart_session, error_messages, get_fees,
set_cart_addons,
)
from pretix.base.services.orders import perform_order
from pretix.base.storelogic.addons import get_addon_groups
from pretix.base.storelogic.fields import (
get_checkout_fields, get_position_fields,
)
from pretix.base.storelogic.payment import current_selected_payments
from pretix.base.timemachine import time_machine_now
from pretix.presale.signals import (
order_api_meta_from_request, order_meta_from_request,
)
from pretix.presale.views.cart import generate_cart_id
from pretix.storefrontapi.endpoints.event import (
CategorySerializer, ItemSerializer,
)
from pretix.storefrontapi.permission import StorefrontEventPermission
from pretix.storefrontapi.serializers import I18nFlattenedModelSerializer
from pretix.storefrontapi.steps import get_steps
logger = logging.getLogger(__name__)
class CartAddLineSerializer(serializers.Serializer):
item = serializers.IntegerField()
variation = serializers.IntegerField(allow_null=True, required=False)
subevent = serializers.IntegerField(allow_null=True, required=False)
count = serializers.IntegerField(default=1)
seat = serializers.CharField(allow_null=True, required=False)
price = serializers.DecimalField(
allow_null=True, required=False, decimal_places=2, max_digits=13
)
voucher = serializers.CharField(allow_null=True, required=False)
class CartAddonLineSerializer(CartAddLineSerializer):
voucher = None
addon_to = serializers.PrimaryKeyRelatedField(
queryset=CartPosition.objects.none(), required=True
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["addon_to"].queryset = CartPosition.objects.filter(
cart_id=self.context["cart_id"], addon_to__isnull=True
)
def to_internal_value(self, data):
i = super().to_internal_value(data)
i["addon_to"] = i["addon_to"].pk
return i
class InlineItemSerializer(I18nFlattenedModelSerializer):
class Meta:
model = Item
fields = [
"id",
"name",
]
class InlineItemVariationSerializer(I18nFlattenedModelSerializer):
class Meta:
model = ItemVariation
fields = [
"id",
"value",
]
class InlineSubEventSerializer(I18nFlattenedModelSerializer):
class Meta:
model = SubEvent
fields = [
"id",
"name",
"date_from",
]
class CartFeeSerializer(serializers.ModelSerializer):
class Meta:
model = OrderFee
fields = [
"fee_type",
"description",
"value",
"tax_rate",
"tax_value",
"internal_type",
]
class FieldSerializer(serializers.Serializer):
identifier = serializers.CharField()
label = serializers.CharField(allow_null=True)
required = serializers.BooleanField()
type = serializers.CharField()
validation_hints = serializers.DictField()
class MinimalCartPositionSerializer(serializers.ModelSerializer):
# todo: prefetch related items
item = InlineItemSerializer(read_only=True)
variation = InlineItemVariationSerializer(read_only=True)
subevent = InlineSubEventSerializer(read_only=True)
class Meta:
model = CartPosition
fields = [
"id",
"addon_to",
"item",
"variation",
"subevent",
"price",
"expires",
# todo: attendee_name, attendee_email, voucher, addon_to, used_membership, seat, is_bundled, discount
# todo: address, requested_valid_from
]
class CartPositionSerializer(MinimalCartPositionSerializer):
def to_representation(self, instance):
d = super().to_representation(instance)
fields = get_position_fields(self.context["event"], instance)
d["fields"] = FieldSerializer(
fields, many=True, context={**self.context, "position": instance}
).data
d["fields_data"] = {f.identifier: f.current_value(instance) for f in fields}
return d
class CheckoutSessionSerializer(serializers.ModelSerializer):
class Meta:
model = CheckoutSession
fields = [
"cart_id",
"sales_channel",
"testmode",
]
def to_representation(self, checkout):
d = super().to_representation(checkout)
cartpos = checkout.get_cart_positions(prefetch_questions=True)
total = sum(p.price for p in cartpos)
try:
fees = get_fees(
self.context["event"],
self.context["request"],
total,
(
checkout.invoice_address
if hasattr(checkout, "invoice_address")
else None
),
payments=[], # todo
positions=cartpos,
)
except TaxRule.SaleNotAllowed:
# ignore for now, will fail on order creation
fees = []
total += sum([f.value for f in fees])
d["cart_positions"] = CartPositionSerializer(
sorted(cartpos, key=lambda c: c.sort_key), many=True, context=self.context
).data
d["cart_fees"] = CartFeeSerializer(fees, many=True, context=self.context).data
d["total"] = str(total)
fields = get_checkout_fields(self.context["event"])
d["fields"] = FieldSerializer(
fields, many=True, context={**self.context, "checkout": checkout}
).data
d["fields_data"] = {
f.identifier: f.current_value(checkout.session_data) for f in fields
}
payments = current_selected_payments(
self.context["event"],
total,
checkout.session_data,
total_includes_payment_fees=False,
fail=False,
)
d["payments"] = [
{
"identifier": p["pprov"].identifier,
"label": str(p["pprov"].public_name),
"payment_amount": str(p["payment_amount"]),
}
for p in payments
]
steps = get_steps(
self.context["event"],
cartpos,
getattr(checkout, "invoice_address", None),
checkout.session_data,
total,
)
d["steps"] = {}
for step in steps:
applicable = step.is_applicable()
valid = not applicable or step.is_valid()
d["steps"][step.identifier] = {
"applicable": applicable,
"valid": valid,
}
return d
class CheckoutViewSet(viewsets.ViewSet):
queryset = CheckoutSession.objects.none()
lookup_url_kwarg = "cart_id"
lookup_field = "cart_id"
permission_classes = [
StorefrontEventPermission,
]
def _return_checkout_status(self, cs: CheckoutSession, status=200):
serializer = CheckoutSessionSerializer(
instance=cs,
context={
"event": self.request.event,
"request": self.request,
},
)
return Response(
serializer.data,
status=status,
)
def create(self, request, *args, **kwargs):
if (
request.event.presale_start
and time_machine_now() < request.event.presale_start
):
raise ValidationError(error_messages["not_started"])
if request.event.presale_has_ended:
raise ValidationError(error_messages["ended"])
cs = CheckoutSession.objects.create(
event=request.event,
cart_id=generate_cart_id(),
sales_channel=request.sales_channel,
testmode=request.event.testmode,
session_data={},
)
return self._return_checkout_status(cs, status=201)
def retrieve(self, request, *args, **kwargs):
cs = get_object_or_404(
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
)
return self._return_checkout_status(cs, status=200)
@action(detail=True, methods=["GET", "PUT"])
def addons(self, request, *args, **kwargs):
cs = get_object_or_404(
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
)
groups = get_addon_groups(
self.request.event,
self.request.sales_channel,
cs.customer,
CartPosition.objects.filter(cart_id=cs.cart_id),
)
ctx = {
"event": self.request.event,
}
if request.method == "PUT":
serializer = CartAddonLineSerializer(
data=request.data.get("lines", []),
many=True,
context={
"event": self.request.event,
"cart_id": cs.cart_id,
},
)
serializer.is_valid(raise_exception=True)
# todo: early validation, validate_cart_addons?
return self._do_async(
cs,
set_cart_addons,
self.request.event.pk,
serializer.validated_data,
[],
cs.cart_id,
locale=translation.get_language(),
invoice_address=(
cs.invoice_address.pk if hasattr(cs, "invoice_address") else None
),
sales_channel=cs.sales_channel.identifier,
override_now_dt=time_machine_now(default=None),
)
elif request.method == "GET":
data = [
{
"parent": MinimalCartPositionSerializer(
grp["pos"], context=ctx
).data,
"categories": [
{
"category": CategorySerializer(
cat["category"], context=ctx
).data,
"multi_allowed": cat["multi_allowed"],
"min_count": cat["min_count"],
"max_count": cat["max_count"],
"items": ItemSerializer(
cat["items"],
many=True,
context={
**ctx,
"price_included": cat["price_included"],
"max_count": (
cat["max_count"] if cat["multi_allowed"] else 1
),
},
).data,
}
for cat in grp["categories"]
],
}
for grp in groups
]
return Response(
data={
"groups": data,
},
status=200,
)
def _get_total(self, cs, payments):
cartpos = cs.get_cart_positions(prefetch_questions=True)
total = sum(p.price for p in cartpos)
try:
# TODO: do we need a different get_fees for storefrontapi?
fees = get_fees(
self.request.event,
self.request,
total,
(cs.invoice_address if hasattr(cs, "invoice_address") else None),
payments=payments,
positions=cartpos,
)
except TaxRule.SaleNotAllowed:
# ignore for now, will fail on order creation
fees = []
total += sum([f.value for f in fees])
return total
@action(detail=True, methods=["GET", "POST"])
def payment(self, request, *args, **kwargs):
cs = get_object_or_404(
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
)
if request.method == "POST":
# TODO: allow explicit removal
for provider in self.request.event.get_payment_providers().values():
if provider.identifier == request.data.get("identifier", ""):
if not provider.multi_use_supported:
# Providers with multi_use_supported will call this themselves
simulated_payments = cs.session_data.get("payments", {})
simulated_payments = [
p
for p in simulated_payments
if p.get("multi_use_supported")
]
simulated_payments.append(
{
"provider": provider.identifier,
"multi_use_supported": False,
"min_value": None,
"max_value": None,
"info_data": {},
}
)
total = self._get_total(
cs,
simulated_payments,
)
else:
total = self._get_total(
cs,
[
p
for p in cs.session_data.get("payments", [])
if p.get("multi_use_supported")
],
)
resp = provider.storefrontapi_prepare(
cs.session_data,
total,
request.data.get("info"),
)
if provider.multi_use_supported:
if resp is True:
# Provider needs to call add_payment_to_cart itself, but we need to remove all previously
# selected ones that don't have multi_use supported. Otherwise, if you first select a credit
# card, then go back and switch to a gift card, you'll have both in the session and the credit
# card has preference, which is unexpected.
cs.session_data["payments"] = [
p
for p in cs.session_data.get("payments", [])
if p.get("multi_use_supported")
]
if provider.identifier not in [
p["provider"]
for p in cs.session_data.get("payments", [])
]:
raise ImproperlyConfigured(
f"Payment provider {provider.identifier} set multi_use_supported "
f"and returned True from payment_prepare, but did not call "
f"add_payment_to_cart"
)
else:
if resp is True or isinstance(resp, str):
# There can only be one payment method that does not have multi_use_supported, remove all
# previous ones.
cs.session_data["payments"] = [
p
for p in cs.session_data.get("payments", [])
if p.get("multi_use_supported")
]
add_payment_to_cart_session(
cs.session_data, provider, None, None, None
)
cs.save(update_fields=["session_data"])
return self._return_checkout_status(cs, 200)
elif request.method == "GET":
available_providers = []
total = self._get_total(
cs,
[
p
for p in cs.session_data.get("payments", [])
if p.get("multi_use_supported")
],
)
for provider in sorted(
self.request.event.get_payment_providers().values(),
key=lambda p: (-p.priority, str(p.public_name).title()),
):
# TODO: do we need a different is_allowed for storefrontapi?
if not provider.is_enabled or not provider.is_allowed(
self.request, total
):
continue
fee = provider.calculate_fee(total)
available_providers.append(
{
"identifier": provider.identifier,
"label": provider.public_name,
"fee": str(fee),
"total": str(total + fee),
}
)
return Response(
data={
"available_providers": available_providers,
},
status=200,
)
@action(detail=True, methods=["PATCH"])
def fields(self, request, *args, **kwargs):
cs = get_object_or_404(
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
)
server_pos = {p.pk: p for p in cs.get_cart_positions(prefetch_questions=True)}
for req_pos in request.data.get("cart_positions", []):
pos = server_pos[req_pos["id"]]
fields = get_position_fields(self.request.event, pos)
fields_data = req_pos["fields_data"]
for f in fields:
if f.identifier in fields_data:
# todo: validation error handing
value = f.validate_input(fields_data[f.identifier])
f.save_input(pos, value)
fields = get_checkout_fields(self.request.event)
fields_data = request.data.get("fields_data", {})
session_data = cs.session_data
for f in fields:
if f.identifier in fields_data:
# todo: validation error handing
value = f.validate_input(fields_data[f.identifier])
f.save_input(session_data, value)
cs.session_data = session_data
cs.save(update_fields=["session_data"])
cs.refresh_from_db()
return self._return_checkout_status(cs, 200)
@action(detail=True, methods=["POST"])
def add_to_cart(self, request, *args, **kwargs):
cs = get_object_or_404(
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
)
serializer = CartAddLineSerializer(
data=request.data.get("lines", []),
many=True,
context={
"event": self.request.event,
},
)
serializer.is_valid(raise_exception=True)
return self._do_async(
cs,
add_items_to_cart,
self.request.event.pk,
serializer.validated_data,
cs.cart_id,
translation.get_language(),
cs.invoice_address.pk if hasattr(cs, "invoice_address") else None,
{},
cs.sales_channel.identifier,
time_machine_now(default=None),
)
@action(detail=True, methods=["POST"])
def confirm(self, request, *args, **kwargs):
cs = get_object_or_404(
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
)
cartpos = cs.get_cart_positions(prefetch_questions=True)
total = sum(p.price for p in cartpos)
try:
fees = get_fees(
self.request.event,
self.request,
total,
(cs.invoice_address if hasattr(cs, "invoice_address") else None),
payments=[], # todo
positions=cartpos,
)
except TaxRule.SaleNotAllowed as e:
raise ValidationError(str(e)) # todo: need better message?
total += sum([f.value for f in fees])
steps = get_steps(
request.event,
cartpos,
getattr(cs, "invoice_address", None),
cs.session_data,
total,
)
for step in steps:
applicable = step.is_applicable()
valid = not applicable or step.is_valid()
if not valid:
raise ValidationError(f"Step {step.identifier} is not valid")
# todo: confirm messages, or integrate them as fields?
meta_info = {
"contact_form_data": cs.session_data.get("contact_form_data", {}),
}
api_meta = {}
for receiver, response in order_meta_from_request.send(
sender=request.event, request=request
):
meta_info.update(response)
for receiver, response in order_api_meta_from_request.send(
sender=request.event, request=request
):
api_meta.update(response)
# todo: delete checkout session
# todo: give info about order
return self._do_async(
cs,
perform_order,
self.request.event.id,
payments=cs.session_data.get("payments", []),
positions=[p.id for p in cartpos],
email=cs.session_data.get("email"),
locale=translation.get_language(),
address=cs.invoice_address.pk if hasattr(cs, "invoice_address") else None,
meta_info=meta_info,
sales_channel=request.sales_channel.identifier,
shown_total=None,
customer=cs.customer,
override_now_dt=time_machine_now(default=None),
api_meta=api_meta,
)
@action(
detail=True,
methods=["GET"],
url_name="task_status",
url_path="task/(?P<asyncid>[^/]+)",
)
def task_status(self, *args, **kwargs):
cs = get_object_or_404(
self.request.event.checkout_sessions, cart_id=kwargs["cart_id"]
)
res = AsyncResult(kwargs["asyncid"])
if res.ready():
if res.successful() and not isinstance(res.info, Exception):
return self._async_success(res, cs)
else:
return self._async_error(res, cs)
return self._async_pending(res, cs)
def _do_async(self, cs, task, *args, **kwargs):
try:
res = task.apply_async(args=args, kwargs=kwargs)
except ConnectionError:
# Task very likely not yet sent, due to redis restarting etc. Let's try once again
res = task.apply_async(args=args, kwargs=kwargs)
if res.ready():
if res.successful() and not isinstance(res.info, Exception):
return self._async_success(res, cs)
else:
return self._async_error(res, cs)
return self._async_pending(res, cs)
def _async_success(self, res, cs):
return Response(
{
"status": "ok",
"checkout_session": self._return_checkout_status(cs).data,
},
status=status.HTTP_200_OK,
)
def _async_error(self, res, cs):
if isinstance(res.info, dict) and res.info["exc_type"] in [
"OrderError",
"CartError",
]:
message = res.info["exc_message"]
elif res.info.__class__.__name__ in ["OrderError", "CartError"]:
message = str(res.info)
else:
logger.error("Unexpected exception: %r" % res.info)
message = _("An unexpected error has occurred, please try again later.")
return Response(
{
"status": "error",
"message": message,
},
status=status.HTTP_409_CONFLICT, # todo: find better status code
)
def _async_pending(self, res, cs):
return Response(
{
"status": "pending",
"check_url": reverse(
"storefrontapi-v1:checkoutsession-task_status",
kwargs={
"organizer": self.request.organizer.slug,
"event": self.request.event.slug,
"cart_id": cs.cart_id,
"asyncid": res.id,
},
request=self.request,
),
},
status=status.HTTP_202_ACCEPTED,
)

View File

@@ -0,0 +1,424 @@
from decimal import Decimal
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers, viewsets
from rest_framework.generics import get_object_or_404
from rest_framework.response import Response
from pretix.base.models import (
Event, Item, ItemCategory, ItemVariation, Quota, SubEvent,
)
from pretix.base.models.tax import TaxedPrice
from pretix.base.storelogic.products import (
get_items_for_product_list, item_group_by_category,
)
from pretix.base.templatetags.rich_text import rich_text
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.storefrontapi.permission import StorefrontEventPermission
from pretix.storefrontapi.serializers import I18nFlattenedModelSerializer
def opt_str(o):
if o is None:
return None
return str(o)
class RichTextField(serializers.Field):
def to_representation(self, value):
return rich_text(value)
class DynamicAttrField(serializers.Field):
def __init__(self, *args, **kwargs):
self.attr = kwargs.pop("attr")
super().__init__(*args, **kwargs)
def to_representation(self, value):
return getattr(value, self.attr)
class EventURLField(serializers.Field):
def to_representation(self, ev):
if isinstance(ev, SubEvent):
return build_absolute_uri(
ev.event, "presale:event.index", kwargs={"subevent": ev.pk}
)
return build_absolute_uri(ev, "presale:event.index")
class EventSettingsField(serializers.Field):
def to_representation(self, ev):
event = ev.event if isinstance(ev, SubEvent) else ev
return {
"display_net_prices": event.settings.display_net_prices,
"show_variations_expanded": event.settings.show_variations_expanded,
"show_times": event.settings.show_times,
"show_dates_on_frontpage": event.settings.show_dates_on_frontpage,
"voucher_explanation_text": str(
rich_text(event.settings.voucher_explanation_text, safelinks=False)
),
"frontpage_text": str(
rich_text(
(
ev.frontpage_text
if isinstance(ev, SubEvent)
else event.settings.frontpage_text
),
safelinks=False,
)
),
}
class CategorySerializer(I18nFlattenedModelSerializer):
description = RichTextField()
class Meta:
model = ItemCategory
fields = [
"id",
"name",
"description",
]
class PricingField(serializers.Field):
def to_representation(self, item_or_var):
if isinstance(item_or_var, Item) and item_or_var.has_variations:
return None
item = item_or_var if isinstance(item_or_var, Item) else item_or_var.item
suggested_price = item_or_var.suggested_price
display_price = item_or_var.display_price
if self.context.get("price_included"):
display_price = TaxedPrice(
gross=Decimal("0.00"),
net=Decimal("0.00"),
tax=Decimal("0.00"),
rate=Decimal("0.00"),
name="",
code=None,
)
if hasattr(item, "initial_price"):
# Pre-select current price for add-ons
suggested_price = item_or_var.initial_price
return {
"display_price": {
"net": opt_str(display_price.net),
"gross": opt_str(display_price.gross),
"tax_rate": opt_str(
display_price.rate if not item.includes_mixed_tax_rate else None
),
"tax_name": opt_str(
display_price.name if not item.includes_mixed_tax_rate else None
),
},
"original_price": (
{
"net": opt_str(item_or_var.original_price.net),
"gross": opt_str(item_or_var.original_price.gross),
"tax_rate": opt_str(
item_or_var.original_price.rate
if not item.includes_mixed_tax_rate
else None
),
"tax_name": opt_str(
item_or_var.original_price.name
if not item.includes_mixed_tax_rate
else None
),
}
if item_or_var.original_price
else None
),
"free_price": item.free_price,
"suggested_price": {
"net": opt_str(suggested_price.net),
"gross": opt_str(suggested_price.gross),
"tax_rate": opt_str(
suggested_price.rate if not item.includes_mixed_tax_rate else None
),
"tax_name": opt_str(
suggested_price.name if not item.includes_mixed_tax_rate else None
),
},
"mandatory_priced_addons": getattr(item, "mandatory_priced_addons", False),
"includes_mixed_tax_rate": item.includes_mixed_tax_rate,
}
class AvailabilityField(serializers.Field):
def to_representation(self, item_or_var):
if isinstance(item_or_var, Item) and item_or_var.has_variations:
return None
item = item_or_var if isinstance(item_or_var, Item) else item_or_var.item
if (
item_or_var.current_unavailability_reason == "require_voucher"
or item.current_unavailability_reason == "require_voucher"
):
return {
"available": False,
"code": "require_voucher",
"message": _("Enter a voucher code below to buy this product."),
"waiting_list": False,
"max_selection": 0,
"quota_left": None,
}
elif (
item_or_var.current_unavailability_reason == "available_from"
or item.current_unavailability_reason == "available_from"
):
return {
"available": False,
"code": "available_from",
"message": _("Not available yet."),
"waiting_list": False,
"max_selection": 0,
"quota_left": None,
}
elif (
item_or_var.current_unavailability_reason == "available_until"
or item.current_unavailability_reason == "available_until"
):
return {
"available": False,
"code": "available_until",
"message": _("Not available any more."),
"waiting_list": False,
"max_selection": 0,
"quota_left": None,
}
elif item_or_var.cached_availability[0] <= Quota.AVAILABILITY_ORDERED:
return {
"available": False,
"code": "sold_out",
"message": _("SOLD OUT"),
"waiting_list": self.context["allow_waitinglist"]
and item.allow_waitinglist,
"max_selection": 0,
"quota_left": 0,
}
elif item_or_var.cached_availability[0] < Quota.AVAILABILITY_OK:
return {
"available": False,
"code": "reserved",
"message": _(
"All remaining products are reserved but might become available again."
),
"waiting_list": self.context["allow_waitinglist"]
and item.allow_waitinglist,
"max_selection": 0,
"quota_left": 0,
}
else:
return {
"available": True,
"code": "ok",
"message": None,
"waiting_list": False,
"max_selection": self.context.get("max_count", item_or_var.order_max),
"quota_left": (
item_or_var.cached_availability[1]
if item.show_quota_left
and item_or_var.cached_availability[1] is not None
else None
),
}
class VariationSerializer(I18nFlattenedModelSerializer):
description = RichTextField()
pricing = PricingField(source="*")
availability = AvailabilityField(source="*")
class Meta:
model = ItemVariation
fields = [
"id",
"value",
"description",
"pricing",
"availability",
]
def to_representation(self, instance):
r = super().to_representation(instance)
if hasattr(instance, "initial"):
# Used for addons
r["initial_count"] = instance.initial
return r
class ItemSerializer(I18nFlattenedModelSerializer):
description = RichTextField()
available_variations = VariationSerializer(many=True, read_only=True)
pricing = PricingField(source="*")
availability = AvailabilityField(source="*")
has_variations = serializers.BooleanField(read_only=True)
class Meta:
model = Item
fields = [
"id",
"name",
"has_variations",
"description",
"picture",
"min_per_order",
"available_variations",
"pricing",
"availability",
]
def to_representation(self, instance):
r = super().to_representation(instance)
if hasattr(instance, "initial"):
# Used for addons
r["initial_count"] = instance.initial
return r
class ProductGroupField(serializers.Field):
def to_representation(self, ev):
event = ev.event if isinstance(ev, SubEvent) else ev
items, display_add_to_cart = get_items_for_product_list(
event,
subevent=ev if isinstance(ev, SubEvent) else None,
require_seat=False,
channel=self.context["sales_channel"],
voucher=None, # TODO
memberships=(
self.context["customer"].usable_memberships(
for_event=ev, testmode=event.testmode
)
if self.context.get("customer")
else None
),
)
return [
{
"category": (
CategorySerializer(cat, context=self.context).data if cat else None
),
"items": ItemSerializer(items, many=True, context=self.context).data,
}
for cat, items in item_group_by_category(items)
]
class BaseEventDetailSerializer(I18nFlattenedModelSerializer):
public_url = EventURLField(source="*", read_only=True)
settings = EventSettingsField(source="*", read_only=True)
class Meta:
model = Event
fields = [
"name",
"has_subevents",
"public_url",
"currency",
"settings",
]
def to_representation(self, ev):
r = super().to_representation(ev)
event = ev.event if isinstance(ev, SubEvent) else ev
if not event.settings.presale_start_show_date or event.presale_is_running:
r["effective_presale_start"] = None
if not event.settings.show_date_to:
r["date_to"] = None
return r
class SubEventDetailSerializer(BaseEventDetailSerializer):
testmode = serializers.BooleanField(source="event.testmode")
has_subevents = serializers.BooleanField(source="event.has_subevents")
product_list = ProductGroupField(source="*")
# todo: vouchers_exist
# todo: date range
# todo: seating, seating waiting list
class Meta:
model = SubEvent
fields = [
"name",
"testmode",
"has_subevents",
"public_url",
"currency",
"settings",
"location",
"date_from",
"date_to",
"date_admission",
"presale_is_running",
"effective_presale_start",
"product_list",
]
class EventDetailSerializer(BaseEventDetailSerializer):
# todo: vouchers_exist
# todo: date range
# todo: seating, seating waiting list
product_list = ProductGroupField(source="*")
class Meta:
model = Event
fields = [
"name",
"testmode",
"has_subevents",
"public_url",
"currency",
"settings",
"location",
"date_from",
"date_to",
"date_admission",
"presale_is_running",
"effective_presale_start",
"product_list",
]
class EventViewSet(viewsets.ViewSet):
queryset = Event.objects.none()
lookup_url_kwarg = "event"
lookup_field = "slug"
permission_classes = [
StorefrontEventPermission,
]
def retrieve(self, request, *args, **kwargs):
event = request.event # Lookup is already done
# todo: prefetch related items
ctx = {
"sales_channel": request.sales_channel,
"customer": None,
"event": event,
"allow_waitinglist": True,
}
if event.has_subevents:
if "subevent" in request.GET:
ctx["event"] = request.event
subevent = get_object_or_404(
request.event.subevents, pk=request.GET.get("subevent"), active=True
)
serializer = SubEventDetailSerializer(subevent, context=ctx)
else:
serializer = BaseEventDetailSerializer(event, context=ctx)
else:
serializer = EventDetailSerializer(event, context=ctx)
return Response(serializer.data)

View File

@@ -0,0 +1,153 @@
#
# 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 logging
from dateutil.parser import parse
from django.http import HttpRequest
from django.urls import resolve
from django.utils.timezone import now
from django_scopes import scope
from rest_framework.response import Response
from pretix.base.middleware import LocaleMiddleware
from pretix.base.models import Event, Organizer
from pretix.base.timemachine import timemachine_now_var
logger = logging.getLogger(__name__)
class ApiMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request: HttpRequest):
if not request.path.startswith("/storefrontapi/"):
return self.get_response(request)
url = resolve(request.path_info)
try:
request.organizer = Organizer.objects.filter(
slug=url.kwargs["organizer"],
).first()
except Organizer.DoesNotExist:
return Response(
{"detail": "Organizer not found."},
status=404,
)
with scope(organizer=getattr(request, "organizer", None)):
# todo: Authorization
is_authorized_public = False # noqa
is_authorized_private = True
sales_channel_id = "web" # todo: get form authorization
if "event" in url.kwargs:
try:
request.event = request.organizer.events.get(
slug=url.kwargs["event"],
organizer=request.organizer,
)
if not request.event.live and not is_authorized_private:
return Response(
{"detail": "Event not live."},
status=403,
)
except Event.DoesNotExist:
return Response(
{"detail": "Event not found."},
status=404,
)
try:
request.sales_channel = request.organizer.sales_channels.get(
identifier=sales_channel_id
)
if (
"X-Storefront-Time-Machine-Date" in request.headers
and "event" in url.kwargs
):
if not request.event.testmode:
return Response(
{
"detail": "Time machine can only be used for events in test mode."
},
status=400,
)
try:
time_machine_date = parse(
request.headers["X-Storefront-Time-Machine-Date"]
)
except ValueError:
return Response(
{"detail": "Invalid time machine header"},
status=400,
)
else:
request.now_dt = time_machine_date
request.now_dt_is_fake = True
timemachine_now_var.set(
request.now_dt if request.now_dt_is_fake else None
)
else:
request.now_dt = now()
request.now_dt_is_fake = False
if (
not request.event.all_sales_channels
and request.sales_channel.identifier
not in (
s.identifier for s in request.event.limit_sales_channels.all()
)
):
return Response(
{"detail": "Event not available on this sales channel."},
status=403,
)
LocaleMiddleware(NotImplementedError).process_request(request)
r = self.get_response(request)
r["Access-Control-Allow-Origin"] = "*" # todo: allow whitelist?
r["Access-Control-Allow-Methods"] = ", ".join(
[
"GET",
"POST",
"HEAD",
"OPTIONS",
"PUT",
"DELETE",
"PATCH",
]
)
r["Access-Control-Allow-Headers"] = ", ".join(
[
"Content-Type",
"X-Storefront-Time-Machine-Date",
"Accept",
"Accept-Language",
]
)
return r
finally:
timemachine_now_var.set(None)

View File

@@ -0,0 +1,30 @@
#
# 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 rest_framework.permissions import BasePermission
class StorefrontEventPermission(BasePermission):
def has_permission(self, request, view):
# TODO: Check middleware results
return True

View File

@@ -0,0 +1,30 @@
from i18nfield.fields import I18nCharField, I18nTextField
from rest_framework.fields import Field
from rest_framework.serializers import ModelSerializer
class I18nFlattenedField(Field):
def __init__(self, **kwargs):
self.allow_blank = kwargs.pop("allow_blank", False)
self.trim_whitespace = kwargs.pop("trim_whitespace", True)
self.max_length = kwargs.pop("max_length", None)
self.min_length = kwargs.pop("min_length", None)
super().__init__(**kwargs)
def to_representation(self, value):
return str(value)
def to_internal_value(self, data):
raise TypeError("Input not supported.")
class I18nFlattenedModelSerializer(ModelSerializer):
pass
I18nFlattenedModelSerializer.serializer_field_mapping[I18nCharField] = (
I18nFlattenedField
)
I18nFlattenedModelSerializer.serializer_field_mapping[I18nTextField] = (
I18nFlattenedField
)

View File

View File

@@ -0,0 +1,122 @@
from collections import UserDict
from decimal import Decimal
from django.test import RequestFactory
from pretix.base.storelogic import IncompleteError
from pretix.base.storelogic.addons import (
addons_is_applicable, addons_is_completed,
)
from pretix.base.storelogic.fields import ensure_fields_are_completed
from pretix.base.storelogic.payment import (
ensure_payment_is_completed, payment_is_applicable,
)
class CheckoutStep:
def __init__(self, event, cart_positions, invoice_address, cart_session, total):
self.event = event
self.cart_positions = cart_positions
self.cart_session = cart_session
self.invoice_address = invoice_address
self.total = total
@property
def identifier(self):
raise NotImplementedError()
def is_applicable(self):
raise NotImplementedError()
def is_valid(self):
raise NotImplementedError()
class AddonStep(CheckoutStep):
identifier = "addons"
def is_applicable(self):
return addons_is_applicable(self.cart_positions)
def is_valid(self):
return addons_is_completed(self.cart_positions)
class FieldsStep(CheckoutStep):
identifier = "fields"
def is_applicable(self):
return True
def is_valid(self):
try:
ensure_fields_are_completed(
self.event,
self.cart_positions,
self.cart_session,
self.invoice_address,
False,
cart_is_free=self.total == Decimal("0.00"),
)
except IncompleteError:
return False
else:
return True
class PaymentStep(CheckoutStep):
identifier = "payment"
@property
def request(self):
# TODO: find a better way to avoid this
rf = RequestFactory()
r = rf.get("/")
r.event = self.event
r.organizer = self.event.organizer
self.cart_session.setdefault("fake_request", {})
cart_id = self.cart_positions[0].cart_id
r.session = UserDict(
{
f"current_cart_event_{self.event.pk}": cart_id,
"carts": {cart_id: self.cart_session},
}
)
r.session.session_key = cart_id
return r
def is_applicable(self):
return payment_is_applicable(
self.event,
self.total,
self.cart_positions,
self.invoice_address,
self.cart_session,
self.request,
)
def is_valid(self):
try:
ensure_payment_is_completed(
self.event,
self.total,
self.cart_session,
self.request,
)
except IncompleteError:
return False
else:
return True
def get_steps(event, cart_positions, invoice_address, cart_session, total):
return [
AddonStep(event, cart_positions, invoice_address, cart_session, total),
FieldsStep(event, cart_positions, invoice_address, cart_session, total),
PaymentStep(event, cart_positions, invoice_address, cart_session, total),
# todo: cross-selling
# todo: customers
# todo: memberships
# todo: plugin signals
# todo: confirmations
]

View File

@@ -0,0 +1,48 @@
#
# 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 importlib
from django.apps import apps
from django.urls import include, re_path
from rest_framework import routers
from .endpoints import checkout, event
storefront_orga_router = routers.DefaultRouter()
storefront_orga_router.register(r"events", event.EventViewSet)
storefront_event_router = routers.DefaultRouter()
storefront_event_router.register(r"checkouts", checkout.CheckoutViewSet)
# Force import of all plugins to give them a chance to register URLs with the router
for app in apps.get_app_configs():
if hasattr(app, "PretixPluginMeta"):
if importlib.util.find_spec(app.name + ".urls"):
importlib.import_module(app.name + ".urls")
urlpatterns = [
re_path(r"^organizers/(?P<organizer>[^/]+)/", include(storefront_orga_router.urls)),
re_path(
r"^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/",
include(storefront_event_router.urls),
),
]

View File

@@ -57,6 +57,7 @@ base_patterns = [
re_path(r'^csp_report/$', csp.csp_report, name='csp.report'),
re_path(r'^agpl_source$', source.get_source, name='source'),
re_path(r'^js_helpers/states/$', js_helpers.states, name='js_helpers.states'),
re_path(r'^storefrontapi/v1/', include(('pretix.storefrontapi.urls', 'pretixstorefrontapi'), namespace='storefrontapi-v1')),
re_path(r'^api/v1/', include(('pretix.api.urls', 'pretixapi'), namespace='api-v1')),
re_path(r'^api/$', RedirectView.as_view(url='/api/v1/'), name='redirect-api-version'),
re_path(r'^.well-known/apple-developer-merchantid-domain-association$',

View File

@@ -1133,49 +1133,6 @@ def test_order_mark_paid_expired_seat_taken(client, env):
assert o.status == Order.STATUS_EXPIRED
@pytest.mark.django_db
def test_order_mark_paid_expired_blocked(client, env):
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
o.expires = now() - timedelta(days=5)
o.status = Order.STATUS_EXPIRED
o.sales_channel = env[0].organizer.sales_channels.get(identifier="bar")
olddate = o.expires
o.save()
seat_a1 = env[0].seats.create(seat_number="A1", product=env[3], seat_guid="A1", blocked=True)
p = o.positions.first()
p.seat = seat_a1
p.save()
q = Quota.objects.create(event=env[0], size=100)
q.items.add(env[3])
client.login(email='dummy@dummy.dummy', password='dummy')
response = client.post('/control/event/dummy/dummy/orders/FOO/transition', {
'status': 'p',
'payment_date': now().date().isoformat(),
'amount': str(o.pending_sum),
'force': 'on'
}, follow=True)
assert b'alert-danger' in response.content
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
assert o.expires.strftime("%Y-%m-%d %H:%M:%S") == olddate.strftime("%Y-%m-%d %H:%M:%S")
assert o.status == Order.STATUS_EXPIRED
env[0].settings.seating_allow_blocked_seats_for_channel = ["bar"]
response = client.post('/control/event/dummy/dummy/orders/FOO/transition', {
'status': 'p',
'payment_date': now().date().isoformat(),
'amount': str(o.pending_sum),
'force': 'on'
}, follow=True)
assert b'alert-success' in response.content
with scopes_disabled():
o = Order.objects.get(id=env[2].id)
assert o.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_order_go_lowercase(client, env):
client.login(email='dummy@dummy.dummy', password='dummy')