mirror of
https://github.com/pretix/pretix.git
synced 2026-07-24 08:11:40 +00:00
Compare commits
203
Commits
sbom
...
pajowu/wallet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d2a5b1fd4 | ||
|
|
8172e47830 | ||
|
|
2a2f87a4ea | ||
|
|
536e1a24a0 | ||
|
|
a1454e1ce3 | ||
|
|
53c042a43a | ||
|
|
db49933cad | ||
|
|
df797ea780 | ||
|
|
8e2b1aadd9 | ||
|
|
07a000ca73 | ||
|
|
ae29c12f15 | ||
|
|
e2a86ec45e | ||
|
|
d16c8d364e | ||
|
|
fccfddfd22 | ||
|
|
e39290885a | ||
|
|
4d37641fc8 | ||
|
|
ccfca5bdf3 | ||
|
|
c0b3b4324a | ||
|
|
46a84e1a87 | ||
|
|
0e19a3d68f | ||
|
|
54035898ff | ||
|
|
3cf275d08a | ||
|
|
429f747597 | ||
|
|
cd41013338 | ||
|
|
d47c435489 | ||
|
|
32e0e31b15 | ||
|
|
ea819530f9 | ||
|
|
09aef94376 | ||
|
|
39fbd25fbb | ||
|
|
214ea5fcd3 | ||
|
|
2de032c2be | ||
|
|
2574b31afd | ||
|
|
5f2264daeb | ||
|
|
4295b02406 | ||
|
|
49247062bc | ||
|
|
f3db461a2a | ||
|
|
83e607b88d | ||
|
|
9609722a98 | ||
|
|
4372aa6725 | ||
|
|
71488f0a02 | ||
|
|
52a4381277 | ||
|
|
bf066909a7 | ||
|
|
1399d37827 | ||
|
|
0c0842c9db | ||
|
|
6e647b41e4 | ||
|
|
7cff4321e0 | ||
|
|
fe5d095ae7 | ||
|
|
ebc31c901a | ||
|
|
2660dfe1bc | ||
|
|
08c376a459 | ||
|
|
6901ed8b26 | ||
|
|
62f1cbadaf | ||
|
|
1386b40e6f | ||
|
|
bbcd1b86b7 | ||
|
|
6894070239 | ||
|
|
7470a7198b | ||
|
|
ddda634ef3 | ||
|
|
3f7064f12b | ||
|
|
6386c99175 | ||
|
|
eb29101d09 | ||
|
|
d007fb9566 | ||
|
|
93cbbea193 | ||
|
|
eacce6d1f8 | ||
|
|
cdd7412fbb | ||
|
|
a6d462c14e | ||
|
|
d5d928cdce | ||
|
|
e0a8b81290 | ||
|
|
6b0419e488 | ||
|
|
48104e0c9f | ||
|
|
7eefb18b67 | ||
|
|
f9022e5a5e | ||
|
|
08c5963d7b | ||
|
|
e8274527cb | ||
|
|
f77aad0eeb | ||
|
|
d5b0059c34 | ||
|
|
47a170ab62 | ||
|
|
93aebffe2c | ||
|
|
7795c711be | ||
|
|
1eb685d4ee | ||
|
|
b8c523cfac | ||
|
|
fae9fff98e | ||
|
|
92cfbd54b9 | ||
|
|
18203e0804 | ||
|
|
52ed8eeb50 | ||
|
|
1e4888bad0 | ||
|
|
c3056a8aae | ||
|
|
4a4cff3c41 | ||
|
|
ffca102a8a | ||
|
|
78f8830f90 | ||
|
|
eb594266a7 | ||
|
|
76e6803eac | ||
|
|
e19908571c | ||
|
|
779ab360df | ||
|
|
723c63008d | ||
|
|
15c194c0a3 | ||
|
|
7f847c3dd2 | ||
|
|
430c6dd269 | ||
|
|
6411648457 | ||
|
|
005b3864b1 | ||
|
|
1ee1c604cf | ||
|
|
e425105dbf | ||
|
|
738b375042 | ||
|
|
d09572d999 | ||
|
|
634754aacb | ||
|
|
14c3baa2aa | ||
|
|
dc1b62fc56 | ||
|
|
ea2c81e6dc | ||
|
|
2bd5e90a86 | ||
|
|
8095134400 | ||
|
|
62f2ed55b2 | ||
|
|
3533450601 | ||
|
|
5fb827c8f5 | ||
|
|
af6727bbe2 | ||
|
|
f0d2733e8d | ||
|
|
90bca935de | ||
|
|
9c752b7263 | ||
|
|
bfdd61a14c | ||
|
|
6aca806239 | ||
|
|
8512a8c4a5 | ||
|
|
ecee6f6c30 | ||
|
|
846ec77c50 | ||
|
|
49c69c816c | ||
|
|
f3868576c5 | ||
|
|
3857361559 | ||
|
|
b0c50de331 | ||
|
|
1dad6bf801 | ||
|
|
d94d4bd57e | ||
|
|
2437f768c9 | ||
|
|
ab621890a2 | ||
|
|
a9135919a1 | ||
|
|
e2c437fd43 | ||
|
|
ba10fc8041 | ||
|
|
7732794317 | ||
|
|
8a47ba7ff5 | ||
|
|
dd2a74557d | ||
|
|
bb9c4fa18d | ||
|
|
ac356acd29 | ||
|
|
54eadaffcc | ||
|
|
617a548b19 | ||
|
|
fe0f7864e7 | ||
|
|
fc4a2f5508 | ||
|
|
4cb32a753f | ||
|
|
3ae9aabcfb | ||
|
|
3270c4e583 | ||
|
|
3b285a89dd | ||
|
|
c1683df1fd | ||
|
|
6fdcbcebd2 | ||
|
|
ddbea7c32e | ||
|
|
3f8ed0f722 | ||
|
|
388eb7de96 | ||
|
|
df4c83d849 | ||
|
|
fa4cec8a2d | ||
|
|
943b319557 | ||
|
|
28b13667ce | ||
|
|
b00d1c9156 | ||
|
|
120317a8f2 | ||
|
|
7d5b00a610 | ||
|
|
83612c7d65 | ||
|
|
7a5f96369a | ||
|
|
7fd6bf41f9 | ||
|
|
458c3d4b83 | ||
|
|
d10d061e45 | ||
|
|
d30bca50f7 | ||
|
|
3903aca7c9 | ||
|
|
493c920aba | ||
|
|
09b7bc00b0 | ||
|
|
2e195c0274 | ||
|
|
18cb9c1816 | ||
|
|
c3e0120f9f | ||
|
|
67f7fec134 | ||
|
|
c2c97f31ca | ||
|
|
fd565ecdb2 | ||
|
|
f35b13b686 | ||
|
|
550bb675f5 | ||
|
|
adc9c9d514 | ||
|
|
8441c4bc7a | ||
|
|
97ff252c09 | ||
|
|
d3ca2ac1e5 | ||
|
|
6eebaaa563 | ||
|
|
c9781f012b | ||
|
|
000bf54105 | ||
|
|
e42d3d632f | ||
|
|
3bf5a5e478 | ||
|
|
a6f31df0d4 | ||
|
|
d40492748a | ||
|
|
f4ca230af7 | ||
|
|
4fb1748bf6 | ||
|
|
9d668af102 | ||
|
|
80fd4a3b2a | ||
|
|
65bb2283d4 | ||
|
|
00751e8911 | ||
|
|
957a066475 | ||
|
|
7e077bdd7e | ||
|
|
e80d84ec3c | ||
|
|
dc3b742d8c | ||
|
|
1f5fe1b237 | ||
|
|
a8997f8971 | ||
|
|
eb068f524c | ||
|
|
f615595547 | ||
|
|
8bd78eefcf | ||
|
|
848f7fa0e5 | ||
|
|
3442a543c8 | ||
|
|
f88c24863d |
@@ -7,6 +7,7 @@ on:
|
||||
- 'src/pretix/static/pretixpresale/widget/**'
|
||||
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
|
||||
- 'src/pretix/plugins/webcheckin/**'
|
||||
- 'src/pretix/plugins/wallet/**'
|
||||
- 'eslint.config.mjs'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
@@ -16,6 +17,7 @@ on:
|
||||
- 'src/pretix/static/pretixpresale/widget/**'
|
||||
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
|
||||
- 'src/pretix/plugins/webcheckin/**'
|
||||
- 'src/pretix/plugins/wallet/**'
|
||||
- 'eslint.config.mjs'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
|
||||
@@ -351,7 +351,8 @@ Endpoints
|
||||
|
||||
:<json boolean error_reason: One of ``canceled``, ``invalid``, ``unpaid``, ``product``, ``rules``, ``revoked``,
|
||||
``incomplete``, ``already_redeemed``, ``blocked``, ``invalid_time``, or ``error``. Required.
|
||||
:<json raw_barcode: The raw barcode you scanned. Required.
|
||||
:<json raw_barcode: The raw barcode or identifier you scanned. Required.
|
||||
:<json raw_source_type: The type of medium you scanned, defaults to ``barcode``. Optional.
|
||||
:<json datetime: Date and time of the scan. Optional.
|
||||
:<json type: Type of scan, defaults to ``"entry"``.
|
||||
:<json position: Internal ID of an order position you matched. Optional.
|
||||
|
||||
@@ -864,6 +864,9 @@ Generating new secrets
|
||||
|
||||
Triggers generation of new ``secret`` and ``web_secret`` attributes for both the order and all order positions.
|
||||
|
||||
Ticket secrets of order positions that have been used to issue a gift card can not
|
||||
be changed. Only the link (``web_secret``) will be changed in this case.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -895,6 +898,9 @@ Generating new secrets
|
||||
|
||||
Triggers generation of a new ``secret`` and ``web_secret`` attribute for a single order position.
|
||||
|
||||
Ticket secrets of order positions that have been used to issue a gift card can not
|
||||
be changed. Only the link (``web_secret``) will be changed in this case.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
@@ -53,7 +53,7 @@ Working with the code
|
||||
---------------------
|
||||
If you do not have a recent installation of ``nodejs``, install it now::
|
||||
|
||||
curl -sL https://deb.nodesource.com/setup_17.x | sudo -E bash -
|
||||
curl -sL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
sudo apt install nodejs
|
||||
|
||||
To make sure it is on your path variable, close and reopen your terminal. Now, install the Python-level dependencies of pretix::
|
||||
|
||||
+2
-2
@@ -18,12 +18,12 @@
|
||||
"doc": "doc"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:control": "vite",
|
||||
"dev:control": "vite --clearScreen=false",
|
||||
"dev:widget": "vite src/pretix/static/pretixpresale/widget",
|
||||
"build": "npm run build:control -s && npm run build:widget -s",
|
||||
"build:control": "vite build",
|
||||
"build:widget": "vite build src/pretix/static/pretixpresale/widget",
|
||||
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin",
|
||||
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin src/pretix/plugins/wallet",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+10
-9
@@ -41,10 +41,10 @@ dependencies = [
|
||||
"django-bootstrap3==26.1",
|
||||
"django-compressor==4.6.0",
|
||||
"django-countries==8.2.*",
|
||||
"django-filter==25.1",
|
||||
"django-filter==26.1",
|
||||
"django-formset-js-improved==0.5.0.5",
|
||||
"django-formtools==2.6.1",
|
||||
"django-hierarkey==2.0.*,>=2.0.1",
|
||||
"django-formtools==2.7",
|
||||
"django-hierarkey==2.0.*,>=2.0.2",
|
||||
"django-hijack==3.7.*",
|
||||
"django-i18nfield==1.11.*",
|
||||
"django-libsass==0.9",
|
||||
@@ -53,7 +53,8 @@ dependencies = [
|
||||
"django-oauth-toolkit==2.3.*",
|
||||
"django-otp==1.7.*",
|
||||
"django-phonenumber-field==8.4.*",
|
||||
"django-redis==6.0.*",
|
||||
"django-querytagger==0.0.3",
|
||||
"django-redis==7.0.*",
|
||||
"django-scopes==2.0.*",
|
||||
"django-statici18n==2.7.*",
|
||||
"djangorestframework==3.17.*",
|
||||
@@ -76,7 +77,7 @@ dependencies = [
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.13.*",
|
||||
"phonenumberslite==9.0.*",
|
||||
"Pillow==12.2.*",
|
||||
"Pillow==12.3.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==7.35.*",
|
||||
"psycopg2-binary",
|
||||
@@ -91,9 +92,9 @@ dependencies = [
|
||||
"pyuca",
|
||||
"qrcode==8.2",
|
||||
"redis==7.4.*",
|
||||
"reportlab==4.5.*",
|
||||
"requests==2.32.*",
|
||||
"sentry-sdk==2.63.*",
|
||||
"reportlab==5.0.*",
|
||||
"requests==2.34.*",
|
||||
"sentry-sdk==2.66.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
@@ -101,7 +102,7 @@ dependencies = [
|
||||
"tqdm==4.*",
|
||||
"ua-parser==1.0.*",
|
||||
"vobject==0.9.*",
|
||||
"webauthn==2.8.*",
|
||||
"webauthn==3.0.*",
|
||||
"zeep==4.3.*"
|
||||
]
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ ignore =
|
||||
src/tests/plugins/stripe/*
|
||||
src/tests/plugins/sendmail/*
|
||||
src/tests/plugins/ticketoutputpdf/*
|
||||
src/tests/plugins/wallet/*
|
||||
.*
|
||||
CODE_OF_CONDUCT.md
|
||||
CONTRIBUTING.md
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ localecompile:
|
||||
./manage.py compilemessages
|
||||
|
||||
localegen:
|
||||
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
|
||||
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
|
||||
./manage.py makemessages --keep-pot --add-location file --ignore "pretix/static/npm_dir/*" $(LNGS)
|
||||
./manage.py makemessages --keep-pot --add-location file -e js,ts,vue -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
|
||||
|
||||
staticfiles: npminstall npmbuild jsi18n
|
||||
./manage.py collectstatic --noinput
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "2026.6.0.dev0"
|
||||
__version__ = "2026.7.0.dev0"
|
||||
|
||||
@@ -66,6 +66,7 @@ INSTALLED_APPS = [
|
||||
'pretix.plugins.returnurl',
|
||||
'pretix.plugins.autocheckin',
|
||||
'pretix.plugins.webcheckin',
|
||||
'pretix.plugins.wallet',
|
||||
'django_countries',
|
||||
'oauth2_provider',
|
||||
'phonenumber_field',
|
||||
@@ -118,6 +119,7 @@ ALL_LANGUAGES = [
|
||||
('sv', _('Swedish')),
|
||||
('es', _('Spanish')),
|
||||
('es-419', _('Spanish (Latin America)')),
|
||||
('th', _('Thai')),
|
||||
('tr', _('Turkish')),
|
||||
('uk', _('Ukrainian')),
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.db.models import prefetch_related_objects
|
||||
from rest_framework import serializers
|
||||
@@ -135,3 +136,30 @@ class SalesChannelMigrationMixin:
|
||||
else:
|
||||
value["sales_channels"] = value["limit_sales_channels"]
|
||||
return value
|
||||
|
||||
|
||||
class CompatDecimalField(serializers.DecimalField):
|
||||
"""
|
||||
Historically, pretix recorded tax rates as decimals with two places. Today, pretix supports tax rates with up to
|
||||
four places. Since our API outputs decimals with the stored precision, this would have changed the API output from
|
||||
"19.00" to "19.0000" without warning. While this is semantically the same thing, we need to assume some pretix API
|
||||
users might run into trouble, either because they treat the value as a string and then map something
|
||||
(e.g. ``if tax_rate == "19.00"``) or process it with a language where this is a significant difference. For example,
|
||||
while in Python ``Decimal("19.00") == Decimal("19.0000")`` is true, in Java
|
||||
``(new BigDecimal("19.00")).equals(new BigDecimal("19.0000"))`` is false and only
|
||||
``(new BigDecimal("19.00")).compareTo(new BigDecimal("19.0000")) == 0`` is true.
|
||||
|
||||
Therefore, we stay backwards compatible by outputting two decimal places *as long as the trailing digits are zero-valued.
|
||||
"""
|
||||
|
||||
regex = re.compile(r"^([0-9]+\.[0-9]{2})0+$")
|
||||
|
||||
def to_representation(self, value):
|
||||
if self.localize:
|
||||
raise ValueError("localization not supported")
|
||||
value = super().to_representation(value)
|
||||
if value and "." not in value:
|
||||
return f"{value}.00"
|
||||
if m := self.regex.match(value):
|
||||
return m.group(1)
|
||||
return value
|
||||
|
||||
@@ -48,7 +48,7 @@ from rest_framework.fields import ChoiceField, Field
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
|
||||
from pretix.api.serializers import (
|
||||
CompatibleJSONField, SalesChannelMigrationMixin,
|
||||
CompatDecimalField, CompatibleJSONField, SalesChannelMigrationMixin,
|
||||
)
|
||||
from pretix.api.serializers.fields import PluginsField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
@@ -681,6 +681,7 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
rate = CompatDecimalField(max_digits=7, decimal_places=4)
|
||||
|
||||
class Meta:
|
||||
model = TaxRule
|
||||
@@ -747,6 +748,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'max_items_per_order',
|
||||
'reservation_time',
|
||||
'contact_mail',
|
||||
'contact_url',
|
||||
'show_variations_expanded',
|
||||
'hide_sold_out',
|
||||
'meta_noindex',
|
||||
|
||||
@@ -42,7 +42,9 @@ from django.utils.functional import cached_property, lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from pretix.api.serializers import SalesChannelMigrationMixin
|
||||
from pretix.api.serializers import (
|
||||
CompatDecimalField, SalesChannelMigrationMixin,
|
||||
)
|
||||
from pretix.api.serializers.event import MetaDataField
|
||||
from pretix.api.serializers.fields import UploadedFileField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
@@ -276,10 +278,10 @@ class ItemAddOnSerializer(serializers.ModelSerializer):
|
||||
return value
|
||||
|
||||
|
||||
class ItemTaxRateField(serializers.Field):
|
||||
class ItemTaxRateField(CompatDecimalField):
|
||||
def to_representation(self, i):
|
||||
if i.tax_rule:
|
||||
return str(Decimal(i.tax_rule.rate))
|
||||
return super().to_representation(Decimal(i.tax_rule.rate))
|
||||
else:
|
||||
return str(Decimal('0.00'))
|
||||
|
||||
@@ -289,7 +291,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
bundles = InlineItemBundleSerializer(many=True, required=False)
|
||||
variations = InlineItemVariationSerializer(many=True, required=False)
|
||||
program_times = InlineItemProgramTimeSerializer(many=True, required=False)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True, max_digits=7, decimal_places=4)
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
||||
'image/png', 'image/jpeg', 'image/gif'
|
||||
|
||||
@@ -41,7 +41,7 @@ from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.relations import SlugRelatedField
|
||||
from rest_framework.reverse import reverse
|
||||
|
||||
from pretix.api.serializers import CompatibleJSONField
|
||||
from pretix.api.serializers import CompatDecimalField, CompatibleJSONField
|
||||
from pretix.api.serializers.event import SubEventSerializer
|
||||
from pretix.api.serializers.forms import form_field_to_serializer_field
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
@@ -52,6 +52,7 @@ from pretix.api.signals import order_api_details, orderposition_api_details
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.invoicing.transmission import get_transmission_types
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress,
|
||||
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
|
||||
@@ -381,6 +382,7 @@ class PrintLogSerializer(serializers.ModelSerializer):
|
||||
class FailedCheckinSerializer(I18nAwareModelSerializer):
|
||||
error_reason = serializers.ChoiceField(choices=Checkin.REASONS, required=True, allow_null=False)
|
||||
raw_barcode = serializers.CharField(required=True, allow_null=False)
|
||||
raw_source_type = serializers.ChoiceField(choices=[(k, v) for k, v in MEDIA_TYPES.items()], default='barcode')
|
||||
position = serializers.PrimaryKeyRelatedField(queryset=OrderPosition.all.none(), required=False, allow_null=True)
|
||||
raw_item = serializers.PrimaryKeyRelatedField(queryset=Item.objects.none(), required=False, allow_null=True)
|
||||
raw_variation = serializers.PrimaryKeyRelatedField(queryset=ItemVariation.objects.none(), required=False, allow_null=True)
|
||||
@@ -390,7 +392,7 @@ class FailedCheckinSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Checkin
|
||||
fields = ('error_reason', 'error_explanation', 'raw_barcode', 'raw_item', 'raw_variation',
|
||||
'raw_subevent', 'nonce', 'datetime', 'type', 'position')
|
||||
'raw_subevent', 'raw_source_type', 'nonce', 'datetime', 'type', 'position')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -591,6 +593,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
country = CompatibleCountryField(source='*')
|
||||
attendee_name = serializers.CharField(required=False)
|
||||
plugin_data = OrderPositionPluginDataField(source='*', allow_null=True, read_only=True)
|
||||
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
|
||||
|
||||
class Meta:
|
||||
list_serializer_class = OrderPositionListSerializer
|
||||
@@ -747,6 +750,8 @@ class OrderPaymentDateField(serializers.DateField):
|
||||
|
||||
|
||||
class OrderFeeSerializer(I18nAwareModelSerializer):
|
||||
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
|
||||
|
||||
class Meta:
|
||||
model = OrderFee
|
||||
fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule',
|
||||
@@ -1911,6 +1916,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer):
|
||||
position = LinePositionField(read_only=True)
|
||||
event_date_from = serializers.DateTimeField(read_only=True, source="period_start")
|
||||
event_date_to = serializers.DateTimeField(read_only=True, source="period_end")
|
||||
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
|
||||
|
||||
class Meta:
|
||||
model = InvoiceLine
|
||||
@@ -1994,6 +2000,7 @@ class BlockedTicketSecretSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class TransactionSerializer(I18nAwareModelSerializer):
|
||||
order = serializers.SlugRelatedField(slug_field="code", read_only=True)
|
||||
tax_rate = CompatDecimalField(max_digits=7, decimal_places=4)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
|
||||
@@ -27,7 +27,7 @@ from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
@@ -492,7 +492,9 @@ class TeamInviteSerializer(serializers.ModelSerializer):
|
||||
def _send_invite(self, instance):
|
||||
mail(
|
||||
instance.email,
|
||||
_('Account invitation'),
|
||||
gettext('You\'ve been invited to join %(organizer)s') % {
|
||||
'organizer': self.context['organizer'].name,
|
||||
},
|
||||
'pretixcontrol/email/invitation.txt',
|
||||
{
|
||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||
@@ -574,6 +576,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
'customer_accounts_require_login_for_order_access',
|
||||
'invoice_regenerate_allowed',
|
||||
'contact_mail',
|
||||
'contact_url',
|
||||
'imprint_url',
|
||||
'organizer_info_text',
|
||||
'event_list_type',
|
||||
|
||||
@@ -1658,6 +1658,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
count_waitinglist=False,
|
||||
force=request.data.get('force', False),
|
||||
send_mail=send_mail,
|
||||
ignore_date=request.data.get('force', False),
|
||||
)
|
||||
except Quota.QuotaExceededException:
|
||||
pass
|
||||
@@ -1693,7 +1694,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
auth=self.request.auth,
|
||||
count_waitinglist=False,
|
||||
send_mail=send_mail,
|
||||
force=force)
|
||||
force=force,
|
||||
ignore_date=force)
|
||||
except Quota.QuotaExceededException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except PaymentException as e:
|
||||
|
||||
@@ -23,6 +23,7 @@ import sys
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape, format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext
|
||||
|
||||
@@ -35,21 +36,23 @@ def get_powered_by(request, safelink=True):
|
||||
d = gs.settings.license_check_input
|
||||
if d.get('poweredby_name'):
|
||||
if d.get('poweredby_url'):
|
||||
msg = gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>').format(
|
||||
msg = format_html(
|
||||
gettext('<a {a_name_attr}>powered by {name}</a> <a {a_attr}>based on pretix</a>'),
|
||||
name=d['poweredby_name'],
|
||||
a_name_attr='href="{}" target="_blank" rel="noopener"'.format(
|
||||
sl(d['poweredby_url']) if safelink else d['poweredby_url'],
|
||||
),
|
||||
a_attr='href="{}" target="_blank" rel="noopener"'.format(
|
||||
a_name_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format(
|
||||
escape(sl(d['poweredby_url'])) if safelink else escape(d['poweredby_url']),
|
||||
)),
|
||||
a_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format(
|
||||
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
|
||||
)
|
||||
))
|
||||
)
|
||||
else:
|
||||
msg = gettext('<a {a_attr}>powered by {name} based on pretix</a>').format(
|
||||
msg = format_html(
|
||||
gettext('<a {a_attr}>powered by {name} based on pretix</a>'),
|
||||
name=d['poweredby_name'],
|
||||
a_attr='href="{}" target="_blank" rel="noopener"'.format(
|
||||
a_attr=mark_safe('href="{}" target="_blank" rel="noopener"'.format(
|
||||
sl('https://pretix.eu') if safelink else 'https://pretix.eu',
|
||||
)
|
||||
))
|
||||
)
|
||||
else:
|
||||
msg = gettext('<a %(a_attr)s>ticketing powered by pretix</a>') % {
|
||||
|
||||
@@ -57,6 +57,8 @@ logger = logging.getLogger('pretix.base.email')
|
||||
|
||||
T = TypeVar("T", bound=EmailBackend)
|
||||
|
||||
_cgnat_net = ipaddress.ip_network('100.64.0.0/10')
|
||||
|
||||
|
||||
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
|
||||
try:
|
||||
@@ -253,12 +255,15 @@ def create_connection(address, timeout=socket.getdefaulttimeout(),
|
||||
|
||||
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
|
||||
ip_addr = ipaddress.ip_address(sa[0])
|
||||
check_ip4 = ip_addr.ipv4_mapped if getattr(ip_addr, "ipv4_mapped", None) else ip_addr
|
||||
if ip_addr.is_multicast:
|
||||
raise socket.error(f"Request to multicast address {sa[0]} blocked")
|
||||
if ip_addr.is_loopback or ip_addr.is_link_local:
|
||||
raise socket.error(f"Request to local address {sa[0]} blocked")
|
||||
if ip_addr.is_private:
|
||||
raise socket.error(f"Request to private address {sa[0]} blocked")
|
||||
if check_ip4 in _cgnat_net:
|
||||
raise socket.error(f"Request to RFC 6598 address {sa[0]} blocked")
|
||||
|
||||
sock = None
|
||||
try:
|
||||
|
||||
@@ -33,8 +33,6 @@
|
||||
# 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 hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
@@ -42,13 +40,12 @@ from django.conf import settings
|
||||
from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
)
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.metrics import pretix_failed_logins
|
||||
from pretix.base.models import User
|
||||
from pretix.helpers.dicts import move_to_end
|
||||
from pretix.helpers.http import get_client_ip
|
||||
from pretix.helpers.ratelimit import rate_limit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,40 +82,20 @@ class LoginForm(forms.Form):
|
||||
else:
|
||||
move_to_end(self.fields, 'keep_logged_in')
|
||||
|
||||
@cached_property
|
||||
def ratelimit_key(self):
|
||||
if not settings.HAS_REDIS:
|
||||
return None
|
||||
client_ip = get_client_ip(self.request)
|
||||
if not client_ip:
|
||||
return None
|
||||
try:
|
||||
client_ip = ipaddress.ip_address(client_ip)
|
||||
except ValueError:
|
||||
# Web server not set up correctly
|
||||
return None
|
||||
if client_ip.is_private:
|
||||
# This is the private IP of the server, web server not set up correctly
|
||||
return None
|
||||
return 'pretix_login_{}'.format(hashlib.sha1(str(client_ip).encode()).hexdigest())
|
||||
|
||||
def clean(self):
|
||||
if all(k in self.cleaned_data for k, f in self.fields.items() if f.required):
|
||||
if self.ratelimit_key:
|
||||
from django_redis import get_redis_connection
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.get(self.ratelimit_key)
|
||||
if cnt and int(cnt) > 10:
|
||||
pretix_failed_logins.inc(1, reason="ratelimit")
|
||||
logger.info("Backend login rejected due to rate limit.")
|
||||
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
|
||||
rate_limit_kwargs = dict(include_ip_from_request=self.request, max_num=10, expire_time=300)
|
||||
if rate_limit("login", **rate_limit_kwargs, increase=False):
|
||||
# Check rate limit without counting up, we increase below only on failed logins
|
||||
pretix_failed_logins.inc(1, reason="ratelimit")
|
||||
logger.info("Backend login rejected due to rate limit.")
|
||||
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
|
||||
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
|
||||
if self.user_cache is None:
|
||||
if self.ratelimit_key:
|
||||
rc.incr(self.ratelimit_key)
|
||||
rc.expire(self.ratelimit_key, 300)
|
||||
logger.info("Backend login invalid.")
|
||||
pretix_failed_logins.inc(1, reason="invalid")
|
||||
# Count towards rate limit (result is ignored, we are checking above)
|
||||
rate_limit("login", **rate_limit_kwargs)
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['invalid_login'],
|
||||
code='invalid_login'
|
||||
|
||||
@@ -53,6 +53,7 @@ from django.db.models import QuerySet
|
||||
from django.forms import Select, widgets
|
||||
from django.forms.widgets import FILE_INPUT_CONTRADICTION
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.functional import lazy
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.text import format_lazy
|
||||
@@ -324,16 +325,21 @@ class WrappedPhonePrefixSelect(Select):
|
||||
initial = None
|
||||
|
||||
def __init__(self, initial=None):
|
||||
choices = [("", "---------")]
|
||||
def _get_choices():
|
||||
choices = [("", "---------")]
|
||||
|
||||
if initial:
|
||||
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
|
||||
continue
|
||||
if initial in values:
|
||||
self.initial = "+%d" % prefix
|
||||
break
|
||||
choices += get_phone_prefixes_sorted_and_localized()
|
||||
return choices
|
||||
|
||||
choices = lazy(_get_choices, list)()
|
||||
|
||||
if initial:
|
||||
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
|
||||
continue
|
||||
if initial in values:
|
||||
self.initial = "+%d" % prefix
|
||||
break
|
||||
choices += get_phone_prefixes_sorted_and_localized()
|
||||
super().__init__(choices=choices, attrs={
|
||||
'aria-label': pgettext_lazy('phonenumber', 'International area code'),
|
||||
'autocomplete': 'tel-country-code',
|
||||
@@ -953,7 +959,7 @@ class BaseQuestionsForm(forms.Form):
|
||||
label=label, required=required,
|
||||
help_text=help_text,
|
||||
initial=_initial,
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
widget=TimePickerWidget(without_seconds=True),
|
||||
)
|
||||
elif q.type == Question.TYPE_DATETIME:
|
||||
if not help_text:
|
||||
@@ -1110,6 +1116,13 @@ class BaseQuestionsForm(forms.Form):
|
||||
if q.dependency_question_id and not question_is_visible(q.dependency_question_id, q.dependency_values) and answer is not None:
|
||||
d['question_%d' % q.pk] = None
|
||||
|
||||
# Strip False answers to required yes/no questions even if all_optional is set, as our data model assumes that
|
||||
# required yes/no questions can only be answered with yes
|
||||
for q in question_cache.values():
|
||||
if q.required and q.type == Question.TYPE_BOOLEAN:
|
||||
if 'question_%d' % q.pk in d and d['question_%d' % q.pk] is False:
|
||||
del d['question_%d' % q.pk]
|
||||
|
||||
return d
|
||||
|
||||
|
||||
@@ -1398,7 +1411,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
elif self.validate_vat_id and vat_id_applicable:
|
||||
try:
|
||||
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
|
||||
self.instance.vat_id_validated = True
|
||||
self.instance.vat_id_validated = bool(normalized_id)
|
||||
self.instance.vat_id = data['vat_id'] = normalized_id
|
||||
except VATIDFinalError as e:
|
||||
if self.all_optional:
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
@@ -46,6 +45,7 @@ from pytz import common_timezones
|
||||
from pretix.base.models import User
|
||||
from pretix.control.forms import SingleLanguageWidget
|
||||
from pretix.helpers.format import format_map
|
||||
from pretix.helpers.ratelimit import rate_limit
|
||||
|
||||
|
||||
class UserSettingsForm(forms.ModelForm):
|
||||
@@ -128,16 +128,11 @@ class UserPasswordChangeForm(forms.Form):
|
||||
def clean_old_pw(self):
|
||||
old_pw = self.cleaned_data.get('old_pw')
|
||||
|
||||
if settings.HAS_REDIS:
|
||||
from django_redis import get_redis_connection
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
|
||||
rc.expire('pretix_pwchange_%s' % self.user.pk, 300)
|
||||
if cnt > 10:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
if rate_limit("pwchange", self.user.pk, max_num=10, expire_time=300):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
|
||||
if not check_password(old_pw, self.user.password):
|
||||
raise forms.ValidationError(
|
||||
@@ -175,19 +170,35 @@ class UserEmailChangeForm(forms.Form):
|
||||
error_messages = {
|
||||
'duplicate_identifier': _("There already is an account associated with this email address. "
|
||||
"Please choose a different one."),
|
||||
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||
}
|
||||
old_email = forms.EmailField(label=_('Old email address'), disabled=True)
|
||||
new_email = forms.EmailField(label=_('New email address'))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop('user')
|
||||
self.request = kwargs.pop('request')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_new_email(self):
|
||||
email = self.cleaned_data['new_email']
|
||||
|
||||
if rate_limit("emailchange_attempt", include_ip_from_request=self.request, max_num=5, expire_time=300):
|
||||
# Rate limit lookup for conflicting email addresses to make enumeration harder
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
|
||||
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists():
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_identifier'],
|
||||
code='duplicate_identifier',
|
||||
)
|
||||
|
||||
if rate_limit("emailchange", self.user.pk, max_num=2, expire_time=300):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['rate_limit'],
|
||||
code='rate_limit',
|
||||
)
|
||||
return email
|
||||
|
||||
@@ -43,6 +43,10 @@ from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.helpers.format import PlainHtmlAlternativeString
|
||||
from pretix.helpers.i18n import (
|
||||
get_format_without_seconds, get_javascript_format,
|
||||
get_javascript_format_without_seconds,
|
||||
)
|
||||
|
||||
|
||||
def replace_arabic_numbers(inp):
|
||||
@@ -108,7 +112,7 @@ class DatePickerWidget(forms.DateInput):
|
||||
|
||||
|
||||
class TimePickerWidget(forms.TimeInput):
|
||||
def __init__(self, attrs=None, time_format=None):
|
||||
def __init__(self, attrs=None, time_format=None, without_seconds=False):
|
||||
attrs = attrs or {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
@@ -117,8 +121,27 @@ class TimePickerWidget(forms.TimeInput):
|
||||
time_attrs['class'] += ' timepickerfield'
|
||||
time_attrs['autocomplete'] = 'off'
|
||||
|
||||
if time_format or without_seconds:
|
||||
# Explicitly set data-format attributes for the JS layer instead of relying on the body-wide config
|
||||
def time_format_attr():
|
||||
if without_seconds:
|
||||
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
|
||||
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
|
||||
|
||||
time_attrs['data-format'] = lazy(time_format_attr, str)
|
||||
|
||||
def time_format_attr():
|
||||
if without_seconds:
|
||||
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
|
||||
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
|
||||
|
||||
time_attrs['data-format'] = lazy(time_format_attr, str)
|
||||
|
||||
def placeholder():
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
if without_seconds:
|
||||
tf = time_format or get_format_without_seconds('TIME_INPUT_FORMATS')
|
||||
else:
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
@@ -182,7 +205,7 @@ class UploadedFileWidget(forms.ClearableFileInput):
|
||||
class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
template_name = 'pretixbase/forms/widgets/splitdatetime.html'
|
||||
|
||||
def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None):
|
||||
def __init__(self, attrs=None, date_format=None, time_format=None, min_date=None, max_date=None, without_seconds=False):
|
||||
attrs = attrs or {}
|
||||
if 'placeholder' in attrs:
|
||||
del attrs['placeholder']
|
||||
@@ -205,14 +228,36 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
max_date if not isinstance(max_date, datetime) else max_date.astimezone(get_current_timezone()).date()
|
||||
).isoformat()
|
||||
|
||||
if date_format or time_format or without_seconds:
|
||||
# Explicitly set data-format attributes for the JS layer instead of relying on the body-wide config
|
||||
def date_format_attr():
|
||||
if without_seconds:
|
||||
return get_javascript_format_without_seconds(date_format or "DATE_INPUT_FORMATS")
|
||||
return get_javascript_format(date_format or "DATE_INPUT_FORMATS")
|
||||
|
||||
date_attrs['data-format'] = lazy(date_format_attr, str)
|
||||
|
||||
def time_format_attr():
|
||||
if without_seconds:
|
||||
return get_javascript_format_without_seconds(time_format or "TIME_INPUT_FORMATS")
|
||||
return get_javascript_format(time_format or "TIME_INPUT_FORMATS")
|
||||
|
||||
time_attrs['data-format'] = lazy(time_format_attr, str)
|
||||
|
||||
def date_placeholder():
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
if without_seconds:
|
||||
df = date_format or get_format_without_seconds('DATE_INPUT_FORMATS')
|
||||
else:
|
||||
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
|
||||
).strftime(df)
|
||||
|
||||
def time_placeholder():
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
if without_seconds:
|
||||
tf = time_format or get_format_without_seconds('TIME_INPUT_FORMATS')
|
||||
else:
|
||||
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
|
||||
return now().replace(
|
||||
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
|
||||
).strftime(tf)
|
||||
|
||||
@@ -109,7 +109,7 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
|
||||
|
||||
|
||||
def get_babel_locale():
|
||||
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal
|
||||
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such as de_Informal
|
||||
# Also, this returns best-effort region information for number formatting etc
|
||||
current_language = translation.get_language()
|
||||
current_region = getattr(_active_region, "value", None)
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
import datetime
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import textwrap
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
@@ -58,8 +56,8 @@ from pretix.base.services.currencies import SOURCE_NAMES
|
||||
from pretix.base.signals import register_invoice_renderers
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.helpers.reportlab import (
|
||||
FontFallbackParagraph, ThumbnailingImageReader, register_ttf_font_if_new,
|
||||
reshaper,
|
||||
FontFallbackParagraph, PlainTextParagraph, ThumbnailingImageReader,
|
||||
normalize_text, register_ttf_font_if_new, reshaper,
|
||||
)
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
@@ -259,18 +257,8 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
|
||||
|
||||
def _normalize(self, text):
|
||||
# reportlab does not support unicode combination characters
|
||||
# It's important we do this before we use ArabicReshaper
|
||||
text = unicodedata.normalize("NFKC", text)
|
||||
|
||||
# reportlab does not support RTL, ligature-heavy scripts like Arabic. Therefore, we use ArabicReshaper
|
||||
# to resolve all ligatures and python-bidi to switch RTL texts.
|
||||
try:
|
||||
text = "<br />".join(get_display(reshaper.reshape(l)) for l in re.split("<br ?/>", text))
|
||||
except:
|
||||
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
|
||||
|
||||
return text
|
||||
# alias kept for plugin compatibility
|
||||
return normalize_text(text)
|
||||
|
||||
def _upper(self, val):
|
||||
# We uppercase labels, but not in every language
|
||||
@@ -351,10 +339,15 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
return 'invoice.pdf', 'application/pdf', buffer.read()
|
||||
|
||||
def _clean_text(self, text, tags=None):
|
||||
return self._normalize(bleach.clean(
|
||||
text,
|
||||
tags=set(tags) if tags else set()
|
||||
).strip().replace('<br>', '<br />').replace('\n', '<br />\n'))
|
||||
# For backwards compatibility with customer content, we need to support tags like <br> and <b> in a few text
|
||||
# fields. Therefore, we can't use PlainTextParagraph for these, but run bleach instead to limit the allowed
|
||||
# tags.
|
||||
return self._normalize(
|
||||
bleach.clean(
|
||||
text,
|
||||
tags=set(tags) if tags else set()
|
||||
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')
|
||||
)
|
||||
|
||||
|
||||
class PaidMarker(Flowable):
|
||||
@@ -405,8 +398,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
invoice_to_top = 52 * mm
|
||||
|
||||
def _draw_invoice_to(self, canvas):
|
||||
p = FontFallbackParagraph(self._clean_text(self.invoice.address_invoice_to),
|
||||
style=self.stylesheet['Normal'])
|
||||
p = PlainTextParagraph(self.invoice.address_invoice_to, style=self.stylesheet['Normal'])
|
||||
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
|
||||
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
|
||||
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
|
||||
@@ -417,8 +409,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
invoice_from_top = 17 * mm
|
||||
|
||||
def _draw_invoice_from(self, canvas):
|
||||
p = FontFallbackParagraph(
|
||||
self._clean_text(self.invoice.full_invoice_from),
|
||||
p = PlainTextParagraph(
|
||||
self.invoice.full_invoice_from,
|
||||
style=self.stylesheet['InvoiceFrom']
|
||||
)
|
||||
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
|
||||
@@ -548,13 +540,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
def _draw_event(self, canvas):
|
||||
def shorten(txt):
|
||||
txt = str(txt)
|
||||
txt = bleach.clean(txt, tags=set()).strip()
|
||||
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
|
||||
p = PlainTextParagraph(txt, style=self.stylesheet['Normal'])
|
||||
p_size = p.wrap(self.event_width, self.event_height)
|
||||
|
||||
while p_size[1] > 2 * self.stylesheet['Normal'].leading:
|
||||
txt = ' '.join(txt.replace('…', '').split()[:-1]) + '…'
|
||||
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
|
||||
p = PlainTextParagraph(txt, style=self.stylesheet['Normal'])
|
||||
p_size = p.wrap(self.event_width, self.event_height)
|
||||
return txt
|
||||
|
||||
@@ -572,7 +563,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
else:
|
||||
p_str = shorten(self.invoice.event.name)
|
||||
|
||||
p = FontFallbackParagraph(self._normalize(p_str.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
|
||||
p = PlainTextParagraph(p_str, style=self.stylesheet['Normal'])
|
||||
p.wrapOn(canvas, self.event_width, self.event_height)
|
||||
p_size = p.wrap(self.event_width, self.event_height)
|
||||
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
|
||||
@@ -645,39 +636,37 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
type_info_text = self.invoice.transmission_type_instance.pdf_info_text()
|
||||
if type_info_text:
|
||||
story.append(FontFallbackParagraph(
|
||||
story.append(PlainTextParagraph(
|
||||
type_info_text,
|
||||
self.stylesheet['WarningBlock']
|
||||
))
|
||||
|
||||
if self.invoice.custom_field:
|
||||
story.append(FontFallbackParagraph(
|
||||
story.append(PlainTextParagraph(
|
||||
'{}: {}'.format(
|
||||
self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)),
|
||||
self._clean_text(self.invoice.custom_field),
|
||||
str(self.invoice.event.settings.invoice_address_custom_field),
|
||||
self.invoice.custom_field,
|
||||
),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.internal_reference:
|
||||
story.append(FontFallbackParagraph(
|
||||
self._normalize(pgettext('invoice', 'Customer reference: {reference}').format(
|
||||
reference=self._clean_text(self.invoice.internal_reference),
|
||||
)),
|
||||
story.append(PlainTextParagraph(
|
||||
pgettext('invoice', 'Customer reference: {reference}').format(
|
||||
reference=self.invoice.internal_reference,
|
||||
),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.invoice_to_vat_id:
|
||||
story.append(FontFallbackParagraph(
|
||||
self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' +
|
||||
self._clean_text(self.invoice.invoice_to_vat_id),
|
||||
story.append(PlainTextParagraph(
|
||||
pgettext('invoice', 'Customer VAT ID') + ': ' + self.invoice.invoice_to_vat_id,
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.invoice_to_beneficiary:
|
||||
story.append(FontFallbackParagraph(
|
||||
self._normalize(pgettext('invoice', 'Beneficiary')) + ':<br />' +
|
||||
self._clean_text(self.invoice.invoice_to_beneficiary),
|
||||
story.append(PlainTextParagraph(
|
||||
pgettext('invoice', 'Beneficiary') + ':\n' + self.invoice.invoice_to_beneficiary,
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
@@ -707,11 +696,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
story = [
|
||||
NextPageTemplate('FirstPage'),
|
||||
FontFallbackParagraph(
|
||||
self._normalize(
|
||||
PlainTextParagraph(
|
||||
(
|
||||
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
|
||||
else pgettext('invoice', 'Invoice')
|
||||
) if not self.invoice.is_cancellation else self._normalize(pgettext('invoice', 'Cancellation')),
|
||||
) if not self.invoice.is_cancellation else pgettext('invoice', 'Cancellation'),
|
||||
self.stylesheet['Heading1']
|
||||
),
|
||||
Spacer(1, 5 * mm),
|
||||
@@ -733,17 +722,17 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
]
|
||||
if has_taxes:
|
||||
tdata = [(
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']),
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']),
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Description'), self.stylesheet['Bold']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Qty'), self.stylesheet['BoldRightNoSplit']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Tax rate'), self.stylesheet['BoldRightNoSplit']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Net'), self.stylesheet['BoldRightNoSplit']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Gross'), self.stylesheet['BoldRightNoSplit']),
|
||||
)]
|
||||
else:
|
||||
tdata = [(
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Description'), self.stylesheet['Bold']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Qty'), self.stylesheet['BoldRightNoSplit']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Amount'), self.stylesheet['BoldRightNoSplit']),
|
||||
)]
|
||||
|
||||
def _group_key(line):
|
||||
@@ -780,8 +769,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
max_height = self.stylesheet['Normal'].leading * 5
|
||||
p_style = self.stylesheet['Normal']
|
||||
for __ in range(1000):
|
||||
p = FontFallbackParagraph(
|
||||
self._clean_text(curr_description, tags=['br']),
|
||||
p = PlainTextParagraph(
|
||||
curr_description,
|
||||
p_style
|
||||
)
|
||||
h = p.wrap(max_width, doc.height)[1]
|
||||
@@ -862,7 +851,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
# Group together at the end of the invoice
|
||||
request_show_service_date = period_line
|
||||
elif period_line:
|
||||
description_p_list.append(FontFallbackParagraph(
|
||||
description_p_list.append(PlainTextParagraph(
|
||||
period_line,
|
||||
self.stylesheet['Fineprint']
|
||||
))
|
||||
@@ -874,7 +863,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
net_price=money_filter(net_value, self.invoice.event.currency),
|
||||
gross_price=money_filter(gross_value, self.invoice.event.currency),
|
||||
)
|
||||
description_p_list.append(FontFallbackParagraph(
|
||||
description_p_list.append(PlainTextParagraph(
|
||||
single_price_line,
|
||||
self.stylesheet['Fineprint']
|
||||
))
|
||||
@@ -883,11 +872,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
description_p_list.pop(0),
|
||||
str(len(lines)),
|
||||
localize(tax_rate) + " %",
|
||||
FontFallbackParagraph(
|
||||
PlainTextParagraph(
|
||||
money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
||||
self.stylesheet['NormalRight']
|
||||
),
|
||||
FontFallbackParagraph(
|
||||
PlainTextParagraph(
|
||||
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
||||
self.stylesheet['NormalRight']
|
||||
),
|
||||
@@ -904,14 +893,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
single_price_line = pgettext('invoice', 'Single price: {price}').format(
|
||||
price=money_filter(gross_value, self.invoice.event.currency),
|
||||
)
|
||||
description_p_list.append(FontFallbackParagraph(
|
||||
description_p_list.append(PlainTextParagraph(
|
||||
single_price_line,
|
||||
self.stylesheet['Fineprint']
|
||||
))
|
||||
tdata.append((
|
||||
description_p_list.pop(0),
|
||||
str(len(lines)),
|
||||
FontFallbackParagraph(
|
||||
PlainTextParagraph(
|
||||
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
||||
self.stylesheet['NormalRight']
|
||||
),
|
||||
@@ -944,12 +933,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
if has_taxes:
|
||||
tdata.append([
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
|
||||
PlainTextParagraph(pgettext('invoice', 'Invoice total'), self.stylesheet['Bold']), '', '', '',
|
||||
money_filter(total, self.invoice.event.currency)
|
||||
])
|
||||
else:
|
||||
tdata.append([
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
|
||||
PlainTextParagraph(pgettext('invoice', 'Invoice total'), self.stylesheet['Bold']), '',
|
||||
money_filter(total, self.invoice.event.currency)
|
||||
])
|
||||
|
||||
@@ -958,12 +947,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
pending_sum = self.invoice.order.pending_sum
|
||||
if pending_sum != total:
|
||||
tdata.append(
|
||||
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] +
|
||||
[PlainTextParagraph(pgettext('invoice', 'Received payments'), self.stylesheet['Normal'])] +
|
||||
(['', '', ''] if has_taxes else ['']) +
|
||||
[money_filter(pending_sum - total, self.invoice.event.currency)]
|
||||
)
|
||||
tdata.append(
|
||||
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] +
|
||||
[PlainTextParagraph(pgettext('invoice', 'Outstanding payments'), self.stylesheet['Bold'])] +
|
||||
(['', '', ''] if has_taxes else ['']) +
|
||||
[money_filter(pending_sum, self.invoice.event.currency)]
|
||||
)
|
||||
@@ -980,12 +969,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
s=Sum('amount')
|
||||
)['s'] or Decimal('0.00')
|
||||
tdata.append(
|
||||
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] +
|
||||
[PlainTextParagraph(pgettext('invoice', 'Paid by gift card'), self.stylesheet['Normal'])] +
|
||||
(['', '', ''] if has_taxes else ['']) +
|
||||
[money_filter(giftcard_sum, self.invoice.event.currency)]
|
||||
)
|
||||
tdata.append(
|
||||
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] +
|
||||
[PlainTextParagraph(pgettext('invoice', 'Remaining amount'), self.stylesheet['Bold'])] +
|
||||
(['', '', ''] if has_taxes else ['']) +
|
||||
[money_filter(total - giftcard_sum, self.invoice.event.currency)]
|
||||
)
|
||||
@@ -1008,14 +997,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
story.append(Spacer(1, 10 * mm))
|
||||
|
||||
if request_show_service_date:
|
||||
story.append(FontFallbackParagraph(
|
||||
self._normalize(pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date)),
|
||||
story.append(PlainTextParagraph(
|
||||
pgettext('invoice', 'Invoice period: {daterange}').format(daterange=request_show_service_date),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
if self.invoice.payment_provider_text:
|
||||
story.append(FontFallbackParagraph(
|
||||
self._normalize(self.invoice.payment_provider_text),
|
||||
self._clean_text(self.invoice.payment_provider_text, tags=['br', 'b']),
|
||||
self.stylesheet['Normal']
|
||||
))
|
||||
|
||||
@@ -1039,10 +1028,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
|
||||
]
|
||||
thead = [
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']),
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']),
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']),
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Tax rate'), self.stylesheet['Fineprint']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Net value'), self.stylesheet['FineprintRight']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Gross value'), self.stylesheet['FineprintRight']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Tax'), self.stylesheet['FineprintRight']),
|
||||
''
|
||||
]
|
||||
tdata = [thead]
|
||||
@@ -1053,7 +1042,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
continue
|
||||
tax = taxvalue_map[idx]
|
||||
tdata.append([
|
||||
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
|
||||
PlainTextParagraph(localize(rate) + " % " + name, self.stylesheet['Fineprint']),
|
||||
money_filter(gross - tax, self.invoice.event.currency),
|
||||
money_filter(gross, self.invoice.event.currency),
|
||||
money_filter(tax, self.invoice.event.currency),
|
||||
@@ -1072,7 +1061,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
table.setStyle(TableStyle(tstyledata))
|
||||
story.append(Spacer(5 * mm, 5 * mm))
|
||||
story.append(KeepTogether([
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']),
|
||||
PlainTextParagraph(pgettext('invoice', 'Included taxes'), self.stylesheet['FineprintHeading']),
|
||||
table
|
||||
]))
|
||||
|
||||
@@ -1089,7 +1078,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
net = gross - tax
|
||||
|
||||
tdata.append([
|
||||
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
|
||||
PlainTextParagraph(localize(rate) + " % " + name, self.stylesheet['Fineprint']),
|
||||
fmt(net), fmt(gross), fmt(tax), ''
|
||||
])
|
||||
|
||||
@@ -1098,13 +1087,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
story.append(KeepTogether([
|
||||
Spacer(1, height=2 * mm),
|
||||
FontFallbackParagraph(
|
||||
self._normalize(pgettext(
|
||||
PlainTextParagraph(
|
||||
pgettext(
|
||||
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
|
||||
'{date}, this corresponds to:'
|
||||
).format(rate=localize(self.invoice.foreign_currency_rate),
|
||||
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
|
||||
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"))),
|
||||
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT")),
|
||||
self.stylesheet['Fineprint']
|
||||
),
|
||||
Spacer(1, height=3 * mm),
|
||||
@@ -1113,14 +1102,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
|
||||
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
|
||||
story.append(Spacer(1, 5 * mm))
|
||||
story.append(FontFallbackParagraph(self._normalize(
|
||||
story.append(PlainTextParagraph(
|
||||
pgettext(
|
||||
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
|
||||
'{date}, the invoice total corresponds to {total}.'
|
||||
).format(rate=localize(self.invoice.foreign_currency_rate),
|
||||
date=date_format(self.invoice.foreign_currency_rate_date, "SHORT_DATE_FORMAT"),
|
||||
authority=SOURCE_NAMES.get(self.invoice.foreign_currency_source, "?"),
|
||||
total=fmt(foreign_total))),
|
||||
total=fmt(foreign_total)),
|
||||
self.stylesheet['Fineprint']
|
||||
))
|
||||
|
||||
@@ -1162,11 +1151,8 @@ class Modern1Renderer(ClassicInvoiceRenderer):
|
||||
def _draw_invoice_from(self, canvas):
|
||||
if not self.invoice.address_invoice_from:
|
||||
return
|
||||
c = [
|
||||
self._clean_text(l)
|
||||
for l in self.invoice.address_invoice_from.strip().split('\n')
|
||||
]
|
||||
p = FontFallbackParagraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender'])
|
||||
c = self.invoice.address_invoice_from.strip().split('\n')
|
||||
p = PlainTextParagraph(' · '.join(c), style=self.stylesheet['Sender'])
|
||||
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
|
||||
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
|
||||
super()._draw_invoice_from(canvas)
|
||||
@@ -1225,8 +1211,8 @@ class Modern1Renderer(ClassicInvoiceRenderer):
|
||||
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs)
|
||||
]
|
||||
|
||||
p = FontFallbackParagraph(
|
||||
self._normalize(date_format(self.invoice.date, "DATE_FORMAT")),
|
||||
p = PlainTextParagraph(
|
||||
date_format(self.invoice.date, "DATE_FORMAT"),
|
||||
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
|
||||
)
|
||||
w = stringWidth(p.text, p.frags[0].fontName, p.frags[0].fontSize)
|
||||
@@ -1283,7 +1269,7 @@ class Modern1SimplifiedRenderer(Modern1Renderer):
|
||||
i = []
|
||||
|
||||
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
|
||||
i.append(FontFallbackParagraph(
|
||||
i.append(PlainTextParagraph(
|
||||
pgettext('invoice', 'Event date: {date_range}').format(
|
||||
date_range=self.invoice.event.get_date_range_display(),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix 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.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Do nothing. Useful for startup performance testing."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
pass
|
||||
@@ -40,6 +40,7 @@ from django.core.cache import cache
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import close_old_connections
|
||||
from django.dispatch.dispatcher import NO_RECEIVERS
|
||||
from django_querytagger.tagging import with_tag
|
||||
|
||||
from pretix.helpers.periodic import SKIPPED
|
||||
|
||||
@@ -82,7 +83,8 @@ class Command(BaseCommand):
|
||||
try:
|
||||
# Check if the DB connection is still good, it might be closed if the previous task took too long.
|
||||
close_old_connections()
|
||||
r = receiver(signal=periodic_task, sender=self)
|
||||
with with_tag(f"periodictask={name}"):
|
||||
r = receiver(signal=periodic_task, sender=self)
|
||||
except Exception as err:
|
||||
if isinstance(err, KeyboardInterrupt):
|
||||
raise err
|
||||
|
||||
+151
-50
@@ -19,6 +19,8 @@
|
||||
# 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
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from urllib.parse import urlparse, urlsplit
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
@@ -43,6 +45,8 @@ from pretix.multidomain.urlreverse import (
|
||||
)
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_supported = None
|
||||
|
||||
|
||||
@@ -65,11 +69,44 @@ def get_supported_language(requested_language, allowed_languages, default_langua
|
||||
return language
|
||||
|
||||
|
||||
class LocaleMiddleware(MiddlewareMixin):
|
||||
|
||||
class BaseLocaleMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
This middleware sets the correct locale and timezone
|
||||
for a request.
|
||||
This is a reduced LocaleMiddleware that uses only information contained in the WSGI request data
|
||||
to figure out the language (cookie and browser settings). We need it to have a consistent language
|
||||
for error pages that are generated from the middleware stack before we know e.g. which user is logged
|
||||
in or which event is selected.
|
||||
"""
|
||||
|
||||
def process_request(self, request: HttpRequest):
|
||||
language = get_language_from_early_request(request)
|
||||
translation.activate(language)
|
||||
set_region(None)
|
||||
request.LANGUAGE_CODE = language
|
||||
timezone.deactivate()
|
||||
|
||||
def process_response(self, request: HttpRequest, response: HttpResponse):
|
||||
language = translation.get_language()
|
||||
patch_vary_headers(response, ('Accept-Language',))
|
||||
if 'Content-Language' not in response:
|
||||
response['Content-Language'] = language
|
||||
return response
|
||||
|
||||
|
||||
class LocaleMiddleware(MiddlewareMixin):
|
||||
"""
|
||||
This is the full LocaleMiddleware that uses all available information to figure out the correct
|
||||
language for the request using all available sources, in this order of priority:
|
||||
|
||||
- Backend: User settings
|
||||
- Language cookie
|
||||
- Frontend: Customer account settings
|
||||
- Browser settings
|
||||
- Frontend: Event/Organizer settings
|
||||
- System default
|
||||
|
||||
It needs to run late in the middleware stack to have all information available for these steps.
|
||||
For some cases, it is even ran a second time since the event is sometimes only figured out after the
|
||||
middleware stack (can happen for plugin views).
|
||||
"""
|
||||
|
||||
def process_request(self, request: HttpRequest):
|
||||
@@ -184,6 +221,24 @@ def get_default_language():
|
||||
return settings.LANGUAGE_CODE
|
||||
|
||||
|
||||
def get_language_from_early_request(request: HttpRequest) -> str:
|
||||
"""
|
||||
Analyzes the request to find what language the user wants the system to
|
||||
show using only WSGI-available information. Only languages listed in
|
||||
settings.LANGUAGES are taken into account. If the user requests a sublanguage
|
||||
where we have a main language, we send out the main language.
|
||||
"""
|
||||
global _supported
|
||||
if _supported is None:
|
||||
_supported = OrderedDict(settings.LANGUAGES)
|
||||
|
||||
return (
|
||||
get_language_from_cookie(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_default_language()
|
||||
)
|
||||
|
||||
|
||||
def get_language_from_request(request: HttpRequest) -> str:
|
||||
"""
|
||||
Analyzes the request to find what language the user wants the system to
|
||||
@@ -198,7 +253,6 @@ def get_language_from_request(request: HttpRequest) -> str:
|
||||
if request.path.startswith(get_script_prefix() + 'control'):
|
||||
return (
|
||||
get_language_from_user_settings(request)
|
||||
or get_language_from_customer_settings(request)
|
||||
or get_language_from_cookie(request)
|
||||
or get_language_from_browser(request)
|
||||
or get_language_from_event(request)
|
||||
@@ -223,7 +277,26 @@ def _parse_csp(header):
|
||||
return h
|
||||
|
||||
|
||||
VALID_CSP_DIRECTIVES = [
|
||||
"child-src", "connect-src", "default-src", "fenced-frame-src", "font-src", "form-action", "frame-src", "img-src",
|
||||
"manifest-src", "media-src", "object-src", "prefetch-src", "report-uri", "script-src", "script-src-elem",
|
||||
"script-src-attr", "style-src", "style-src-elem", "style-src-attr", "worker-src",
|
||||
]
|
||||
|
||||
CSP_ILLEGAL_CHARS = re.compile(r'[\s,;]')
|
||||
|
||||
|
||||
def _sanitize_csp(h):
|
||||
for k, v in h.items():
|
||||
if k not in VALID_CSP_DIRECTIVES:
|
||||
raise ValueError("Invalid CSP directive " + k)
|
||||
if any(CSP_ILLEGAL_CHARS.search(el) for el in v):
|
||||
logger.warning("Stripping invalid component from CSP: %r", h)
|
||||
h[k] = [el for el in v if not CSP_ILLEGAL_CHARS.search(el)]
|
||||
|
||||
|
||||
def _render_csp(h):
|
||||
_sanitize_csp(h)
|
||||
return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v)
|
||||
|
||||
|
||||
@@ -243,21 +316,23 @@ def _merge_csp(a, b):
|
||||
|
||||
|
||||
class SecurityMiddleware(MiddlewareMixin):
|
||||
CSP_EXEMPT = (
|
||||
'/api/v1/docs/',
|
||||
SAFE_TYPES = (
|
||||
# CSP policies are only used for:
|
||||
# - HTML and SVG in top-level contexts
|
||||
# - SVG or JS Workers delivered in embedded contexts
|
||||
# See: https://www.w3.org/TR/CSP2/#which-policy-applies
|
||||
# Therefore, we can save bandwidth on not including our (sometimes huge) policy
|
||||
# on API responses or CSS. We do however include it with other types as a precaution
|
||||
# (whitelist instead of blacklist) and we also do not whitelist JavaScript in
|
||||
# we ever add service workers to not break the protection of this feature:
|
||||
# https://www.w3.org/TR/CSP2/#sandboxing-and-workers
|
||||
'application/json',
|
||||
'text/css',
|
||||
# We used to skip CSP for PDF since it was necessary for inline previews in Safari,
|
||||
# but at the moment it does not seem to be an issue to just send it.
|
||||
)
|
||||
|
||||
def process_response(self, request, resp):
|
||||
def nested_dict_values(d):
|
||||
for v in d.values():
|
||||
if isinstance(v, dict):
|
||||
yield from nested_dict_values(v)
|
||||
else:
|
||||
if isinstance(v, str):
|
||||
yield v
|
||||
|
||||
url = resolve(request.path_info)
|
||||
|
||||
if settings.DEBUG and resp.status_code >= 400:
|
||||
# Don't use CSP on debug error page as it breaks of Django's fancy error
|
||||
# pages
|
||||
@@ -268,18 +343,20 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
# https://github.com/pretix/pretix/issues/765
|
||||
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
|
||||
|
||||
img_src = []
|
||||
gs = global_settings_object(request)
|
||||
if gs.settings.leaflet_tiles:
|
||||
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
|
||||
if "Content-Type" in resp and resp["Content-Type"].split(";")[0] in self.SAFE_TYPES:
|
||||
if 'Content-Security-Policy' in resp:
|
||||
del resp['Content-Security-Policy']
|
||||
return resp
|
||||
|
||||
font_src = set()
|
||||
if hasattr(request, 'event'):
|
||||
for font in get_fonts(request.event, pdf_support_required=False).values():
|
||||
for path in list(nested_dict_values(font)):
|
||||
font_location = urlparse(path)
|
||||
if font_location.scheme and font_location.netloc:
|
||||
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
|
||||
if not getattr(resp, '_csp_ignore', False):
|
||||
resp['Content-Security-Policy'] = _render_csp(self._build_csp(request, resp))
|
||||
elif 'Content-Security-Policy' in resp:
|
||||
del resp['Content-Security-Policy']
|
||||
|
||||
return resp
|
||||
|
||||
def _build_csp(self, request, resp):
|
||||
url = resolve(request.path_info)
|
||||
|
||||
h = {
|
||||
'default-src': ["{static}"],
|
||||
@@ -287,9 +364,9 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'object-src': ["'none'"],
|
||||
'frame-src': ['{static}'],
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}"],
|
||||
'img-src': ["{static}", "{media}", "data:"] + img_src,
|
||||
'font-src': ["{static}"] + list(font_src),
|
||||
'connect-src': ["{static}", "{dynamic}", "{media}"],
|
||||
'img-src': ["{static}", "{media}", "data:"],
|
||||
'font-src': ["{static}"],
|
||||
'media-src': ["{static}", "data:"],
|
||||
# form-action is not only used to match on form actions, but also on URLs
|
||||
# form-actions redirect to. In the context of e.g. payment providers or
|
||||
@@ -298,6 +375,13 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []),
|
||||
}
|
||||
|
||||
gs = global_settings_object(request)
|
||||
if gs.settings.leaflet_tiles:
|
||||
h['img-src'].append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
|
||||
|
||||
if hasattr(request, 'event'):
|
||||
h['font-src'] += list(self._get_font_origins(request.event))
|
||||
|
||||
if settings.VITE_DEV_MODE:
|
||||
h['script-src'] += ["http://localhost:5173", "ws://localhost:5173"]
|
||||
h['style-src'] += ["'unsafe-inline'"]
|
||||
@@ -309,6 +393,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
if not settings.VITE_DEV_MODE:
|
||||
# can't have 'unsafe-inline' and nonce at the same time
|
||||
h['style-src'].append(nonce)
|
||||
|
||||
# Only include pay.google.com for wallet detection purposes on the Payment selection page
|
||||
if (
|
||||
url.url_name == "event.order.pay.change" or
|
||||
@@ -317,27 +402,32 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
h['script-src'].append('https://pay.google.com')
|
||||
h['frame-src'].append('https://pay.google.com')
|
||||
h['connect-src'].append('https://google.com/pay')
|
||||
|
||||
if settings.LOG_CSP:
|
||||
h['report-uri'] = ["/csp_report/"]
|
||||
|
||||
if 'Content-Security-Policy' in resp:
|
||||
_merge_csp(h, _parse_csp(resp['Content-Security-Policy']))
|
||||
|
||||
if settings.CSP_ADDITIONAL_HEADER:
|
||||
_merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER))
|
||||
|
||||
staticdomain = "'self'"
|
||||
dynamicdomain = "'self'"
|
||||
mediadomain = "'self'"
|
||||
placeholders = {
|
||||
"{static}": ["'self'"],
|
||||
"{dynamic}": ["'self'"],
|
||||
"{media}": ["'self'"],
|
||||
}
|
||||
if settings.MEDIA_URL.startswith('http'):
|
||||
mediadomain += " " + settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)]
|
||||
placeholders["{media}"].append(settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)])
|
||||
if settings.STATIC_URL.startswith('http'):
|
||||
staticdomain += " " + settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)]
|
||||
placeholders["{static}"].append(settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)])
|
||||
if settings.SITE_URL.startswith('http'):
|
||||
if settings.SITE_URL.find('/', 9) > 0:
|
||||
staticdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
|
||||
dynamicdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)]
|
||||
placeholders["{static}"].append(settings.SITE_URL[:settings.SITE_URL.find('/', 9)])
|
||||
placeholders["{dynamic}"].append(settings.SITE_URL[:settings.SITE_URL.find('/', 9)])
|
||||
else:
|
||||
staticdomain += " " + settings.SITE_URL
|
||||
dynamicdomain += " " + settings.SITE_URL
|
||||
placeholders["{static}"].append(settings.SITE_URL)
|
||||
placeholders["{dynamic}"].append(settings.SITE_URL)
|
||||
|
||||
if hasattr(request, 'organizer') and request.organizer:
|
||||
if hasattr(request, 'event') and request.event:
|
||||
@@ -348,18 +438,29 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
siteurlsplit = urlsplit(settings.SITE_URL)
|
||||
if siteurlsplit.port and siteurlsplit.port not in (80, 443):
|
||||
domain = '%s:%d' % (domain, siteurlsplit.port)
|
||||
dynamicdomain += " " + domain
|
||||
placeholders["{dynamic}"].append(domain)
|
||||
|
||||
if request.path not in self.CSP_EXEMPT and not getattr(resp, '_csp_ignore', False):
|
||||
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain,
|
||||
media=mediadomain)
|
||||
for k, v in h.items():
|
||||
h[k] = sorted(set(' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' ')))
|
||||
resp['Content-Security-Policy'] = _render_csp(h)
|
||||
elif 'Content-Security-Policy' in resp:
|
||||
del resp['Content-Security-Policy']
|
||||
for k, v in h.items():
|
||||
h[k] = sorted(set(result for part in v for result in placeholders.get(part, [part])))
|
||||
|
||||
return resp
|
||||
return h
|
||||
|
||||
def _get_font_origins(self, event):
|
||||
def nested_dict_values(d):
|
||||
for v in d.values():
|
||||
if isinstance(v, dict):
|
||||
yield from nested_dict_values(v)
|
||||
else:
|
||||
if isinstance(v, str):
|
||||
yield v
|
||||
|
||||
font_src = set()
|
||||
for font in get_fonts(event, pdf_support_required=False).values():
|
||||
for path in list(nested_dict_values(font)):
|
||||
font_location = urlparse(path)
|
||||
if font_location.scheme and font_location.netloc:
|
||||
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
|
||||
return font_src
|
||||
|
||||
|
||||
class RejectInvalidInputMiddleware(MiddlewareMixin):
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 4.2.8 on 2024-07-01 09:27
|
||||
import logging
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Count
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def clean_duplicate_secrets(apps, schema_editor):
|
||||
# This will autofix all possible duplicate Order.code and OrderPosition.secret values,
|
||||
# unless Order.code is already too long to append something. This would need to be fixed by
|
||||
# sysadmins manually.
|
||||
OrderPosition = apps.get_model("pretixbase", "OrderPosition")
|
||||
Order = apps.get_model("pretixbase", "Order")
|
||||
|
||||
qs = OrderPosition.all.values("secret", "order__event__organizer_id").order_by().annotate(c=Count("*")).filter(c__gt=1)
|
||||
for row in qs:
|
||||
affected = OrderPosition.all.filter(
|
||||
**{k: v for k, v in row.items() if k != "c"}
|
||||
).order_by("pk")
|
||||
logger.error(f"Found {row['c']} tickets with with the same secret \"{row['secret']}\" in organizer {row['order__event__organizer_id']}, all except one will be changed")
|
||||
for i, a in enumerate(affected):
|
||||
if i > 0:
|
||||
a.secret = a.secret + "__dupl__" + str(a.pk)
|
||||
logger.info(
|
||||
f"Ticket {a.pk} has new secret {a.secret}"
|
||||
)
|
||||
a.save(update_fields=["organizer_id", "secret"])
|
||||
|
||||
qs = Order.objects.values("code", "event__organizer_id").order_by().annotate(c=Count("*")).filter(c__gt=1)
|
||||
for row in qs:
|
||||
affected = Order.objects.filter(
|
||||
**{k: v for k, v in row.items() if k != "c"}
|
||||
).order_by("pk")
|
||||
logger.error(f"Found {row['c']} orders with with the same code \"{row['code']}\" in organizer {row['event__organizer_id']}, all except one will be changed")
|
||||
for i, a in enumerate(affected):
|
||||
if i > 0:
|
||||
if len(a.code) > 16 - len(str(a.pk)):
|
||||
raise ValueError(f"Cannot auto-fix order with duplicate code {a.code}, order code is too long already")
|
||||
a.code = a.code + str(a.pk).zfill(16 - len(a.code))
|
||||
logger.info(
|
||||
f"Order {a.pk} has new code {a.code}"
|
||||
)
|
||||
a.save(update_fields=["organizer_id", "code"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
(
|
||||
"pretixbase",
|
||||
"0301_reusablemedium_remove_orderposition",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(clean_duplicate_secrets, migrations.RunPython.noop),
|
||||
]
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 4.2.8 on 2024-07-01 09:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
(
|
||||
"pretixbase",
|
||||
"0302_resolve_duplicate_codes_and_secrets",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunSQL(
|
||||
"UPDATE pretixbase_order "
|
||||
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_event e WHERE e.id = pretixbase_order.event_id) "
|
||||
"WHERE pretixbase_order.organizer_id IS NULL;",
|
||||
migrations.RunSQL.noop,
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"UPDATE pretixbase_orderposition "
|
||||
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_order o LEFT JOIN pretixbase_event e ON e.id = o.event_id WHERE o.id = pretixbase_orderposition.order_id) "
|
||||
"WHERE pretixbase_orderposition.organizer_id IS NULL;",
|
||||
migrations.RunSQL.noop,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="order",
|
||||
name="organizer",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="orders",
|
||||
to="pretixbase.organizer",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="orderposition",
|
||||
name="organizer",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="order_positions",
|
||||
to="pretixbase.organizer",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-15 20:10
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0303_alter_order_organizer_alter_orderposition_organizer'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='cartposition',
|
||||
name='tax_rate',
|
||||
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, default=Decimal('0'), max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='invoiceline',
|
||||
name='tax_rate',
|
||||
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, default=Decimal('0'), max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderfee',
|
||||
name='tax_rate',
|
||||
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='orderposition',
|
||||
name='tax_rate',
|
||||
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='tax_rate',
|
||||
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, max_digits=7),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='taxrule',
|
||||
name='rate',
|
||||
field=pretix.helpers.models.NormalizedDecimalField(decimal_places=4, max_digits=7),
|
||||
),
|
||||
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-28 11:34
|
||||
import logging
|
||||
|
||||
from django.db import IntegrityError, migrations, transaction
|
||||
from django.db.models import Count, F
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fix_cross_organizer_eventmetavalues(apps, schema_editor):
|
||||
EventMetaProperty = apps.get_model("pretixbase", "EventMetaProperty")
|
||||
EventMetaValue = apps.get_model("pretixbase", "EventMetaValue")
|
||||
|
||||
cross_org_values = EventMetaValue.objects.filter(
|
||||
event__organizer__pk__ne=F('property__organizer__pk')
|
||||
).order_by('event__organizer__slug', 'event__slug')
|
||||
for emv in cross_org_values:
|
||||
logger.warning("%s", f"Fixing cross-organizer EventMetaValue: {emv.event.organizer.slug}/{emv.event.slug}")
|
||||
logger.warning(" %s", f"{emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {repr(emv.value)}")
|
||||
try:
|
||||
emv.property = emv.event.organizer.meta_properties.get(name=emv.property.name)
|
||||
if EventMetaValue.objects.filter(event=emv.event, property=emv.property).exists():
|
||||
correct = EventMetaValue.objects.get(event=emv.event, property=emv.property)
|
||||
if correct.value != emv.value:
|
||||
logger.warning(" %s", f"WARN: conflicting EventMetaValue with property in correct organizer already exists, deleting the cross-organizer one")
|
||||
else:
|
||||
logger.warning(" %s", f"OK: same-value EventMetaValue with property in correct organizer already exists, deleting the cross-organizer one")
|
||||
logger.warning(" %s", f"keeping: {correct.property.name}({correct.property.id}@{correct.property.organizer.slug}) = {repr(correct.value)}")
|
||||
emv.delete()
|
||||
else:
|
||||
logger.warning(" %s", f"OK: found existing EventMetaProperty in {emv.event.organizer.slug}, updating reference")
|
||||
logger.warning(" %s", f"after: {emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {repr(emv.value)}")
|
||||
emv.save(update_fields=["property"])
|
||||
except EventMetaProperty.DoesNotExist:
|
||||
meta_prop = emv.property
|
||||
meta_prop.pk = None
|
||||
meta_prop.organizer = emv.event.organizer
|
||||
meta_prop.filter_public = False
|
||||
meta_prop.save(force_insert=True)
|
||||
logger.warning(" %s", f"WARN: found no matching EventMetaProperty, creating")
|
||||
logger.warning(" %s", f"after: {emv.property.name}({emv.property.id}@{emv.property.organizer.slug}) = {repr(emv.value)}")
|
||||
emv.save(update_fields=["property"])
|
||||
|
||||
|
||||
def make_eventmetaproperties_unique(apps, schema_editor):
|
||||
EventMetaProperty = apps.get_model("pretixbase", "EventMetaProperty")
|
||||
EventMetaValue = apps.get_model("pretixbase", "EventMetaValue")
|
||||
|
||||
duplicates = EventMetaProperty.objects.values('organizer', 'organizer__slug', 'name').annotate(count=Count('id')).filter(count__gt=1)
|
||||
for dup in duplicates:
|
||||
logger.warning("%s", f"Fixup duplicate property {dup['organizer__slug']} {dup['name']}")
|
||||
props = list(EventMetaProperty.objects.filter(organizer=dup['organizer'], name=dup['name']))
|
||||
|
||||
target = props[0]
|
||||
invalid = props[1:]
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
affected = EventMetaValue.objects.filter(
|
||||
event__organizer=dup['organizer'], property__in=invalid
|
||||
).update(
|
||||
property=target
|
||||
)
|
||||
logger.warning("%s", f" Switching {affected} value(s) over to {target.name}({target.id}@{target.organizer.slug})")
|
||||
|
||||
except IntegrityError as e:
|
||||
logger.warning("%s", f" Failed to switch all value(s) over to {target.name}({target.id}@{target.organizer.slug})")
|
||||
logger.warning("%s", f" {e}")
|
||||
for prop in invalid:
|
||||
newname = f'{prop.name}_DUPLICATE_{prop.id}'
|
||||
logger.warning("%s", f" Renaming {prop.name}({prop.id}@{prop.organizer.slug}) to {newname}({prop.id}@{prop.organizer.slug})")
|
||||
prop.name = newname
|
||||
prop.filter_public = False
|
||||
prop.save()
|
||||
|
||||
else:
|
||||
for prop in invalid:
|
||||
logger.warning("%s", f" Deleting {prop.name}({prop.id}@{prop.organizer.slug})")
|
||||
prop.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0304_tax_rate_decimals"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fix_cross_organizer_eventmetavalues, migrations.RunPython.noop),
|
||||
migrations.RunPython(make_eventmetaproperties_unique, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-28 11:34
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0305_fixup_eventmetaproperties"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="eventmetaproperty",
|
||||
unique_together={("organizer", "name")},
|
||||
),
|
||||
]
|
||||
@@ -373,7 +373,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
|
||||
mail(
|
||||
email or self.email,
|
||||
_('Account information changed'),
|
||||
_('Changes to your account'),
|
||||
'pretixcontrol/email/security_notice.txt',
|
||||
{
|
||||
'user': self,
|
||||
@@ -400,12 +400,13 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
|
||||
with language(self.locale):
|
||||
if reason == 'email_change':
|
||||
msg = str(_('to confirm changing your email address from {old_email}\nto {new_email}, use the following code:').format(
|
||||
msg = str(_('To change your email address from {old_email} to {new_email}, use the following code:').format(
|
||||
old_email=self.email, new_email=email,
|
||||
))
|
||||
elif reason == 'email_verify':
|
||||
msg = str(_('to confirm that your email address {email} belongs to your pretix account, use the following code:').format(
|
||||
msg = str(_('To verify your email address {email} on {instance}, use the following code:').format(
|
||||
email=self.email,
|
||||
instance=settings.PRETIX_INSTANCE_NAME,
|
||||
))
|
||||
else:
|
||||
raise Exception('Invalid confirmation code reason')
|
||||
@@ -418,7 +419,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
}
|
||||
mail(
|
||||
email or self.email,
|
||||
_('pretix confirmation code'),
|
||||
_('Your confirmation code'),
|
||||
'pretixcontrol/email/confirmation_code.txt',
|
||||
{
|
||||
'user': self,
|
||||
@@ -462,7 +463,9 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
from pretix.base.services.mail import mail
|
||||
|
||||
mail(
|
||||
self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
|
||||
self.email,
|
||||
_('Reset your password'),
|
||||
'pretixcontrol/email/forgot.txt',
|
||||
{
|
||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||
'user': self,
|
||||
@@ -647,25 +650,22 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
id__in=self.teams.filter(TeamQuerySet.organizer_permission_q(permission)).values_list('organizer', flat=True)
|
||||
)
|
||||
|
||||
def has_active_staff_session(self, session_key=None):
|
||||
def has_active_staff_session(self, session_key):
|
||||
"""
|
||||
Returns whether or not a user has an active staff session (formerly known as superuser session)
|
||||
with the given session key.
|
||||
"""
|
||||
return self.get_active_staff_session(session_key) is not None
|
||||
|
||||
def get_active_staff_session(self, session_key=None):
|
||||
if not self.is_staff:
|
||||
def get_active_staff_session(self, session_key):
|
||||
if not self.is_staff or not session_key:
|
||||
return None
|
||||
if not hasattr(self, '_staff_session_cache'):
|
||||
self._staff_session_cache = {}
|
||||
if session_key not in self._staff_session_cache:
|
||||
qs = StaffSession.objects.filter(
|
||||
user=self, date_end__isnull=True
|
||||
)
|
||||
if session_key:
|
||||
qs = qs.filter(session_key=session_key)
|
||||
sess = qs.first()
|
||||
sess = StaffSession.objects.filter(
|
||||
user=self, date_end__isnull=True, session_key=session_key
|
||||
).first()
|
||||
if sess:
|
||||
if sess.date_start < now() - timedelta(seconds=settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE):
|
||||
sess.date_end = now()
|
||||
|
||||
@@ -395,7 +395,7 @@ class AttendeeProfile(models.Model):
|
||||
self.company,
|
||||
self.street,
|
||||
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||
self.country.name,
|
||||
self.country.name if self.country else None,
|
||||
]
|
||||
for a in self.answers:
|
||||
value = a.get('value')
|
||||
|
||||
@@ -40,6 +40,7 @@ import warnings
|
||||
from collections import Counter, OrderedDict, defaultdict
|
||||
from datetime import datetime, time, timedelta
|
||||
from operator import attrgetter
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urljoin
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -79,10 +80,16 @@ from pretix.helpers.thumb import get_thumbnail
|
||||
from ..settings import settings_hierarkey
|
||||
from .organizer import Organizer, Team
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from hierarkey.proxy import HierarkeyProxy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventMixin:
|
||||
if TYPE_CHECKING:
|
||||
settings: HierarkeyProxy
|
||||
|
||||
def clean(self):
|
||||
if self.presale_start and self.presale_end and self.presale_start > self.presale_end:
|
||||
raise ValidationError({'presale_end': _('The end of the presale period has to be later than its start.')})
|
||||
@@ -172,6 +179,12 @@ class EventMixin:
|
||||
self.date_to.astimezone(tz), ("D" if short else "l")
|
||||
)
|
||||
|
||||
def is_same_day(self):
|
||||
if not self.date_to:
|
||||
return True
|
||||
else:
|
||||
return self.date_from.astimezone(self.timezone).date() == self.date_to.astimezone(self.timezone).date()
|
||||
|
||||
def get_date_range_display(self, tz=None, force_show_end=False, as_html=False, try_to_show_times=False) -> str:
|
||||
"""
|
||||
Returns a formatted string containing the start date and the end date
|
||||
@@ -642,7 +655,7 @@ class Event(EventMixin, LoggedModel):
|
||||
is_remote = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("This event is remote or partially remote."),
|
||||
help_text=_("This will be used to let users know if the event is in a different timezone and let’s us calculate users’ local times."),
|
||||
help_text=_("This will be used to let users know if the event is in a different timezone, and to let us calculate the local time of a user."),
|
||||
)
|
||||
geo_lat = models.FloatField(
|
||||
verbose_name=_("Latitude"),
|
||||
@@ -899,7 +912,7 @@ class Event(EventMixin, LoggedModel):
|
||||
self.save()
|
||||
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
|
||||
|
||||
if hasattr(other, 'alternative_domain_assignment'):
|
||||
if hasattr(other, 'alternative_domain_assignment') and not is_cross_organizer:
|
||||
other.alternative_domain_assignment.domain.event_assignments.create(event=self)
|
||||
|
||||
if not self.all_sales_channels:
|
||||
@@ -1396,15 +1409,12 @@ class Event(EventMixin, LoggedModel):
|
||||
|
||||
for mp in self.organizer.meta_properties.all():
|
||||
if mp.required and not self.meta_data.get(mp.name):
|
||||
issues.append(
|
||||
('<a {a_attr}>' + gettext('You need to fill the meta parameter "{property}".') + '</a>').format(
|
||||
property=mp.name,
|
||||
a_attr='href="%s#id_prop-%d-value"' % (
|
||||
reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
|
||||
mp.pk
|
||||
)
|
||||
)
|
||||
)
|
||||
issues.append(format_html(
|
||||
'<a href="{href}{href_hash}">{text}</a>',
|
||||
text=gettext('You need to fill the meta parameter "{property}".').format(property=mp.name),
|
||||
href=reverse('control:event.settings', kwargs={'organizer': self.organizer.slug, 'event': self.slug}),
|
||||
href_hash=f'#id_prop-{mp.pk}-value',
|
||||
))
|
||||
|
||||
responses = event_live_issues.send(self)
|
||||
for receiver, response in sorted(responses, key=lambda r: str(r[0])):
|
||||
@@ -1847,6 +1857,7 @@ class EventMetaProperty(LoggedModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ("position", "name",)
|
||||
unique_together = ('organizer', 'name')
|
||||
|
||||
@property
|
||||
def choice_keys(self):
|
||||
|
||||
@@ -49,6 +49,7 @@ from django_scopes import ScopedManager
|
||||
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
from pretix.helpers.countries import FastCountryField
|
||||
from pretix.helpers.models import NormalizedDecimalField
|
||||
|
||||
|
||||
def invoice_filename(instance, filename: str) -> str:
|
||||
@@ -450,7 +451,7 @@ class InvoiceLine(models.Model):
|
||||
description = models.TextField()
|
||||
gross_value = models.DecimalField(max_digits=13, decimal_places=2)
|
||||
tax_value = models.DecimalField(max_digits=13, decimal_places=2, default=Decimal('0.00'))
|
||||
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
|
||||
tax_rate = NormalizedDecimalField(max_digits=7, decimal_places=4, default=Decimal('0'))
|
||||
tax_name = models.CharField(max_length=190)
|
||||
tax_code = models.CharField(max_length=190, null=True, blank=True)
|
||||
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
|
||||
|
||||
@@ -885,26 +885,6 @@ class Item(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
|
||||
now_dt = now_dt or time_machine_now()
|
||||
subevent_item = subevent and subevent.item_overrides.get(self.pk)
|
||||
if not self.active:
|
||||
return 'active'
|
||||
elif self.available_from and self.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif self.available_until and self.available_until < now_dt:
|
||||
return 'available_until'
|
||||
elif (self.require_voucher or self.hide_without_voucher) and not has_voucher:
|
||||
return 'require_voucher'
|
||||
elif subevent_item and subevent_item.available_from and subevent_item.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif subevent_item and subevent_item.available_until and subevent_item.available_until < now_dt:
|
||||
return 'available_until'
|
||||
elif self.hidden_if_item_available and self._dependency_available:
|
||||
return 'hidden_if_item_available'
|
||||
else:
|
||||
return None
|
||||
|
||||
def _get_quotas(self, ignored_quotas=None, subevent=None):
|
||||
check_quotas = set(getattr(
|
||||
self, '_subevent_quotas', # Utilize cache in product list
|
||||
@@ -1413,22 +1393,6 @@ class ItemVariation(models.Model):
|
||||
return False
|
||||
return True
|
||||
|
||||
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
|
||||
now_dt = now_dt or time_machine_now()
|
||||
subevent_var = subevent and subevent.var_overrides.get(self.pk)
|
||||
if not self.active:
|
||||
return 'active'
|
||||
elif self.available_from and self.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif self.available_until and self.available_until < now_dt:
|
||||
return 'available_until'
|
||||
elif subevent_var and subevent_var.available_from and subevent_var.available_from > now_dt:
|
||||
return 'available_from'
|
||||
elif subevent_var and subevent_var.available_until and subevent_var.available_until < now_dt:
|
||||
return 'available_until'
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def meta_data(self):
|
||||
data = self.item.meta_data
|
||||
|
||||
@@ -87,6 +87,7 @@ from pretix.base.timemachine import time_machine_now
|
||||
|
||||
from ...helpers import OF_SELF
|
||||
from ...helpers.countries import CachedCountries, FastCountryField
|
||||
from ...helpers.models import NormalizedDecimalField
|
||||
from ...helpers.names import build_name
|
||||
from ...testutils.middleware import debugflags_var
|
||||
from ._transactions import (
|
||||
@@ -224,8 +225,6 @@ class Order(LockModel, LoggedModel):
|
||||
"Organizer",
|
||||
related_name="orders",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
@@ -329,7 +328,7 @@ class Order(LockModel, LoggedModel):
|
||||
default="line",
|
||||
)
|
||||
|
||||
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
|
||||
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='organizer')
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Order")
|
||||
@@ -1697,7 +1696,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
|
||||
self.company,
|
||||
self.street,
|
||||
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||
self.country.name
|
||||
self.country.name if self.country else ''
|
||||
]
|
||||
lines = [r.strip() for r in lines if r]
|
||||
return '\n'.join(lines).strip()
|
||||
@@ -2356,8 +2355,8 @@ class OrderFee(RoundingCorrectionMixin, models.Model):
|
||||
)
|
||||
description = models.CharField(max_length=190, blank=True)
|
||||
internal_type = models.CharField(max_length=255, blank=True)
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=7, decimal_places=2,
|
||||
tax_rate = NormalizedDecimalField(
|
||||
max_digits=7, decimal_places=4,
|
||||
verbose_name=_('Tax rate')
|
||||
)
|
||||
tax_rule = models.ForeignKey(
|
||||
@@ -2541,8 +2540,6 @@ class OrderPosition(AbstractPosition):
|
||||
"Organizer",
|
||||
related_name="order_positions",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
@@ -2555,8 +2552,8 @@ class OrderPosition(AbstractPosition):
|
||||
max_digits=13, decimal_places=2, null=True, blank=True,
|
||||
)
|
||||
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=7, decimal_places=2,
|
||||
tax_rate = NormalizedDecimalField(
|
||||
max_digits=7, decimal_places=4,
|
||||
verbose_name=_('Tax rate')
|
||||
)
|
||||
tax_rule = models.ForeignKey(
|
||||
@@ -2599,7 +2596,7 @@ class OrderPosition(AbstractPosition):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
all = ScopedManager(organizer='order__event__organizer')
|
||||
all = ScopedManager(organizer='organizer')
|
||||
objects = ActivePositionManager()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -3074,8 +3071,8 @@ class Transaction(models.Model):
|
||||
price_includes_rounding_correction = models.DecimalField(
|
||||
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=7, decimal_places=2,
|
||||
tax_rate = NormalizedDecimalField(
|
||||
max_digits=7, decimal_places=4,
|
||||
verbose_name=_('Tax rate')
|
||||
)
|
||||
tax_rule = models.ForeignKey(
|
||||
@@ -3190,8 +3187,8 @@ class CartPosition(AbstractPosition):
|
||||
verbose_name=_("Limit for extending expiration date"),
|
||||
null=True
|
||||
)
|
||||
tax_rate = models.DecimalField(
|
||||
max_digits=7, decimal_places=2, default=Decimal('0.00'),
|
||||
tax_rate = NormalizedDecimalField(
|
||||
max_digits=7, decimal_places=4, default=Decimal('0'),
|
||||
verbose_name=_('Tax rate')
|
||||
)
|
||||
tax_code = models.CharField(
|
||||
@@ -3438,7 +3435,7 @@ class InvoiceAddress(models.Model):
|
||||
self.name,
|
||||
self.street,
|
||||
(self.zipcode or '') + ' ' + (self.city or '') + ' ' + (self.state_for_address or ''),
|
||||
self.country.name,
|
||||
self.country.name if self.country else '',
|
||||
self.vat_id,
|
||||
self.custom_field,
|
||||
self.internal_reference,
|
||||
|
||||
@@ -35,6 +35,7 @@ import operator
|
||||
import string
|
||||
from datetime import date, datetime, time
|
||||
from functools import reduce
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytz_deprecation_shim
|
||||
from django.conf import settings
|
||||
@@ -61,6 +62,9 @@ from ...helpers.permission_migration import (
|
||||
from ..settings import settings_hierarkey
|
||||
from .auth import User
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from hierarkey.proxy import HierarkeyProxy
|
||||
|
||||
|
||||
@settings_hierarkey.add(cache_namespace='organizer')
|
||||
class Organizer(LoggedModel):
|
||||
@@ -78,6 +82,9 @@ class Organizer(LoggedModel):
|
||||
"""
|
||||
|
||||
settings_namespace = 'organizer'
|
||||
if TYPE_CHECKING:
|
||||
settings: HierarkeyProxy
|
||||
|
||||
name = models.CharField(max_length=200,
|
||||
verbose_name=_("Name"))
|
||||
slug = models.CharField(
|
||||
|
||||
@@ -40,6 +40,7 @@ from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.helpers.countries import FastCountryField
|
||||
from pretix.helpers.models import NormalizedDecimalField
|
||||
|
||||
|
||||
class TaxedPrice:
|
||||
@@ -335,9 +336,9 @@ class TaxRule(LoggedModel):
|
||||
max_length=190,
|
||||
choices=TAX_CODE_LISTS,
|
||||
)
|
||||
rate = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
rate = NormalizedDecimalField(
|
||||
max_digits=7,
|
||||
decimal_places=4,
|
||||
validators=[
|
||||
MaxValueValidator(
|
||||
limit_value=Decimal("100.00"),
|
||||
|
||||
@@ -32,8 +32,10 @@
|
||||
# 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 datetime
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -421,27 +423,33 @@ class Voucher(LoggedModel):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def clean_quota_get_ignored(old_instance):
|
||||
quotas = set()
|
||||
was_valid = old_instance and (
|
||||
old_instance.valid_until is None or old_instance.valid_until >= now()
|
||||
)
|
||||
if old_instance and old_instance.block_quota and was_valid:
|
||||
if old_instance.quota:
|
||||
quotas.add(old_instance.quota)
|
||||
elif old_instance.variation:
|
||||
quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
|
||||
elif old_instance.item:
|
||||
if old_instance.item.has_variations:
|
||||
quotas |= set(
|
||||
Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
|
||||
itemvariation__item=old_instance.item,
|
||||
quota__subevent=old_instance.subevent,
|
||||
).values('quota_id'))
|
||||
)
|
||||
else:
|
||||
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
|
||||
return quotas
|
||||
def get_affected_quotas(quota, item, variation, subevent):
|
||||
if quota:
|
||||
return {quota}
|
||||
elif item and variation:
|
||||
return set(variation.quotas.filter(subevent=subevent))
|
||||
elif item and not item.has_variations:
|
||||
return set(item.quotas.filter(subevent=subevent))
|
||||
elif item and item.has_variations:
|
||||
return set(
|
||||
Quota.objects.filter(
|
||||
pk__in=Quota.variations.through.objects.filter(
|
||||
itemvariation__item=item,
|
||||
quota__subevent=subevent,
|
||||
).values('quota_id')
|
||||
)
|
||||
)
|
||||
else:
|
||||
return set()
|
||||
|
||||
@staticmethod
|
||||
def clean_quota_get_ignored(voucher_data: Union["VoucherBulkData", "Voucher"]):
|
||||
if voucher_data:
|
||||
valid = voucher_data.valid_until is None or voucher_data.valid_until >= now()
|
||||
if valid and voucher_data.block_quota and voucher_data.max_usages > voucher_data.redeemed:
|
||||
return Voucher.get_affected_quotas(voucher_data.quota, voucher_data.item, voucher_data.variation, voucher_data.subevent)
|
||||
|
||||
return set()
|
||||
|
||||
@staticmethod
|
||||
def clean_quota_check(data, cnt, old_instance, event, quota, item, variation):
|
||||
@@ -453,22 +461,8 @@ class Voucher(LoggedModel):
|
||||
if event.has_subevents and data.get('block_quota') and not data.get('subevent'):
|
||||
raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.'))
|
||||
|
||||
if quota:
|
||||
new_quotas = {quota}
|
||||
elif item and variation:
|
||||
new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
|
||||
elif item and not item.has_variations:
|
||||
new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
|
||||
elif item and item.has_variations:
|
||||
new_quotas = set(
|
||||
Quota.objects.filter(
|
||||
pk__in=Quota.variations.through.objects.filter(
|
||||
itemvariation__item=item,
|
||||
quota__subevent=data.get('subevent'),
|
||||
).values('quota_id')
|
||||
)
|
||||
)
|
||||
else:
|
||||
new_quotas = Voucher.get_affected_quotas(quota, item, variation, data.get('subevent'))
|
||||
if not new_quotas:
|
||||
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
|
||||
'tickets.'))
|
||||
|
||||
@@ -644,3 +638,16 @@ class Voucher(LoggedModel):
|
||||
]
|
||||
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
|
||||
return ops
|
||||
|
||||
|
||||
@dataclass
|
||||
class VoucherBulkData:
|
||||
item: object
|
||||
variation: object
|
||||
quota: object
|
||||
block_quota: bool
|
||||
valid_until: datetime.datetime
|
||||
subevent: object
|
||||
redeemed: int
|
||||
max_usages: int
|
||||
allow_ignore_quota: bool
|
||||
|
||||
@@ -834,7 +834,7 @@ class BasePaymentProvider:
|
||||
"""
|
||||
raise NotImplementedError() # NOQA
|
||||
|
||||
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
|
||||
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str | None:
|
||||
"""
|
||||
After the user has confirmed their purchase, this method will be called to complete
|
||||
the payment process. This is the place to actually move the money if applicable.
|
||||
@@ -936,7 +936,7 @@ class BasePaymentProvider:
|
||||
"""
|
||||
Will be called if the *event administrator* views the details of a payment.
|
||||
|
||||
It should return HTML code containing information regarding the current payment
|
||||
It should return a SafeString containing HTML code, with information regarding the current payment
|
||||
status and, if applicable, next steps.
|
||||
|
||||
The default implementation returns an empty string.
|
||||
@@ -961,7 +961,7 @@ class BasePaymentProvider:
|
||||
"""
|
||||
Will be called if the *event administrator* views the details of a refund.
|
||||
|
||||
It should return HTML code containing information regarding the current refund
|
||||
It should return a SafeString containing HTML code, with information regarding the current refund
|
||||
status and, if applicable, next steps.
|
||||
|
||||
The default implementation returns an empty string.
|
||||
@@ -1706,6 +1706,58 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
)
|
||||
|
||||
|
||||
class BaseHistoricalPaymentProvider(BasePaymentProvider):
|
||||
"""
|
||||
Base class for payment providers that no longer exist but can't be deleted to make sure historical
|
||||
payments are shown correctly.
|
||||
|
||||
Subclasses are recommended to only implement:
|
||||
- identifier
|
||||
- verbose_name
|
||||
- public_name
|
||||
- payment_control_render
|
||||
- payment_control_render_short
|
||||
- refund_control_render
|
||||
- refund_control_render_short
|
||||
- render_invoice_text
|
||||
- render_invoice_stamp
|
||||
- api_payment_details
|
||||
- api_refund_details
|
||||
- shred_payment_info
|
||||
- matching_id
|
||||
- refund_matching_id
|
||||
"""
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
return False
|
||||
|
||||
@property
|
||||
def settings_form_fields(self) -> dict:
|
||||
return {}
|
||||
|
||||
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
|
||||
return False
|
||||
|
||||
def payment_is_valid_session(self, request: HttpRequest, payment: OrderPayment):
|
||||
return False
|
||||
|
||||
def order_change_allowed(self, order: Order, request: HttpRequest=None) -> bool:
|
||||
return False
|
||||
|
||||
def payment_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
return False
|
||||
|
||||
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
|
||||
return False
|
||||
|
||||
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
|
||||
raise PaymentException(_("This payment provider exists for historical purposes only and is no longer usable."))
|
||||
|
||||
def execute_refund(self, refund: OrderRefund):
|
||||
raise PaymentException(_("This payment provider exists for historical purposes only and is no longer usable."))
|
||||
|
||||
|
||||
@receiver(register_payment_providers, dispatch_uid="payment_free")
|
||||
def register_payment_provider(sender, **kwargs):
|
||||
return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment, GiftCardPayment]
|
||||
|
||||
+11
-6
@@ -77,6 +77,7 @@ from reportlab.platypus import Paragraph
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Checkin, Event, Order, OrderPosition, Question
|
||||
from pretix.base.services.placeholders import PlaceholderContext
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import layout_image_variables, layout_text_variables
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
@@ -401,11 +402,7 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
"editor_sample": _("Event organizer info text"),
|
||||
"evaluate": lambda op, order, ev: str(order.event.settings.organizer_info_text)
|
||||
}),
|
||||
("event_info_text", {
|
||||
"label": _("Event info text"),
|
||||
"editor_sample": _("Event info text"),
|
||||
"evaluate": lambda op, order, ev: str(order.event.settings.event_info_text)
|
||||
}),
|
||||
("event_info_text", {}), # Placeholder to "reserve" position, defined later in `get_variables`
|
||||
("now_date", {
|
||||
"label": _("Printing date"),
|
||||
"editor_sample": _("2017-05-31"),
|
||||
@@ -670,6 +667,14 @@ def get_images(event):
|
||||
def get_variables(event):
|
||||
v = copy.copy(DEFAULT_VARIABLES)
|
||||
|
||||
templating_context = PlaceholderContext(event=event)
|
||||
v['event_info_text'] = {
|
||||
"label": _("Event info text"),
|
||||
"editor_sample": _("Event info text"),
|
||||
"evaluate": lambda op, order, ev:
|
||||
templating_context.format(str(order.event.settings.event_info_text))
|
||||
}
|
||||
|
||||
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
|
||||
concatenation_for_salutation = scheme.get("concatenation_for_salutation", scheme["concatenation"])
|
||||
@@ -1067,7 +1072,7 @@ class Renderer:
|
||||
except:
|
||||
logger.exception('Reshaping/Bidi fixes failed on string {}'.format(repr(text)))
|
||||
|
||||
p = Paragraph(text, style=style)
|
||||
p = Paragraph(text, style=style) # not using AutoEscapeParagraph is safe as we escape above
|
||||
return p, ad, lineheight
|
||||
|
||||
def _draw_textcontainer(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
||||
|
||||
@@ -245,6 +245,9 @@ def recv_classic(sender, **kwargs):
|
||||
|
||||
|
||||
def assign_ticket_secret(event, position, force_invalidate_if_revokation_list_used=False, force_invalidate=False, save=True):
|
||||
if position.pk and position.issued_gift_cards.exists():
|
||||
return
|
||||
|
||||
gen = event.ticket_secret_generator
|
||||
if gen.use_revocation_list and force_invalidate_if_revokation_list_used:
|
||||
force_invalidate = True
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Subquery
|
||||
from django.utils.crypto import get_random_string
|
||||
@@ -377,12 +378,13 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
confirmation_code = get_random_string(8, allowed_chars="01234567890")
|
||||
mail(
|
||||
user.email,
|
||||
subject=gettext('Bulk-refund confirmation'),
|
||||
subject=gettext('Confirm event cancellation and bulk refund'),
|
||||
template='pretixbase/email/cancel_confirm.txt',
|
||||
context={
|
||||
"event": str(event),
|
||||
"amount": money_filter(refund_total, event.currency),
|
||||
"confirmation_code": confirmation_code,
|
||||
"instance": settings.PRETIX_INSTANCE_NAME,
|
||||
},
|
||||
locale=user.locale,
|
||||
)
|
||||
|
||||
@@ -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.presale.productlist import prepare_item_list_for_shop
|
||||
|
||||
|
||||
class DummyCategory:
|
||||
@@ -162,7 +162,7 @@ class CrossSellingService:
|
||||
]
|
||||
|
||||
def _prepare_items(self, subevent, items_qs, discount_info):
|
||||
items, _btn = get_grouped_items(
|
||||
items, _btn = prepare_item_list_for_shop(
|
||||
self.event,
|
||||
subevent=subevent,
|
||||
voucher=None,
|
||||
|
||||
@@ -340,12 +340,13 @@ def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter,
|
||||
if schedule.owner.is_active:
|
||||
mail(
|
||||
email=schedule.owner.email,
|
||||
subject=gettext('Export failed'),
|
||||
subject=gettext('Scheduled export failed'),
|
||||
template='pretixbase/email/export_failed.txt',
|
||||
context={
|
||||
'configuration_url': config_url,
|
||||
'reason': msg,
|
||||
'soft': soft,
|
||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||
},
|
||||
event=context if isinstance(context, Event) else None,
|
||||
organizer=context.organizer if isinstance(context, Event) else context,
|
||||
|
||||
@@ -24,6 +24,7 @@ from typing import List, Optional
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -96,6 +97,8 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
||||
:param valid_from_not_chosen: Set to ``True`` to indicate that the customer is in an early step of the checkout flow
|
||||
where the valid_from date is not selected yet. In this case, the valid_from date is not checked.
|
||||
"""
|
||||
if lock and not transaction.get_connection().in_atomic_block:
|
||||
raise Exception('validate_memberships_in_order(lock=True) should only be called in atomic transaction!')
|
||||
tz = event.timezone
|
||||
applicable_positions = [
|
||||
p for p in positions
|
||||
|
||||
@@ -26,8 +26,9 @@ from typing import List
|
||||
from django.conf import settings as django_settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.utils import IntegrityError
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext as _, ngettext
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.modelimport import DataImportError, ImportColumn, parse_csv
|
||||
@@ -260,6 +261,7 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
|
||||
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
|
||||
# shorter. We'll see what works better in reality…
|
||||
vouchers = []
|
||||
codes = set()
|
||||
lock_seats = []
|
||||
for i, record in enumerate(data):
|
||||
try:
|
||||
@@ -268,6 +270,14 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
|
||||
|
||||
if not record.get("code"):
|
||||
raise ValidationError(_('A voucher cannot be created without a code.'))
|
||||
code = record.get("code")
|
||||
if code.upper() in codes:
|
||||
raise ValidationError(
|
||||
_('Voucher codes must be unique. Code "{code}" already exists in this import.').format(
|
||||
code=code,
|
||||
)
|
||||
)
|
||||
codes.add(code.upper())
|
||||
Voucher.clean_item_properties(
|
||||
record,
|
||||
event,
|
||||
@@ -286,8 +296,22 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
|
||||
lock_seats.append(voucher.seat)
|
||||
except (ValidationError, ImportError) as e:
|
||||
raise DataImportError(
|
||||
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
|
||||
_('Invalid data in row {row}: {message}').format(row=i + 1, message=str(e))
|
||||
)
|
||||
existing_codes = Voucher.objects.filter(
|
||||
event=event,
|
||||
code__in=codes,
|
||||
).values_list("code", flat=True)
|
||||
if len(existing_codes):
|
||||
raise DataImportError(
|
||||
ngettext(
|
||||
'Voucher codes must be unique. Import contains existing voucher code {code}.',
|
||||
'Voucher codes must be unique. Import contains existing voucher codes {code}.',
|
||||
len(existing_codes)
|
||||
).format(
|
||||
code=", ".join(existing_codes)
|
||||
)
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
# We don't support quotas here, so we only need to lock if seats are in use
|
||||
@@ -300,7 +324,13 @@ def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user
|
||||
|
||||
save_logentries = []
|
||||
for v in vouchers:
|
||||
v.save()
|
||||
try:
|
||||
v.save()
|
||||
except IntegrityError:
|
||||
# should not happen as we check existing codes before, but we did not lock so we might have a race-condition
|
||||
raise DataImportError(
|
||||
_('Vouchers could not be imported, probably due to a voucher code already being in use.')
|
||||
)
|
||||
save_logentries.append(v.log_action(
|
||||
'pretix.voucher.added',
|
||||
user=user,
|
||||
|
||||
@@ -110,6 +110,7 @@ from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.models import modelcopy
|
||||
from pretix.helpers.periodic import minimum_interval
|
||||
from pretix.presale.productlist import prepare_item_list_for_shop
|
||||
from pretix.testutils.middleware import debugflags_var
|
||||
|
||||
|
||||
@@ -790,6 +791,12 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
||||
[op.seat for op in sorted_positions if op.seat],
|
||||
shared_lock_objects=[event]
|
||||
)
|
||||
elif any(cp.voucher and cp.voucher.budget for cp in sorted_positions):
|
||||
# Voucher budgets are not guaranteed by the cart manager
|
||||
lock_objects(
|
||||
[op.voucher for op in sorted_positions if op.voucher and op.voucher.budget],
|
||||
shared_lock_objects=[event]
|
||||
)
|
||||
|
||||
q_avail = Counter()
|
||||
v_avail = Counter()
|
||||
@@ -1599,6 +1606,7 @@ class OrderChangeManager:
|
||||
'seat_forbidden': gettext_lazy('The selected product does not allow to select a seat.'),
|
||||
'tax_rule_country_blocked': gettext_lazy('The selected country is blocked by your tax rule.'),
|
||||
'gift_card_change': gettext_lazy('You cannot change the price of a position that has been used to issue a gift card.'),
|
||||
'gift_card_secret': gettext_lazy('You cannot change the ticket secret of a position that has been used to issue a gift card.'),
|
||||
'max_items_per_product': ngettext_lazy(
|
||||
"You cannot select more than %(max)s item of the product %(product)s.",
|
||||
"You cannot select more than %(max)s items of the product %(product)s.",
|
||||
@@ -1756,6 +1764,9 @@ class OrderChangeManager:
|
||||
self._operations.append(self.RegenerateSecretOperation(position))
|
||||
|
||||
def change_ticket_secret(self, position: OrderPosition, new_secret: str):
|
||||
if position.issued_gift_cards.exists():
|
||||
raise OrderError(self.error_messages['gift_card_secret'])
|
||||
|
||||
self._operations.append(self.ChangeSecretOperation(position, new_secret))
|
||||
|
||||
def change_valid_from(self, position: OrderPosition, new_value: datetime):
|
||||
@@ -1943,13 +1954,18 @@ class OrderChangeManager:
|
||||
|
||||
:param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values),
|
||||
``"count"``, and ``"price"``.
|
||||
:param limit_main_positions: By default, the method works on all methods of the order. If you set this to a
|
||||
:param limit_main_positions: By default, the method works on all positions of the order. If you set this to a
|
||||
queryset or a list of positions, all other positions and their add-ons will be kept
|
||||
untouched.
|
||||
"""
|
||||
if self._operations:
|
||||
raise ValueError("Setting addons should be the first/only operation")
|
||||
|
||||
def _allowed_on_order_sales_channel(item_or_var, order):
|
||||
return item_or_var.all_sales_channels or (
|
||||
order.sales_channel.identifier in (s.identifier for s in item_or_var.limit_sales_channels.all())
|
||||
)
|
||||
|
||||
# Prepare containers for min/max check of products
|
||||
item_counts = Counter()
|
||||
for p in self.order.positions.all():
|
||||
@@ -2043,13 +2059,11 @@ class OrderChangeManager:
|
||||
if not item.is_available() or (variation and not variation.is_available()):
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
|
||||
if not item.all_sales_channels:
|
||||
if self.order.sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()):
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
if not _allowed_on_order_sales_channel(item, self.order):
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
|
||||
if variation and not variation.all_sales_channels:
|
||||
if self.order.sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()):
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
if variation and not _allowed_on_order_sales_channel(variation, self.order):
|
||||
raise OrderError(error_messages['unavailable'])
|
||||
|
||||
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available():
|
||||
raise OrderError(error_messages['not_for_sale'])
|
||||
@@ -2097,6 +2111,36 @@ class OrderChangeManager:
|
||||
)
|
||||
item_counts[item] += 1
|
||||
|
||||
def _addon_is_available(a):
|
||||
# If an item is no longer available due to time, it should usually also be no longer
|
||||
# user-removable, because e.g. the stock has already been ordered.
|
||||
# We always set voucher=None because that's what's done when generating the form in
|
||||
# OrderChangeMixin (vouchers for addons are not supported).
|
||||
# This also prevents accidental removal through the UI because a hidden product will no longer
|
||||
# be part of the input.
|
||||
if not _allowed_on_order_sales_channel(a.item, self.order) or (
|
||||
a.variation and not _allowed_on_order_sales_channel(a.variation, self.order)
|
||||
):
|
||||
return False
|
||||
|
||||
items, _ = prepare_item_list_for_shop(
|
||||
self.order.event,
|
||||
channel=self.order.sales_channel,
|
||||
subevent=a.subevent,
|
||||
voucher=None,
|
||||
base_qs=Item.objects.filter(pk=a.item.pk),
|
||||
allow_addons=True
|
||||
)
|
||||
if (not items) or items[0].current_unavailability_reason:
|
||||
return False
|
||||
|
||||
if a.variation:
|
||||
variations = [var for var in items[0].available_variations if var.pk == a.variation.pk]
|
||||
if (not variations) or variations[0].current_unavailability_reason:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# Detect removed add-ons and create RemoveOperations
|
||||
for cp, al in list(current_addons.items()):
|
||||
for k, v in al.items():
|
||||
@@ -2106,22 +2150,7 @@ class OrderChangeManager:
|
||||
for a in current_addons[cp][k][:current_num - input_num]:
|
||||
if a.canceled:
|
||||
continue
|
||||
is_unavailable = (
|
||||
# If an item is no longer available due to time, it should usually also be no longer
|
||||
# user-removable, because e.g. the stock has already been ordered.
|
||||
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
|
||||
# not mean it should be unremovable for others.
|
||||
# This also prevents accidental removal through the UI because a hidden product will no longer
|
||||
# be part of the input.
|
||||
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
|
||||
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
|
||||
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
|
||||
or (
|
||||
not a.item.all_sales_channels and
|
||||
not a.item.limit_sales_channels.contains(self.order.sales_channel)
|
||||
)
|
||||
)
|
||||
if is_unavailable:
|
||||
if not _addon_is_available(a):
|
||||
# "Re-select" add-on
|
||||
selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1
|
||||
continue
|
||||
|
||||
@@ -44,7 +44,7 @@ from django.conf import settings
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import CachedFile, Event, User, cachedfile_name
|
||||
@@ -171,15 +171,16 @@ def shred(self, event: Event, fileid: str, confirm_code: str, user: int=None, lo
|
||||
|
||||
if user:
|
||||
with language(user.locale):
|
||||
event_name = str(event.name)
|
||||
mail(
|
||||
user.email,
|
||||
_('Data shredding completed'),
|
||||
gettext('Data shredding completed for %(event)s') % {'event': event_name},
|
||||
'pretixbase/email/shred_completed.txt',
|
||||
{
|
||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||
'user': user,
|
||||
'organizer': event.organizer.name,
|
||||
'event': str(event.name),
|
||||
'event': event_name,
|
||||
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
|
||||
'shredders': ', '.join([str(s.verbose_name) for s in shredders])
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import register_ticket_outputs
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
from django.db import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -90,36 +91,43 @@ def generate(model: str, pk: int, provider: str):
|
||||
class DummyRollbackException(Exception):
|
||||
pass
|
||||
|
||||
def get_preview_position(event):
|
||||
connection = transaction.get_connection()
|
||||
if not connection.in_atomic_block:
|
||||
raise RuntimeError("get_preview_position needs to be called in a rolledback_transaction")
|
||||
|
||||
item = event.items.create(name=_("Sample product"), default_price=Decimal('42.23'),
|
||||
description=_("Sample product description"))
|
||||
item2 = event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40'))
|
||||
|
||||
def preview(event: int, provider: str):
|
||||
from pretix.base.models import Order
|
||||
order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
|
||||
email='sample@pretix.eu',
|
||||
locale=event.settings.locale,
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
expires=now(), code="PREVIEW1234", total=119)
|
||||
|
||||
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
sample = {k: str(v) for k, v in scheme['sample'].items()}
|
||||
position = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price)
|
||||
s = event.subevents.first()
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=position, subevent=s)
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=position, subevent=s)
|
||||
|
||||
InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company"))
|
||||
return position
|
||||
|
||||
def preview(event: int, provider: str, provider_arguments: dict = {}):
|
||||
event = Event.objects.get(id=event)
|
||||
|
||||
with rolledback_transaction(), language(event.settings.locale, event.settings.region):
|
||||
item = event.items.create(name=_("Sample product"), default_price=Decimal('42.23'),
|
||||
description=_("Sample product description"))
|
||||
item2 = event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40'))
|
||||
|
||||
from pretix.base.models import Order
|
||||
order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
|
||||
email='sample@pretix.eu',
|
||||
locale=event.settings.locale,
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
expires=now(), code="PREVIEW1234", total=119)
|
||||
|
||||
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
|
||||
sample = {k: str(v) for k, v in scheme['sample'].items()}
|
||||
p = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price)
|
||||
s = event.subevents.first()
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p, subevent=s)
|
||||
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p, subevent=s)
|
||||
|
||||
InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company"))
|
||||
p = get_preview_position(event)
|
||||
|
||||
responses = register_ticket_outputs.send(event)
|
||||
for receiver, response in responses:
|
||||
prov = response(event)
|
||||
if prov.identifier == provider:
|
||||
return prov.generate(p)
|
||||
return prov.generate(p, **provider_arguments)
|
||||
|
||||
|
||||
def get_tickets_for_order(order, base_position=None):
|
||||
|
||||
@@ -1276,7 +1276,7 @@ DEFAULTS = {
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'write_permission': 'event.settings.invoicing:write',
|
||||
'form_kwargs': dict(
|
||||
label=_("Allow to update existing invoices"),
|
||||
label=_("Allow updating existing invoices"),
|
||||
help_text=_("By default, invoices can never again be changed once they are issued. In most countries, we "
|
||||
"recommend to leave this option turned off and always issue a new invoice if a change needs "
|
||||
"to be made."),
|
||||
@@ -1924,8 +1924,6 @@ DEFAULTS = {
|
||||
'serializer_class': serializers.BooleanField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Hide all past dates from calendar"),
|
||||
help_text=_("This option currently only affects the calendar of this event series, not the organizer-wide "
|
||||
"calendar.")
|
||||
)
|
||||
},
|
||||
'allow_modifications': {
|
||||
@@ -2286,6 +2284,17 @@ DEFAULTS = {
|
||||
help_text=_("We'll show this publicly to allow attendees to contact you.")
|
||||
)
|
||||
},
|
||||
'contact_url': {
|
||||
'default': None,
|
||||
'type': str,
|
||||
'serializer_class': serializers.URLField,
|
||||
'form_class': forms.URLField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Contact URL"),
|
||||
help_text=_("If you set this, the footer contact link will point here instead of using the email address above. "
|
||||
"Please note that you still need to add a contact email address that will be shared with all emails you send.")
|
||||
)
|
||||
},
|
||||
'imprint_url': {
|
||||
'default': None,
|
||||
'type': str,
|
||||
|
||||
@@ -535,8 +535,9 @@ EventPluginRegistry = PluginAwareRegistry # for backwards compatibility
|
||||
event_live_issues = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out to determine whether an event can be taken live. If you want to
|
||||
prevent the event from going live, return a string that will be displayed to the user
|
||||
as the error message. If you don't, your receiver should return ``None``.
|
||||
prevent the event from going live, return an error message to display to the user (either
|
||||
as a SafeString containing HTML, or a string that will be HTML-escaped). If you don't,
|
||||
your receiver should return ``None``.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
{% load i18n %}
|
||||
{% trans "You have requested us to cancel an event which includes a larger bulk-refund:" %}
|
||||
{% trans "You requested to cancel an event that involves a large bulk refund:" %}
|
||||
|
||||
{% trans "Event" %}: {{ event }}
|
||||
- {% trans "Event" %}: {{ event }}
|
||||
- {% trans "Estimated refund" %}: **{{ amount }}**
|
||||
|
||||
{% trans "Estimated refund amount" %}: **{{ amount }}**
|
||||
{% trans "To confirm, paste the following code into the cancellation form:" %}
|
||||
|
||||
{% trans "Please confirm that you want to proceed by coping the following confirmation code into the cancellation form:" %}
|
||||
{{ confirmation_code }}
|
||||
|
||||
**{{ confirmation_code }}**
|
||||
{% blocktrans with instance=instance %}Don't share this code with anyone. The {{ instance }} team will never ask you for it.{% endblocktrans %}
|
||||
|
||||
{% blocktrans with instance=instance %}Thanks,
|
||||
The {{ instance }} Team{% endblocktrans %}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{% load i18n %}
|
||||
{% trans "Your export failed." %}
|
||||
{% trans "Your scheduled export failed." %}
|
||||
|
||||
{% trans "Reason:" %} {{ reason }}
|
||||
- {% trans "Reason" %}: {{ reason }}
|
||||
|
||||
{% if not soft %}
|
||||
{% trans "If your export fails five times in a row, it will no longer be sent." %}
|
||||
{% endif %}
|
||||
{% if not soft %}{% trans "If an export fails five times in a row, we'll stop sending it." %}{% endif %}
|
||||
|
||||
{% trans "Configuration link:" %}
|
||||
{% trans "You can adjust or remove this export here:" %}
|
||||
|
||||
{{ configuration_url }}
|
||||
|
||||
{% blocktrans with instance=instance %}Thanks,
|
||||
The {{ instance }} Team{% endblocktrans %}
|
||||
|
||||
@@ -52,13 +52,13 @@
|
||||
<table cellpadding="20"><tr><td>
|
||||
<![endif]-->
|
||||
<div class="content">
|
||||
{% trans "You receive these emails based on your notification settings." %}<br>
|
||||
{% trans "You're receiving this email based on your notification settings." %}<br>
|
||||
<a href="{{ settings_url }}">
|
||||
{% trans "Click here to view and change your notification settings" %}
|
||||
{% trans "Manage settings" %}
|
||||
</a>
|
||||
{% if disable_url %}<br>
|
||||
<a href="{{ disable_url }}">
|
||||
{% trans "Click here disable all notifications immediately." %}
|
||||
{% trans "Disable all notifications" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
{% load i18n %}
|
||||
{{ notification.title }}{% if notification.detail %}
|
||||
|
||||
{{ notification.detail }}
|
||||
{% endif %}{% if notification.url %}
|
||||
{{ notification.detail }}{% endif %}{% if notification.url %}
|
||||
|
||||
{{ notification.url }}{% endif %}{% for attr in notification.attributes %}
|
||||
{{ notification.url }}{% endif %}{% if notification.attributes %}
|
||||
|
||||
{{ attr.title }}: {{ attr.value }}{% endfor %}{% for action in notification.actions %}
|
||||
{% for attr in notification.attributes %}- {{ attr.title }}: {{ attr.value }}
|
||||
{% endfor %}{% endif %}{% for action in notification.actions %}
|
||||
|
||||
{{ action.label }}
|
||||
{{ action.url }}{% endfor %}
|
||||
{{ action.label }}:
|
||||
|
||||
{% trans "You receive these emails based on your notification settings." %}
|
||||
{% trans "Click here to view and change your notification settings:" %}
|
||||
{{ settings_url }}
|
||||
{% if disable_url %}{% trans "Click here disable all notifications immediately:" %}
|
||||
{{ disable_url }}
|
||||
{{ action.url }}{% endfor %}
|
||||
|
||||
---
|
||||
|
||||
{% trans "You're receiving this email based on your notification settings." %}
|
||||
|
||||
- {% trans "Manage settings" %}: {{ settings_url }}
|
||||
{% if disable_url %}- {% trans "Disable all notifications" %}: {{ disable_url }}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
{% load i18n %}
|
||||
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
|
||||
{% load i18n %}{% blocktrans %}Hello,
|
||||
|
||||
we hereby confirm that the following data shredding job has been completed:
|
||||
The following data shredding job has been completed:
|
||||
|
||||
Organizer: {{ organizer }}
|
||||
- Organizer: {{ organizer }}
|
||||
- Event: {{ event }}
|
||||
- Data selection: {{ shredders }}
|
||||
- Start time: {{ start_time }}
|
||||
|
||||
Event: {{ event }}
|
||||
Any data added to the event after the start time may not have been deleted.
|
||||
|
||||
Data selection: {{ shredders }}
|
||||
|
||||
Start time: {{ start_time }} (new data added after this time might not have been deleted)
|
||||
|
||||
Best regards,
|
||||
|
||||
Your {{ instance }} team
|
||||
Thanks,
|
||||
The {{ instance }} Team
|
||||
{% endblocktrans %}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "error.html" %}
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load urlreplace %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "Please continue in a new tab" %}</h1>
|
||||
<p class="larger">
|
||||
{% blocktrans trimmed %}
|
||||
For security reasons, the following step is only possible in a new tab.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<p class="larger">
|
||||
{% blocktrans trimmed %}
|
||||
If the new tab did not open automatically, please click the following button:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<div class="text-center">
|
||||
<a href="{{ url }}"
|
||||
class="btn btn-primary btn-lg" target="_blank">
|
||||
<span class="fa fa-external-link-square"></span>
|
||||
{% trans "Continue in new tab" %}
|
||||
</a>
|
||||
{{ url|json_script:"framebreak-url" }}
|
||||
<script type="text/javascript" src="{% static "pretixbase/js/framebreak.js" %}"></script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -2,13 +2,14 @@
|
||||
{% load i18n %}
|
||||
{% load rich_text %}
|
||||
{% load static %}
|
||||
{% load wrap_in %}
|
||||
{% block title %}{% trans "Redirect" %}{% endblock %}
|
||||
{% block content %}
|
||||
<i class="fa fa-link fa-fw big-icon"></i>
|
||||
<div class="error-details">
|
||||
<h1>{% trans "Redirect" %}</h1>
|
||||
<h3>
|
||||
{% blocktrans trimmed with host="<strong>"|add:hostname|add:"</strong>"|safe %}
|
||||
{% blocktrans trimmed with host=hostname|wrap_in:'strong' %}
|
||||
The link you clicked on wants to redirect you to a destination on the website {{ host }}.
|
||||
{% endblocktrans %}
|
||||
{% blocktrans trimmed %}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import importlib
|
||||
|
||||
from django import template
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from pretix.base.models import Event
|
||||
@@ -44,7 +45,7 @@ def eventsignal(event: Event, signame: str, **kwargs):
|
||||
_html = []
|
||||
for receiver, response in signal.send(event, **kwargs):
|
||||
if response:
|
||||
_html.append(response)
|
||||
_html.append(conditional_escape(response))
|
||||
return mark_safe("".join(_html))
|
||||
|
||||
|
||||
@@ -63,5 +64,5 @@ def signal(signame: str, request, **kwargs):
|
||||
_html = []
|
||||
for receiver, response in signal.send(request, **kwargs):
|
||||
if response:
|
||||
_html.append(response)
|
||||
_html.append(conditional_escape(response))
|
||||
return mark_safe("".join(_html))
|
||||
|
||||
@@ -26,6 +26,8 @@ from babel.numbers import format_currency
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.utils import formats
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from pretix.base.i18n import get_babel_locale
|
||||
|
||||
@@ -82,3 +84,19 @@ def money_numberfield_filter(value: Decimal, arg=''):
|
||||
|
||||
places = settings.CURRENCY_PLACES.get(arg, 2)
|
||||
return str(value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP))
|
||||
|
||||
|
||||
@register.filter(is_safe=True)
|
||||
def tax_rate_format(number):
|
||||
"""
|
||||
Display a Decimal to its significant decimal places, used for tax rates.
|
||||
"""
|
||||
assert isinstance(number, Decimal)
|
||||
return mark_safe(
|
||||
formats.number_format(
|
||||
number.normalize(),
|
||||
-number.as_tuple().exponent,
|
||||
use_l10n=True,
|
||||
force_grouping=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -42,8 +42,6 @@ from bleach import DEFAULT_CALLBACKS, html5lib_shim
|
||||
from bleach.linkifier import build_email_re
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.core import signing
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.html import escape
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
@@ -54,6 +52,7 @@ from markdown.postprocessors import Postprocessor
|
||||
from markdown.treeprocessors import UnescapeTreeprocessor
|
||||
from tlds import tld_set
|
||||
|
||||
from pretix.base.views.redirect import safelink
|
||||
from pretix.helpers.format import SafeFormatter, format_map
|
||||
|
||||
register = template.Library()
|
||||
@@ -158,8 +157,7 @@ def safelink_callback(attrs, new=False):
|
||||
"""
|
||||
url = html.unescape(attrs.get((None, 'href'), '/'))
|
||||
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
|
||||
signer = signing.Signer(salt='safe-redirect')
|
||||
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
||||
attrs[None, 'href'] = safelink(url)
|
||||
attrs[None, 'target'] = '_blank'
|
||||
attrs[None, 'rel'] = 'noopener'
|
||||
return attrs
|
||||
|
||||
@@ -114,7 +114,7 @@ class BaseTicketOutput:
|
||||
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
|
||||
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
|
||||
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
|
||||
If you want, you can just iterate over ``order.positions_with_tickets`` which applies the
|
||||
If you want, you can just iterate over ``self.get_tickets_to_print`` which applies the
|
||||
appropriate filters for you.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
@@ -192,6 +192,17 @@ class BaseTicketOutput:
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_meta(self) -> bool:
|
||||
"""
|
||||
Returns whether or whether not this output is a "meta" output that only works as a settings holder
|
||||
and should never be used directly. This is a trick to implement outputs with multiple formats but
|
||||
unified settings.
|
||||
|
||||
.. note:: You should set is_enabled to False for meta outputs.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def download_button_text(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -27,11 +27,11 @@ from django.template import TemplateDoesNotExist, loader
|
||||
from django.template.loader import get_template
|
||||
from django.utils.functional import Promise
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.csrf import requires_csrf_token
|
||||
from sentry_sdk import last_event_id
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.middleware import get_language_from_request
|
||||
from pretix.multidomain.middlewares import requires_csrf_token
|
||||
|
||||
|
||||
def csrf_failure(request, reason=""):
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
# 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
|
||||
import urllib.parse
|
||||
|
||||
from django.core import signing
|
||||
@@ -26,6 +27,8 @@ from django.http import HttpResponseBadRequest, HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_samesite_referer(request):
|
||||
referer = request.headers.get('referer')
|
||||
@@ -42,11 +45,16 @@ def _is_samesite_referer(request):
|
||||
|
||||
|
||||
def redir_view(request):
|
||||
signer = signing.Signer(salt='safe-redirect')
|
||||
framebreak = "framebreak" in request.GET
|
||||
salt = 'framebreak-safelink-url' if framebreak else 'safelink-url'
|
||||
try:
|
||||
url = signer.unsign(request.GET.get('url', ''))
|
||||
url = signing.Signer(salt=salt).unsign(request.GET.get('url', ''))
|
||||
except signing.BadSignature:
|
||||
return HttpResponseBadRequest('Invalid parameter')
|
||||
try:
|
||||
# Backwards-compatibility for a change in 2026-06, remove after a while
|
||||
url = signing.Signer(salt='safe-redirect').unsign(request.GET.get('url', ''))
|
||||
except signing.BadSignature:
|
||||
return HttpResponseBadRequest('Invalid parameter')
|
||||
|
||||
if not _is_samesite_referer(request):
|
||||
u = urllib.parse.urlparse(url)
|
||||
@@ -55,11 +63,26 @@ def redir_view(request):
|
||||
'url': url,
|
||||
})
|
||||
|
||||
if framebreak:
|
||||
r = render(request, 'pretixbase/framebreak.html', {
|
||||
'url': url,
|
||||
})
|
||||
r.xframe_options_exempt = True
|
||||
return r
|
||||
|
||||
r = HttpResponseRedirect(url)
|
||||
r['X-Robots-Tag'] = 'noindex'
|
||||
return r
|
||||
|
||||
|
||||
def safelink(url):
|
||||
signer = signing.Signer(salt='safe-redirect')
|
||||
return reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
||||
def safelink(url, framebreak=False):
|
||||
url = str(url)
|
||||
if not (url.startswith('https://') or url.startswith('http://') or url.startswith("/")):
|
||||
logger.warning('Invalid URL passed to safelink: %r', url)
|
||||
return '#invalid-url'
|
||||
salt = 'framebreak-safelink-url' if framebreak else 'safelink-url'
|
||||
signer = signing.Signer(salt=salt)
|
||||
u = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
||||
if framebreak:
|
||||
u += "&framebreak=true"
|
||||
return u
|
||||
|
||||
@@ -19,14 +19,56 @@
|
||||
# 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
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery import Celery, signals
|
||||
from django.dispatch import receiver
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
app = Celery('pretix')
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)
|
||||
|
||||
|
||||
@receiver(signals.before_task_publish)
|
||||
def on_before_task_publish(sender, body, exchange, routing_key, headers, properties, declare, retry_policy, **kwargs):
|
||||
from pretix.helpers.logs import local
|
||||
|
||||
trace = getattr(local, 'trace', [])
|
||||
request_id = getattr(local, 'request_id', None)
|
||||
if request_id:
|
||||
trace.append(request_id)
|
||||
|
||||
headers["X-Pretix-Trace"] = " ".join(trace)
|
||||
|
||||
|
||||
@receiver(signals.task_received)
|
||||
def on_task_received(sender, request, **kwargs):
|
||||
trace = request._request_dict.get("X-Pretix-Trace")
|
||||
if trace:
|
||||
logger.info(f"Task {request.id} has trace {trace}")
|
||||
|
||||
|
||||
@receiver(signals.task_prerun)
|
||||
def on_task_prerun(sender, task_id, task, **kwargs):
|
||||
from pretix.helpers.logs import local
|
||||
|
||||
local.request_id = task_id
|
||||
if task.request.headers and "X-Pretix-Trace" in task.request.headers:
|
||||
local.trace = task.request.headers["X-Pretix-Trace"].split(" ")
|
||||
else:
|
||||
local.trace = []
|
||||
local.trace.append(task_id)
|
||||
|
||||
|
||||
@receiver(signals.task_postrun)
|
||||
def on_task_postrun(sender, task_id, task, **kwargs):
|
||||
from pretix.helpers.logs import local
|
||||
|
||||
local.request_id = None
|
||||
local.trace = []
|
||||
|
||||
@@ -197,10 +197,10 @@ class EventWizardBasicsForm(I18nModelForm):
|
||||
'presale_end': SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'date_from': SplitDateTimePickerWidget(),
|
||||
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-date_from_0'}),
|
||||
'presale_start': SplitDateTimePickerWidget(),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-presale_start_0'}),
|
||||
'date_from': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-date_from_0'}, without_seconds=True),
|
||||
'presale_start': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_basics-presale_start_0'}, without_seconds=True),
|
||||
'slug': SlugWidget,
|
||||
}
|
||||
|
||||
@@ -521,11 +521,11 @@ class EventUpdateForm(I18nModelForm):
|
||||
'limit_sales_channels': SafeModelMultipleChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'date_from': SplitDateTimePickerWidget(),
|
||||
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
|
||||
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
|
||||
'presale_start': SplitDateTimePickerWidget(),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
|
||||
'date_from': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True),
|
||||
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}, without_seconds=True),
|
||||
'presale_start': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}, without_seconds=True),
|
||||
}
|
||||
|
||||
|
||||
@@ -625,6 +625,7 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
|
||||
'max_items_per_order',
|
||||
'reservation_time',
|
||||
'contact_mail',
|
||||
'contact_url',
|
||||
'show_variations_expanded',
|
||||
'hide_sold_out',
|
||||
'meta_noindex',
|
||||
@@ -671,6 +672,11 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
|
||||
|
||||
base_context = {
|
||||
'frontpage_text': ['event'],
|
||||
'presale_has_ended_text': ['event'],
|
||||
'voucher_explanation_text': ['event'],
|
||||
'banner_text': ['event'],
|
||||
'banner_text_bottom': ['event'],
|
||||
'event_info_text': ['event'],
|
||||
}
|
||||
|
||||
def _resolve_virtual_keys_input(self, data, prefix=''):
|
||||
@@ -945,7 +951,7 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
class ProviderForm(SettingsForm):
|
||||
"""
|
||||
This is a SettingsForm, but if fields are set to required=True, validation
|
||||
errors are only raised if the payment method is enabled.
|
||||
errors are only raised if the provider is enabled.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -1673,7 +1679,7 @@ class CountriesAndEUAndStates(CountriesAndEU):
|
||||
|
||||
class TaxRuleLineForm(I18nForm):
|
||||
country = LazyTypedChoiceField(
|
||||
choices=CountriesAndEUAndStates(),
|
||||
choices=lazy(lambda: CountriesAndEUAndStates(), CountriesAndEUAndStates),
|
||||
required=False
|
||||
)
|
||||
address_type = forms.ChoiceField(
|
||||
|
||||
@@ -770,7 +770,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
|
||||
)
|
||||
elif q.type == Question.TYPE_TIME:
|
||||
self.fields[fname] = forms.TimeField(
|
||||
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
|
||||
widget=TimePickerWidget(without_seconds=True),
|
||||
help_text=_('Exact matches only'),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -246,8 +246,8 @@ class QuestionForm(I18nModelForm):
|
||||
'valid_string_length_max',
|
||||
]
|
||||
widgets = {
|
||||
'valid_datetime_min': SplitDateTimePickerWidget(),
|
||||
'valid_datetime_max': SplitDateTimePickerWidget(),
|
||||
'valid_datetime_min': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'valid_datetime_max': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'valid_date_min': DatePickerWidget(),
|
||||
'valid_date_max': DatePickerWidget(),
|
||||
'items': forms.CheckboxSelectMultiple(
|
||||
@@ -1427,6 +1427,6 @@ class ItemProgramTimeForm(I18nModelForm):
|
||||
'end': forms.SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'start': SplitDateTimePickerWidget(),
|
||||
'end': SplitDateTimePickerWidget(),
|
||||
'start': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'end': SplitDateTimePickerWidget(without_seconds=True),
|
||||
}
|
||||
|
||||
@@ -604,6 +604,7 @@ class OrganizerSettingsForm(SettingsForm):
|
||||
'customer_accounts_require_login_for_order_access',
|
||||
'invoice_regenerate_allowed',
|
||||
'contact_mail',
|
||||
'contact_url',
|
||||
'imprint_url',
|
||||
'organizer_info_text',
|
||||
'event_list_type',
|
||||
|
||||
@@ -39,6 +39,7 @@ from pretix.base.reldate import RelativeDateTimeField, RelativeDateWrapper
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
|
||||
from pretix.control.forms.rrule import RRuleForm
|
||||
from pretix.helpers.i18n import get_javascript_format_without_seconds
|
||||
from pretix.helpers.money import change_decimal_field
|
||||
|
||||
|
||||
@@ -80,11 +81,11 @@ class SubEventForm(I18nModelForm):
|
||||
'presale_end': SplitDateTimeField,
|
||||
}
|
||||
widgets = {
|
||||
'date_from': SplitDateTimePickerWidget(),
|
||||
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
|
||||
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}),
|
||||
'presale_start': SplitDateTimePickerWidget(),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
|
||||
'date_from': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'date_to': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True),
|
||||
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_date_from_0'}, without_seconds=True),
|
||||
'presale_start': SplitDateTimePickerWidget(without_seconds=True),
|
||||
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}, without_seconds=True),
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +163,7 @@ class SubEventBulkEditForm(I18nModelForm):
|
||||
self.fields[k + '_time'] = forms.TimeField(
|
||||
label=self._meta.model._meta.get_field(k).verbose_name,
|
||||
help_text=self._meta.model._meta.get_field(k).help_text,
|
||||
widget=TimePickerWidget(),
|
||||
widget=TimePickerWidget(without_seconds=True),
|
||||
required=False,
|
||||
)
|
||||
|
||||
@@ -506,6 +507,12 @@ class TimeForm(forms.Form):
|
||||
required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['time_from'].widget.attrs['data-format'] = get_javascript_format_without_seconds("TIME_INPUT_FORMATS")
|
||||
self.fields['time_to'].widget.attrs['data-format'] = get_javascript_format_without_seconds("TIME_INPUT_FORMATS")
|
||||
self.fields['time_admission'].widget.attrs['data-format'] = get_javascript_format_without_seconds("TIME_INPUT_FORMATS")
|
||||
|
||||
|
||||
TimeFormSet = formset_factory(
|
||||
TimeForm,
|
||||
|
||||
@@ -32,16 +32,20 @@
|
||||
# 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
|
||||
import csv
|
||||
from collections import namedtuple
|
||||
from collections import Counter, namedtuple
|
||||
from io import StringIO
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import EmailValidator
|
||||
from django.db.models import Count, F, Max
|
||||
from django.db.models.functions import Upper
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from django_scopes.forms import SafeModelChoiceField
|
||||
|
||||
from pretix.base.email import get_available_placeholders
|
||||
@@ -50,7 +54,10 @@ from pretix.base.forms import (
|
||||
)
|
||||
from pretix.base.forms.widgets import format_placeholders_help_text
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Item, Voucher
|
||||
from pretix.base.models import Item, ItemVariation, Quota, SubEvent, Voucher
|
||||
from pretix.base.models.vouchers import VoucherBulkData
|
||||
from pretix.base.services.locking import lock_objects
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
|
||||
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
|
||||
from pretix.control.signals import voucher_form_validation
|
||||
@@ -105,15 +112,22 @@ class VoucherForm(I18nModelForm):
|
||||
except Item.DoesNotExist:
|
||||
pass
|
||||
super().__init__(*args, **kwargs)
|
||||
if not self.event and self.instance:
|
||||
self.event = self.instance.event
|
||||
|
||||
if instance.event.has_subevents:
|
||||
self.fields['subevent'].queryset = instance.event.subevents.all()
|
||||
self.fields['tag'].widget.attrs['data-typeahead-url'] = reverse('control:event.vouchers.tags.typeahead', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
})
|
||||
|
||||
if self.event.has_subevents:
|
||||
self.fields['subevent'].queryset = self.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': instance.event.slug,
|
||||
'organizer': instance.event.organizer.slug,
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
}
|
||||
)
|
||||
@@ -123,18 +137,19 @@ class VoucherForm(I18nModelForm):
|
||||
del self.fields['subevent']
|
||||
|
||||
choices = []
|
||||
if 'itemvar' in initial or (self.data and 'itemvar' in self.data):
|
||||
iv = self.data.get('itemvar') or initial.get('itemvar', '')
|
||||
prefix = (self.prefix + '-') if self.prefix else ''
|
||||
if 'itemvar' in initial or (self.data and prefix + 'itemvar' in self.data):
|
||||
iv = self.data.get(prefix + 'itemvar', '') or initial.get('itemvar', '') or ''
|
||||
if iv.startswith('q-'):
|
||||
q = self.instance.event.quotas.get(pk=iv[2:])
|
||||
q = self.event.quotas.get(pk=iv[2:])
|
||||
choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q)))
|
||||
elif '-' in iv:
|
||||
itemid, varid = iv.split('-')
|
||||
i = self.instance.event.items.get(pk=itemid)
|
||||
i = self.event.items.get(pk=itemid)
|
||||
v = i.variations.get(pk=varid)
|
||||
choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (str(i), v.value)))
|
||||
elif iv:
|
||||
i = self.instance.event.items.get(pk=iv)
|
||||
i = self.event.items.get(pk=iv)
|
||||
if i.variations.exists():
|
||||
choices.append((str(i.pk), _('{product} – Any variation').format(product=i)))
|
||||
else:
|
||||
@@ -145,8 +160,8 @@ class VoucherForm(I18nModelForm):
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:event.vouchers.itemselect2', kwargs={
|
||||
'event': instance.event.slug,
|
||||
'organizer': instance.event.organizer.slug,
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': _('All products')
|
||||
}
|
||||
@@ -154,7 +169,7 @@ class VoucherForm(I18nModelForm):
|
||||
self.fields['itemvar'].required = False
|
||||
self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices
|
||||
|
||||
if self.instance.event.seating_plan or self.instance.event.subevents.filter(seating_plan__isnull=False).exists():
|
||||
if self.event.seating_plan or self.event.subevents.filter(seating_plan__isnull=False).exists():
|
||||
self.fields['seat'] = forms.CharField(
|
||||
label=_("Specific seat ID"),
|
||||
max_length=255,
|
||||
@@ -164,40 +179,45 @@ class VoucherForm(I18nModelForm):
|
||||
help_text=str(self.instance.seat) if self.instance.seat else '',
|
||||
)
|
||||
|
||||
def parse_itemvar(self, data):
|
||||
try:
|
||||
itemid = quotaid = None
|
||||
iv = data.get('itemvar', '')
|
||||
if iv.startswith('q-'):
|
||||
quotaid = iv[2:]
|
||||
elif '-' in iv:
|
||||
itemid, varid = iv.split('-')
|
||||
elif iv:
|
||||
itemid, varid = iv, None
|
||||
else:
|
||||
itemid, varid = None, None
|
||||
|
||||
if itemid:
|
||||
item = self.event.items.get(pk=itemid)
|
||||
if varid:
|
||||
variation = item.variations.get(pk=varid)
|
||||
else:
|
||||
variation = None
|
||||
quota = None
|
||||
elif quotaid:
|
||||
quota = self.event.quotas.get(pk=quotaid)
|
||||
item = None
|
||||
variation = None
|
||||
else:
|
||||
quota = None
|
||||
item = None
|
||||
variation = None
|
||||
|
||||
return (item, variation, quota)
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(_("Invalid product selected."))
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
|
||||
if not self._errors:
|
||||
try:
|
||||
itemid = quotaid = None
|
||||
iv = self.data.get('itemvar', '')
|
||||
if iv.startswith('q-'):
|
||||
quotaid = iv[2:]
|
||||
elif '-' in iv:
|
||||
itemid, varid = iv.split('-')
|
||||
elif iv:
|
||||
itemid, varid = iv, None
|
||||
else:
|
||||
itemid, varid = None, None
|
||||
|
||||
if itemid:
|
||||
self.instance.item = self.instance.event.items.get(pk=itemid)
|
||||
if varid:
|
||||
self.instance.variation = self.instance.item.variations.get(pk=varid)
|
||||
else:
|
||||
self.instance.variation = None
|
||||
self.instance.quota = None
|
||||
elif quotaid:
|
||||
self.instance.quota = self.instance.event.quotas.get(pk=quotaid)
|
||||
self.instance.item = None
|
||||
self.instance.variation = None
|
||||
else:
|
||||
self.instance.quota = None
|
||||
self.instance.item = None
|
||||
self.instance.variation = None
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
raise ValidationError(_("Invalid product selected."))
|
||||
self.instance.item, self.instance.variation, self.instance.quota = self.parse_itemvar(self.data)
|
||||
|
||||
if 'codes' in data:
|
||||
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
|
||||
@@ -209,7 +229,7 @@ class VoucherForm(I18nModelForm):
|
||||
|
||||
try:
|
||||
Voucher.clean_item_properties(
|
||||
data, self.instance.event,
|
||||
data, self.event,
|
||||
self.instance.quota, self.instance.item, self.instance.variation,
|
||||
seats_given=data.get('seat') or data.get('seats'),
|
||||
block_quota=data.get('block_quota')
|
||||
@@ -229,7 +249,7 @@ class VoucherForm(I18nModelForm):
|
||||
|
||||
try:
|
||||
Voucher.clean_subevent(
|
||||
data, self.instance.event
|
||||
data, self.event
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise ValidationError({"subevent": e.message})
|
||||
@@ -245,19 +265,19 @@ class VoucherForm(I18nModelForm):
|
||||
if check_quota:
|
||||
Voucher.clean_quota_check(
|
||||
data, cnt, self.initial_instance_data,
|
||||
self.instance.event, self.instance.quota, self.instance.item, self.instance.variation
|
||||
self.event, self.instance.quota, self.instance.item, self.instance.variation
|
||||
)
|
||||
Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk)
|
||||
Voucher.clean_voucher_code(data, self.event, self.instance.pk)
|
||||
if 'seat' in self.fields:
|
||||
if data.get('seat'):
|
||||
self.instance.seat = Voucher.clean_seat_id(
|
||||
data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
|
||||
data, self.instance.item, self.instance.quota, self.event, self.instance.pk
|
||||
)
|
||||
self.instance.item = self.instance.seat.product
|
||||
else:
|
||||
self.instance.seat = None
|
||||
|
||||
voucher_form_validation.send(sender=self.instance.event, form=self, data=data)
|
||||
voucher_form_validation.send(sender=self.event, form=self, data=data)
|
||||
|
||||
return data
|
||||
|
||||
@@ -265,6 +285,215 @@ class VoucherForm(I18nModelForm):
|
||||
return super().save(commit)
|
||||
|
||||
|
||||
class VoucherBulkEditForm(VoucherForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.mixed_values = kwargs.pop('mixed_values')
|
||||
self.queryset = kwargs.pop('queryset')
|
||||
super().__init__(**kwargs)
|
||||
del self.fields["code"]
|
||||
self.fields.pop("seat", None)
|
||||
|
||||
def is_bulk_checked(self, fieldname):
|
||||
return self.prefix + fieldname in self.data.getlist('_bulk')
|
||||
|
||||
def clean(self):
|
||||
# We skip the parent class because it's not suited for bulk editing and implement custom validation here.
|
||||
# This does not validate *everything* we validate in VoucherForm. For example, we skip validation that one does
|
||||
# not create a voucher for an add-on product or that the seat matches the product to save on complexity.
|
||||
# This is a UX validation only anyway, since one could first create the voucher and then make the product an
|
||||
# add-on product. However, we need to validate everything that we don't want violated in the database.
|
||||
data = super(VoucherForm, self).clean()
|
||||
|
||||
if self.is_bulk_checked("itemvar"):
|
||||
data["item"], data["variation"], data["quota"] = self.parse_itemvar(data)
|
||||
|
||||
if self.is_bulk_checked("max_usages") and "max_usages" in data:
|
||||
max_redeemed = self.queryset.aggregate(m=Max("redeemed"))["m"]
|
||||
if data["max_usages"] < max_redeemed:
|
||||
raise ValidationError(_(
|
||||
"You cannot reduce the maximum number of redemptions to %(max_usages)s, because at least one "
|
||||
"of the selected vouchers has already been redeemed %(max_redeemed)s times."
|
||||
) % {"max_usages": data["max_usages"], "max_redeemed": max_redeemed})
|
||||
|
||||
# Check diff on product and quota usage based on old groups of vouchers
|
||||
if any(self.is_bulk_checked(k) for k in ("max_usages", "itemvar", "block_quota", "valid_until", "subevent")):
|
||||
quota_diff = Counter()
|
||||
|
||||
current_vouchers = self.queryset.order_by().values(
|
||||
"item", "variation", "quota", "block_quota", "valid_until", "subevent", "redeemed", "max_usages",
|
||||
"allow_ignore_quota",
|
||||
).annotate(c=Count("*"))
|
||||
item_cache = {i.pk: i for i in Item.objects.filter(pk__in=[c["item"] for c in current_vouchers])}
|
||||
var_cache = {v.pk: v for v in ItemVariation.objects.filter(pk__in=[c["variation"] for c in current_vouchers])}
|
||||
quota_cache = {q.pk: q for q in Quota.objects.filter(pk__in=[c["quota"] for c in current_vouchers])}
|
||||
subevent_cache = {s.pk: s for s in SubEvent.objects.filter(pk__in=[c["subevent"] for c in current_vouchers])}
|
||||
|
||||
for current in current_vouchers:
|
||||
bulk_count = current.pop('c')
|
||||
current = VoucherBulkData(**current)
|
||||
# Get quotas that are currently used
|
||||
if current.item:
|
||||
current.item = item_cache[current.item]
|
||||
if current.variation:
|
||||
current.variation = var_cache[current.variation]
|
||||
if current.quota:
|
||||
current.quota = quota_cache[current.quota]
|
||||
if current.subevent:
|
||||
current.subevent = subevent_cache[current.subevent]
|
||||
|
||||
old_quotas = Voucher.clean_quota_get_ignored(current)
|
||||
old_amount = max(current.max_usages - current.redeemed, 0) * bulk_count
|
||||
|
||||
# Predict state after change
|
||||
after_change = copy.copy(current)
|
||||
if self.is_bulk_checked("itemvar") and "itemvar" in data:
|
||||
after_change.item = data["item"]
|
||||
after_change.variation = data["variation"]
|
||||
after_change.quota = data["quota"]
|
||||
if self.is_bulk_checked("subevent") and "subevent" in data:
|
||||
after_change.subevent = data["subevent"]
|
||||
if self.is_bulk_checked("max_usages") and "max_usages" in data:
|
||||
after_change.max_usages = data["max_usages"]
|
||||
if self.is_bulk_checked("block_quota") and "block_quota" in data:
|
||||
after_change.block_quota = data["block_quota"]
|
||||
if self.is_bulk_checked("valid_until") and "valid_until" in data:
|
||||
after_change.valid_until = data["valid_until"]
|
||||
if self.is_bulk_checked("allow_ignore_quota") and "allow_ignore_quota" in data:
|
||||
after_change.allow_ignore_quota = data["allow_ignore_quota"]
|
||||
|
||||
if after_change.quota and self.event.has_subevents and not after_change.subevent:
|
||||
raise ValidationError(_("You cannot create a voucher that allows selection of a quota but has no date selected."))
|
||||
|
||||
if after_change.quota and after_change.subevent and after_change.quota.subevent_id != after_change.subevent.pk:
|
||||
raise ValidationError(_("The selected quota does not match the selected subevent."))
|
||||
|
||||
if after_change.block_quota and self.event.has_subevents and not after_change.subevent:
|
||||
raise ValidationError(
|
||||
_('If you want this voucher to block quota, you need to select a specific date.'))
|
||||
|
||||
if after_change.block_quota and not after_change.item and not after_change.quota:
|
||||
raise ValidationError(
|
||||
_('You need to select a specific product or quota if this voucher should reserve '
|
||||
'tickets.')
|
||||
)
|
||||
|
||||
if after_change.allow_ignore_quota:
|
||||
# todo: is this the most useful way to do this?
|
||||
continue
|
||||
|
||||
new_quotas = Voucher.clean_quota_get_ignored(after_change)
|
||||
new_amount = max(after_change.max_usages - after_change.redeemed, 0) * bulk_count
|
||||
|
||||
if new_quotas != old_quotas or new_amount != old_amount:
|
||||
for q in old_quotas:
|
||||
quota_diff[q] -= old_amount
|
||||
for q in new_quotas:
|
||||
quota_diff[q] += new_amount
|
||||
|
||||
if any(v > 0 for q, v in quota_diff.items()):
|
||||
lock_objects([q for q, v in quota_diff.items() if q.size is not None and v > 0], shared_lock_objects=[self.event])
|
||||
qa = QuotaAvailability(count_waitinglist=False)
|
||||
qa.queue(*(q for q, v in quota_diff.items() if v > 0))
|
||||
qa.compute()
|
||||
|
||||
if any(qa.results[q][0] != Quota.AVAILABILITY_OK or (qa.results[q][1] is not None and qa.results[q][1] < required)
|
||||
for q, required in quota_diff.items() if required > 0):
|
||||
raise ValidationError(_(
|
||||
'There is no sufficient quota available to perform this change.'
|
||||
))
|
||||
|
||||
has_seat = self.queryset.filter(seat__isnull=False).exists()
|
||||
if has_seat:
|
||||
if self.is_bulk_checked("max_usages"):
|
||||
raise ValidationError(_(
|
||||
'Changing the maximum number of usages in bulk is not supported if any of the selected vouchers '
|
||||
'is assigned a seat.'
|
||||
))
|
||||
if self.is_bulk_checked("subevent"):
|
||||
raise ValidationError(pgettext_lazy(
|
||||
'subevent',
|
||||
'Changing the date in bulk is not supported if any of the selected vouchers '
|
||||
'is assigned a seat.'
|
||||
))
|
||||
if self.is_bulk_checked("itemvar") and data["quota"]:
|
||||
raise ValidationError(_(
|
||||
'Changing the product to a quota is not supported if any of the selected vouchers '
|
||||
'is assigned a seat.'
|
||||
))
|
||||
|
||||
if self.is_bulk_checked("valid_until"):
|
||||
if data["valid_until"] is None or data["valid_until"] >= now():
|
||||
currently_not_blocked_seats = self.queryset.filter(
|
||||
seat__isnull=False,
|
||||
max_usages__gt=F("redeemed"),
|
||||
valid_until__lt=now(),
|
||||
)
|
||||
if self.event.has_subevents:
|
||||
subevents = self.event.subevents.filter(pk__in=currently_not_blocked_seats.values_list("subevent"))
|
||||
for se in subevents:
|
||||
conflicts = currently_not_blocked_seats.filter(
|
||||
subevent=se
|
||||
).exclude(
|
||||
seat_id__in=se.free_seats().values("pk")
|
||||
)
|
||||
if conflicts:
|
||||
raise ValidationError(_(
|
||||
'This change cannot be completed because not all assigned seats of the vouchers are '
|
||||
'still available'
|
||||
))
|
||||
else:
|
||||
conflicts = currently_not_blocked_seats.exclude(
|
||||
seat_id__in=self.event.free_seats().values("pk")
|
||||
)
|
||||
if conflicts:
|
||||
raise ValidationError(_(
|
||||
'This change cannot be completed because not all assigned seats of the vouchers are '
|
||||
'still available'
|
||||
))
|
||||
|
||||
return data
|
||||
|
||||
def save(self, commit=True):
|
||||
objs = list(self.queryset)
|
||||
fields = set()
|
||||
|
||||
check_map = {
|
||||
'price_mode': '__price',
|
||||
'value': '__price',
|
||||
}
|
||||
for k in self.fields:
|
||||
if not self.is_bulk_checked(check_map.get(k, k)):
|
||||
continue
|
||||
|
||||
if k == 'itemvar':
|
||||
fields.add("item")
|
||||
fields.add("variation")
|
||||
fields.add("quota")
|
||||
else:
|
||||
fields.add(k)
|
||||
for obj in objs:
|
||||
if k == 'itemvar':
|
||||
obj.item = self.cleaned_data["item"]
|
||||
obj.variation = self.cleaned_data["variation"]
|
||||
obj.quota = self.cleaned_data["quota"]
|
||||
else:
|
||||
setattr(obj, k, self.cleaned_data[k])
|
||||
|
||||
fields = [f for f in fields if f != 'itemvars']
|
||||
if fields:
|
||||
Voucher.objects.bulk_update(objs, fields, 200)
|
||||
|
||||
def full_clean(self):
|
||||
if len(self.data) == 0:
|
||||
# form wasn't submitted
|
||||
self._errors = ErrorDict()
|
||||
return
|
||||
super().full_clean()
|
||||
|
||||
def _post_clean(self):
|
||||
pass # skip model-level clean
|
||||
|
||||
|
||||
class VoucherBulkForm(VoucherForm):
|
||||
codes = forms.CharField(
|
||||
widget=forms.Textarea,
|
||||
|
||||
@@ -36,7 +36,8 @@ from urllib.parse import quote, urljoin, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import REDIRECT_FIELD_NAME, logout
|
||||
from django.http import Http404
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.http import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, resolve_url
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import get_script_prefix, resolve, reverse
|
||||
@@ -97,6 +98,8 @@ class PermissionMiddleware:
|
||||
super().__init__()
|
||||
|
||||
def _login_redirect(self, request):
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
|
||||
# Taken from django/contrib/auth/decorators.py
|
||||
path = request.build_absolute_uri()
|
||||
# urlparse chokes on lazy objects in Python 3, force to str
|
||||
@@ -109,10 +112,21 @@ class PermissionMiddleware:
|
||||
if ((not login_scheme or login_scheme == current_scheme) and
|
||||
(not login_netloc or login_netloc == current_netloc)):
|
||||
path = request.get_full_path()
|
||||
from django.contrib.auth.views import redirect_to_login
|
||||
|
||||
return redirect_to_login(
|
||||
path, resolved_login_url, REDIRECT_FIELD_NAME)
|
||||
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
|
||||
# It's not useful to return a 302 redirect on a XMLHttpRequest request, because
|
||||
# the XMLHttpRequest is unable to detect redirects.
|
||||
return HttpResponse(
|
||||
"Authentication required",
|
||||
status=401,
|
||||
headers={
|
||||
# Appending ?next= is handled by client, because it should be the top-level context url,
|
||||
# not the URL called in the background
|
||||
"X-Login-Url": resolved_login_url,
|
||||
}
|
||||
)
|
||||
|
||||
return redirect_to_login(path, resolved_login_url, REDIRECT_FIELD_NAME)
|
||||
|
||||
def __call__(self, request):
|
||||
url = resolve(request.path_info)
|
||||
@@ -212,14 +226,17 @@ class AuditLogMiddleware:
|
||||
if request.path.startswith(get_script_prefix() + 'control') and request.user.is_authenticated:
|
||||
if getattr(request.user, "is_hijacked", False):
|
||||
hijack_history = request.session.get('hijack_history', False)
|
||||
hijacker = get_object_or_404(User, pk=hijack_history[0])
|
||||
hijacker = get_object_or_404(User, pk=hijack_history[0]["user"])
|
||||
ss = hijacker.get_active_staff_session(request.session.get('hijacker_session'))
|
||||
if ss:
|
||||
ss.logs.create(
|
||||
url=request.path,
|
||||
method=request.method,
|
||||
impersonating=request.user
|
||||
)
|
||||
if not ss:
|
||||
# Staff session expired or not found
|
||||
logout(request)
|
||||
return redirect_to_login(request.get_full_path())
|
||||
ss.logs.create(
|
||||
url=request.path,
|
||||
method=request.method,
|
||||
impersonating=request.user
|
||||
)
|
||||
else:
|
||||
ss = request.user.get_active_staff_session(request.session.session_key)
|
||||
if ss:
|
||||
|
||||
@@ -39,7 +39,8 @@ from pretix.base.signals import (
|
||||
html_page_start = GlobalSignal()
|
||||
"""
|
||||
This signal allows you to put code in the beginning of the main page for every
|
||||
page in the backend. You are expected to return HTML.
|
||||
page in the backend. You are expected to return a SafeString containing HTML, or
|
||||
a string that will be HTML-escaped.
|
||||
|
||||
The ``sender`` keyword argument will contain the request.
|
||||
"""
|
||||
@@ -129,7 +130,7 @@ event_dashboard_top = EventPluginSignal()
|
||||
Arguments: 'request'
|
||||
|
||||
This signal is sent out to include custom HTML in the top part of the the event dashboard.
|
||||
Receivers should return HTML.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
An additional keyword argument ``subevent`` *can* contain a sub-event.
|
||||
@@ -140,7 +141,7 @@ event_dashboard_widgets = EventPluginSignal()
|
||||
This signal is sent out to include widgets in the event dashboard. Receivers
|
||||
should return a list of dictionaries, where each dictionary can have the keys:
|
||||
|
||||
* content (str, containing HTML)
|
||||
* content (SafeString, containing HTML)
|
||||
* display_size (str, one of "full" (whole row), "big" (half a row) or "small"
|
||||
(quarter of a row). May be ignored on small displays, default is "small")
|
||||
* priority (int, used for ordering, higher comes first, default is 1)
|
||||
@@ -157,7 +158,7 @@ Arguments: 'user'
|
||||
This signal is sent out to include widgets in the personal user dashboard. Receivers
|
||||
should return a list of dictionaries, where each dictionary can have the keys:
|
||||
|
||||
* content (str, containing HTML)
|
||||
* content (SafeString, containing HTML)
|
||||
* display_size (str, one of "full" (whole row), "big" (half a row) or "small"
|
||||
(quarter of a row). May be ignored on small displays, default is "small")
|
||||
* priority (int, used for ordering, higher comes first, default is 1)
|
||||
@@ -172,6 +173,7 @@ Arguments: 'form'
|
||||
|
||||
This signal allows you to add additional HTML to the form that is used for modifying vouchers.
|
||||
You receive the form object in the ``form`` keyword argument.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
@@ -209,6 +211,7 @@ Arguments: 'quota'
|
||||
|
||||
This signal allows you to append HTML to a Quota's detail view. You receive the
|
||||
quota as argument in the ``quota`` keyword argument.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
@@ -219,6 +222,7 @@ Arguments: 'subevent'
|
||||
|
||||
This signal allows you to append HTML to a SubEvent's detail view. You receive the
|
||||
subevent as argument in the ``subevent`` keyword argument.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
@@ -265,7 +269,8 @@ order_info = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``order``, ``request``
|
||||
|
||||
This signal is sent out to display additional information on the order detail page
|
||||
This signal is sent out to display additional information on the order detail page.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
Additionally, the argument ``order`` and ``request`` are available.
|
||||
@@ -275,7 +280,8 @@ order_approve_info = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``order``, ``request``
|
||||
|
||||
This signal is sent out to display additional information on the order approve page
|
||||
This signal is sent out to display additional information on the order approve page.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
Additionally, the argument ``order`` and ``request`` are available.
|
||||
@@ -286,6 +292,7 @@ order_position_buttons = EventPluginSignal()
|
||||
Arguments: ``order``, ``position``, ``request``
|
||||
|
||||
This signal is sent out to display additional buttons for a single position of an order.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
Additionally, the argument ``order`` and ``request`` are available.
|
||||
@@ -315,6 +322,7 @@ Arguments: 'request'
|
||||
|
||||
This signal is sent out to include template snippets on the settings page of an event
|
||||
that allows generating a pretix Widget code.
|
||||
Receivers should return a SafeString containing HTML, or a string that will be HTML-escaped.
|
||||
|
||||
As with all event plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
A second keyword argument ``request`` will contain the request object.
|
||||
|
||||
@@ -38,6 +38,7 @@ from pretix import __version__
|
||||
from pretix.base.models import Order, OrderPayment, Transaction
|
||||
from pretix.base.plugins import get_all_plugins
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.helpers.reportlab import PlainTextParagraph
|
||||
from pretix.plugins.reports.exporters import ReportlabExportMixin
|
||||
from pretix.settings import DATA_DIR
|
||||
|
||||
@@ -79,23 +80,23 @@ class SysReport(ReportlabExportMixin):
|
||||
style_small.fontSize = 6
|
||||
|
||||
story = [
|
||||
Paragraph("System report", headlinestyle),
|
||||
PlainTextParagraph("System report", headlinestyle),
|
||||
Spacer(1, 5 * mm),
|
||||
Paragraph("Usage", subheadlinestyle),
|
||||
PlainTextParagraph("Usage", subheadlinestyle),
|
||||
Spacer(1, 5 * mm),
|
||||
self._usage_table(),
|
||||
Spacer(1, 5 * mm),
|
||||
Paragraph("Installed versions", subheadlinestyle),
|
||||
PlainTextParagraph("Installed versions", subheadlinestyle),
|
||||
Spacer(1, 5 * mm),
|
||||
self._tech_table(),
|
||||
Spacer(1, 5 * mm),
|
||||
Paragraph("Plugins", subheadlinestyle),
|
||||
PlainTextParagraph("Plugins", subheadlinestyle),
|
||||
Spacer(1, 5 * mm),
|
||||
Paragraph(self._get_plugin_versions(), style_small),
|
||||
PlainTextParagraph(self._get_plugin_versions(), style_small),
|
||||
Spacer(1, 5 * mm),
|
||||
Paragraph("Custom templates", subheadlinestyle),
|
||||
PlainTextParagraph("Custom templates", subheadlinestyle),
|
||||
Spacer(1, 5 * mm),
|
||||
Paragraph(self._get_custom_templates(), style_small),
|
||||
PlainTextParagraph(self._get_custom_templates(), style_small),
|
||||
Spacer(1, 5 * mm),
|
||||
]
|
||||
|
||||
@@ -121,13 +122,13 @@ class SysReport(ReportlabExportMixin):
|
||||
("RIGHTPADDING", (-1, 0), (-1, -1), 0),
|
||||
]
|
||||
tdata = [
|
||||
[Paragraph("Site URL:", style), Paragraph(settings.SITE_URL, style)],
|
||||
[Paragraph("pretix version:", style), Paragraph(__version__, style)],
|
||||
[Paragraph("Python version:", style), Paragraph(sys.version, style)],
|
||||
[Paragraph("Platform:", style), Paragraph(platform.platform(), style)],
|
||||
[PlainTextParagraph("Site URL:", style), Paragraph(settings.SITE_URL, style)],
|
||||
[PlainTextParagraph("pretix version:", style), Paragraph(__version__, style)],
|
||||
[PlainTextParagraph("Python version:", style), Paragraph(sys.version, style)],
|
||||
[PlainTextParagraph("Platform:", style), Paragraph(platform.platform(), style)],
|
||||
[
|
||||
Paragraph("Database engine:", style),
|
||||
Paragraph(settings.DATABASES["default"]["ENGINE"], style),
|
||||
PlainTextParagraph("Database engine:", style),
|
||||
PlainTextParagraph(settings.DATABASES["default"]["ENGINE"], style),
|
||||
],
|
||||
]
|
||||
table = Table(tdata, colWidths=colwidths, repeatRows=0)
|
||||
@@ -206,7 +207,7 @@ class SysReport(ReportlabExportMixin):
|
||||
year_last = now().year
|
||||
tdata = [
|
||||
[
|
||||
Paragraph(l, style_small_head)
|
||||
PlainTextParagraph(l, style_small_head)
|
||||
for l in (
|
||||
"Time frame",
|
||||
"Currency",
|
||||
@@ -257,19 +258,19 @@ class SysReport(ReportlabExportMixin):
|
||||
|
||||
tdata.append(
|
||||
(
|
||||
Paragraph(
|
||||
PlainTextParagraph(
|
||||
date_format(first_day, "M Y")
|
||||
+ " – "
|
||||
+ date_format(after_day - timedelta(days=1), "M Y"),
|
||||
style_small,
|
||||
),
|
||||
Paragraph(c, style_small),
|
||||
Paragraph(str(orders_count), style_small) if i == 0 else "",
|
||||
Paragraph(money_filter(revenue_data.get("s_net") or 0, c), style_small),
|
||||
Paragraph(str(testmode_count), style_small) if i == 0 else "",
|
||||
Paragraph(str(unconfirmed_count), style_small) if i == 0 else "",
|
||||
Paragraph(str(revenue_data.get("c") or 0), style_small),
|
||||
Paragraph(money_filter(revenue_data.get("s_gross") or 0, c), style_small),
|
||||
PlainTextParagraph(c, style_small),
|
||||
PlainTextParagraph(str(orders_count), style_small) if i == 0 else "",
|
||||
PlainTextParagraph(money_filter(revenue_data.get("s_net") or 0, c), style_small),
|
||||
PlainTextParagraph(str(testmode_count), style_small) if i == 0 else "",
|
||||
PlainTextParagraph(str(unconfirmed_count), style_small) if i == 0 else "",
|
||||
PlainTextParagraph(str(revenue_data.get("c") or 0), style_small),
|
||||
PlainTextParagraph(money_filter(revenue_data.get("s_gross") or 0, c), style_small),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -56,5 +56,4 @@
|
||||
</form>
|
||||
<script type="text/plain" id="good_origin">{{ good_origin }}</script>
|
||||
<script type="text/plain" id="bad_origin_report_url">{{ bad_origin_report_url }}</script>
|
||||
<!-- pretix-login-marker -->{# marker required for ajax calls to detect that user session is over #}
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
{% load escapejson %}
|
||||
{% block content %}
|
||||
<form class="form-signin" action="" method="post" id="webauthn-form">
|
||||
{% csrf_token %}
|
||||
@@ -30,8 +31,7 @@
|
||||
</form>
|
||||
{% if jsondata %}
|
||||
<script type="text/json" id="webauthn-login">
|
||||
{{ jsondata|safe }}
|
||||
|
||||
{{ jsondata|escapejson }}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% compress js %}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,7 +72,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
@@ -102,7 +102,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello,
|
||||
{% load i18n %}{% blocktrans with code=code reason=reason instance=instance %}Hello,
|
||||
|
||||
{{ reason }}
|
||||
|
||||
{{ code }}
|
||||
|
||||
Please do never give this code to another person. Our support team will never ask for this code.
|
||||
Don't share this code with anyone. The {{ instance }} team will never ask you for it.
|
||||
|
||||
If this code was not requested by you, please contact us immediately.
|
||||
If you didn't request this code, please contact us immediately.
|
||||
|
||||
Best regards,
|
||||
Your {{ instance }} team
|
||||
Thanks,
|
||||
The {{ instance }} Team
|
||||
{% endblocktrans %}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
{% load i18n %}{% blocktrans with code=code instance=instance %}Hello,
|
||||
{% load i18n %}{% blocktrans with code=code address=address instance=instance %}Hello,
|
||||
|
||||
someone requested to use {{ address }} as a sender address on {{ instance }}.
|
||||
This will allow them to send emails that are shown to originate from this email address.
|
||||
If that was you, please enter the following confirmation code:
|
||||
Someone requested to use {{ address }} as a sender address on {{ instance }}. Once verified, emails sent from {{ instance }} can show this address as the sender.
|
||||
|
||||
{{ code }}
|
||||
If this was you, enter the following code in the setup form:
|
||||
|
||||
If this was not requested by you, you can safely ignore this email.
|
||||
{{ code }}
|
||||
|
||||
Best regards,
|
||||
Don't share this code with anyone unless you want to authorize them to use this address for this purpose. The {{ instance }} team will never ask you for it.
|
||||
|
||||
Your {{ instance }} team
|
||||
{% endblocktrans %}
|
||||
If you didn't request this, you can safely ignore this email.
|
||||
|
||||
Thanks,
|
||||
The {{ instance }} Team
|
||||
{% endblocktrans %}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
|
||||
|
||||
you requested a new password. Please go to the following page to reset your password:
|
||||
We received a request to reset the password for your {{ instance }} account. To choose a new password, follow the link below:
|
||||
|
||||
{{ url }}
|
||||
|
||||
Best regards,
|
||||
Your {{ instance }} team
|
||||
If you didn't request this, you can safely ignore this email — your password won't change.
|
||||
|
||||
Thanks,
|
||||
The {{ instance }} Team
|
||||
{% endblocktrans %}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
{% load i18n %}{% blocktrans with url=url|safe %}Hello,
|
||||
|
||||
you have been invited to a team on {{ instance }}, a platform to perform event
|
||||
ticket sales.
|
||||
You've been invited to join a team on {{ instance }}, an event ticket sales platform.
|
||||
|
||||
Organizer: {{ organizer }}
|
||||
Team: {{ team }}
|
||||
- Organizer: {{ organizer }}
|
||||
- Team: {{ team }}
|
||||
|
||||
To accept, follow the link below:
|
||||
|
||||
If you want to join that team, just click on the following link:
|
||||
{{ url }}
|
||||
|
||||
If you do not want to join, you can safely ignore or delete this email.
|
||||
If you don't want to join, you can safely ignore this email.
|
||||
|
||||
Best regards,
|
||||
|
||||
Your {{ instance }} team
|
||||
Thanks,
|
||||
The {{ instance }} Team
|
||||
{% endblocktrans %}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
{% load i18n %}{% blocktrans with url=url|safe os=source.os_type agent=source.agent_type %}Hello,
|
||||
{% load i18n %}{% blocktrans %}Hello,
|
||||
|
||||
a login to your {{ instance }} account from an unusual or new location was detected. The login was performed using {{ agent }} on {{ os }} from {{ country }}.
|
||||
We noticed a new sign-in to your {{ instance }} account:
|
||||
{% endblocktrans %}
|
||||
- {% trans "Time" %}: {{ when }}
|
||||
- {% trans "Browser" %}: {{ agent }}
|
||||
- {% trans "Operating system" %}: {{ os }}
|
||||
{% if device %}- {% trans "Device" %}: {{ device }}
|
||||
{% endif %}{% if country %}- {% trans "Country" %}: {{ country }}
|
||||
{% endif %}
|
||||
{% blocktrans with url=url|safe %}If it was you, no action is needed.
|
||||
|
||||
If this was you, you can safely ignore this email.
|
||||
|
||||
If this was not you, we recommend that you change your password in your account settings:
|
||||
If you don't recognize this sign-in, please change your password immediately:
|
||||
|
||||
{{ url }}
|
||||
|
||||
Best regards,
|
||||
Your {{ instance }} team
|
||||
Thanks,
|
||||
The {{ instance }} Team
|
||||
{% endblocktrans %}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
{% load i18n %}{% blocktrans with url=url|safe messages=messages|safe %}Hello,
|
||||
|
||||
this is to inform you that the account information of your {{ instance }} account has been
|
||||
changed. In particular, the following changes have been performed:
|
||||
The following changes were made to your {{ instance }} account:
|
||||
|
||||
{{ messages }}
|
||||
|
||||
If this change was not performed by you, please contact us immediately.
|
||||
If you didn't make these changes, please contact the {{ instance }} support team immediately.
|
||||
|
||||
You can review and change your account settings here:
|
||||
You can review your account settings here:
|
||||
|
||||
{{ url }}
|
||||
|
||||
Best regards,
|
||||
Your {{ instance }} team
|
||||
Thanks,
|
||||
The {{ instance }} Team
|
||||
{% endblocktrans %}
|
||||
|
||||
@@ -50,6 +50,46 @@
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if dkim_warning %}
|
||||
<div class="alert alert-danger">
|
||||
<p>
|
||||
{{ dkim_warning }}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "Your new DKIM record should be set up as a CNAME record like this:" %}
|
||||
</p>
|
||||
<pre><code>{{ dkim_hostname }} CNAME {{ dkim_cname }}</code></pre>
|
||||
<p>
|
||||
{% trans "Please keep in mind that updates to DNS might require multiple hours to take effect." %}
|
||||
</p>
|
||||
</div>
|
||||
{% elif dkim_cname %}
|
||||
<div class="alert alert-success">
|
||||
{% blocktrans trimmed %}
|
||||
We found a DKIM record on your domain for this system. Great!
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if dmarc_warning %}
|
||||
<div class="alert alert-danger">
|
||||
<p>
|
||||
{{ dmarc_warning }}
|
||||
</p>
|
||||
<p>
|
||||
{% trans "Your new DMARC record could look like this:" %}
|
||||
</p>
|
||||
<pre><code>_dmarc.{{ hostname }} TXT "v=DMARC1; p=quarantine; sp=none; adkim=r; aspf=r;"</code></pre>
|
||||
<p>
|
||||
{% trans "Please keep in mind that updates to DNS might require multiple hours to take effect." %}
|
||||
</p>
|
||||
</div>
|
||||
{% elif dkim_cname %}
|
||||
<div class="alert alert-success">
|
||||
{% blocktrans trimmed %}
|
||||
We found a DMARC record on your domain for this system. Great!
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if verification %}
|
||||
<h3>{% trans "Verification" %}</h3>
|
||||
<p>
|
||||
@@ -70,7 +110,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if spf_warning %}
|
||||
{% if spf_warning or dkim_warning or dmarc_warning %}
|
||||
<div class="form-group submit-group">
|
||||
<a href="" class="btn btn-default btn-save">
|
||||
{% trans "Cancel" %}
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% elif w.link %}
|
||||
@@ -114,7 +114,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x´"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
@@ -122,7 +122,7 @@
|
||||
{% if w.lazy %}
|
||||
<span class="fa fa-cog fa-4x"></span>
|
||||
{% else %}
|
||||
{{ w.content|safe }}
|
||||
{{ w.content }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
<ul>
|
||||
{% for issue in issues %}
|
||||
<li>{{ issue|safe }}</li>
|
||||
<li>{{ issue }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@
|
||||
</p>
|
||||
<ul>
|
||||
{% for issue in issues %}
|
||||
<li>{{ issue|safe }}</li>
|
||||
<li>{{ issue }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<table class="table table-payment-providers">
|
||||
<tbody>
|
||||
{% for provider in providers %}
|
||||
<tr>
|
||||
<tr{% if provider.highlight %} class="success-left"{% endif %}>
|
||||
<td>
|
||||
<strong>{{ provider.verbose_name }}</strong>
|
||||
</td>
|
||||
@@ -56,7 +56,7 @@
|
||||
<td colspan="4">
|
||||
<br>
|
||||
{% url "control:event.settings.plugins" event=request.event.slug organizer=request.organizer.slug as plugin_settings_url %}
|
||||
<a href="{{ plugin_settings_url }}#tab-0-1-open" class="btn btn-default">
|
||||
<a href="{{ plugin_settings_url }}?go=payment#tab-0-1-open" class="btn btn-default">
|
||||
<i class="fa fa-plus"></i> {% trans "Enable additional payment plugins" %}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
</div>
|
||||
<form action="" method="post" class="form-horizontal form-plugins">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="go" value="{{ request.GET.go }}">
|
||||
<div id="plugin_search_results" class="panel panel-default collapse">
|
||||
<div class="panel-heading">
|
||||
<button type="button" class="close" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
{% bootstrap_field form.date_admission layout="control" %}
|
||||
{% bootstrap_field form.currency layout="control" %}
|
||||
{% bootstrap_field sform.contact_mail layout="control" %}
|
||||
{% bootstrap_field sform.contact_url layout="control" %}
|
||||
{% bootstrap_field sform.imprint_url layout="control" %}
|
||||
{% bootstrap_field form.is_public layout="control" %}
|
||||
{% bootstrap_field form.all_sales_channels layout="control" %}
|
||||
|
||||
@@ -156,11 +156,11 @@
|
||||
<br/>
|
||||
<small class="text-muted">
|
||||
{% if not i.tax_rule.price_includes_tax %}
|
||||
{% blocktrans trimmed with rate=i.tax_rule.rate|floatformat:-2 taxname=i.tax_rule.name %}
|
||||
{% blocktrans trimmed with rate=i.tax_rule.rate|tax_rate_format taxname=i.tax_rule.name %}
|
||||
<strong>plus</strong> {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans trimmed with rate=i.tax_rule.rate|floatformat:-2 taxname=i.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=i.tax_rule.rate|tax_rate_format taxname=i.tax_rule.name|default:s_taxes %}
|
||||
incl. {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% load money %}
|
||||
{% load wrap_in %}
|
||||
{% block title %}
|
||||
{% trans "Cancel order" %}
|
||||
{% endblock %}
|
||||
@@ -26,7 +27,7 @@
|
||||
{% if form.cancellation_fee %}
|
||||
{% if fee %}
|
||||
{% with fee|money:request.event.currency as f %}
|
||||
<p>{% blocktrans trimmed with fee="<strong>"|add:f|add:"</strong>"|safe %}
|
||||
<p>{% blocktrans trimmed with fee=f|wrap_in:"strong" %}
|
||||
The configured cancellation fee for a self-service cancellation would be {{ fee }} for this
|
||||
order, but for a cancellation performed by you, you need to set the cancellation fee here:
|
||||
{% endblocktrans %}</p>
|
||||
|
||||
@@ -284,6 +284,14 @@
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
{% bootstrap_field position.form.operation_secret layout='inline' %}
|
||||
{% if position.issued_gift_cards.exists %}
|
||||
<div class="alert alert-info">
|
||||
{% blocktrans trimmed %}
|
||||
Ticket secrets of order positions that have been used to issue a gift card can not
|
||||
be changed. Only the link will be changed in this case.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -705,7 +705,7 @@
|
||||
{% if line.tax_rate %}
|
||||
<br/>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=line.tax_rate|floatformat:-2 taxname=line.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=line.tax_rate|tax_rate_format taxname=line.tax_rule.name|default:s_taxes %}
|
||||
<strong>plus</strong> {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
@@ -715,7 +715,7 @@
|
||||
{% if line.tax_rate and line.price %}
|
||||
<br/>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=line.tax_rate|floatformat:-2 taxname=line.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=line.tax_rate|tax_rate_format taxname=line.tax_rule.name|default:s_taxes %}
|
||||
incl. {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
@@ -755,7 +755,7 @@
|
||||
{% if fee.tax_rate %}
|
||||
<br/>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=fee.tax_rate|floatformat:-2 taxname=fee.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=fee.tax_rate|tax_rate_format taxname=fee.tax_rule.name|default:s_taxes %}
|
||||
<strong>plus</strong> {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
@@ -765,7 +765,7 @@
|
||||
{% if fee.tax_rate %}
|
||||
<br/>
|
||||
<small>
|
||||
{% blocktrans trimmed with rate=fee.tax_rate|floatformat:-2 taxname=fee.tax_rule.name|default:s_taxes %}
|
||||
{% blocktrans trimmed with rate=fee.tax_rate|tax_rate_format taxname=fee.tax_rule.name|default:s_taxes %}
|
||||
incl. {{ rate }}% {{ taxname }}
|
||||
{% endblocktrans %}
|
||||
</small>
|
||||
@@ -903,7 +903,7 @@
|
||||
<tr>
|
||||
<td colspan="1"></td>
|
||||
<td colspan="5">
|
||||
{{ p.html_info|safe }}
|
||||
{{ p.html_info }}
|
||||
{% if staff_session %}
|
||||
<p>
|
||||
<a href="" class="btn btn-default btn-xs admin-only" data-expandpayment data-id="{{ p.pk }}">
|
||||
@@ -1018,7 +1018,7 @@
|
||||
</dl>
|
||||
{% endif %}
|
||||
{% if r.html_info %}
|
||||
{{ r.html_info|safe }}
|
||||
{{ r.html_info }}
|
||||
{% endif %}
|
||||
{% if staff_session %}
|
||||
<p>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load bootstrap3 %}
|
||||
{% load escapejson %}
|
||||
{% block inner %}
|
||||
<h1>{% trans "Connect to device:" %} {{ device.name }}</h1>
|
||||
|
||||
@@ -18,7 +19,7 @@
|
||||
{% trans "Open the app that you want to connect and optionally reset it to the original state." %}
|
||||
</li>
|
||||
<li>{% trans "Scan the following configuration code:" %}<br><br>
|
||||
<script type="text/json" data-replace-with-qr>{{ qrdata|safe }}</script><br>
|
||||
<script type="application/json" data-replace-with-qr>{{ qrdata|escapejson_dumps }}</script><br>
|
||||
{% trans "If your app/device does not support scanning a QR code, you can also enter the following information:" %}
|
||||
<br>
|
||||
<strong>{% trans "System URL:" %}</strong> <code id="system_url">{{ settings.SITE_URL }}</code>
|
||||
|
||||
@@ -137,6 +137,15 @@
|
||||
</td>
|
||||
<td>
|
||||
{{ d.software_brand|default_if_none:"" }} {{ d.software_version|default_if_none:"" }}
|
||||
{% if staff_session %}
|
||||
<details class="admin-only">
|
||||
<summary>
|
||||
<i class="fa fa-angle-down collapse-indicator"></i>
|
||||
{% trans "Details" %}
|
||||
</summary>
|
||||
<pre class="admin-only">{{ d.info|pprint }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if d.initialized %}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
{% endif %}
|
||||
{% bootstrap_field sform.imprint_url layout="control" %}
|
||||
{% bootstrap_field sform.contact_mail layout="control" %}
|
||||
{% bootstrap_field sform.contact_url layout="control" %}
|
||||
{% bootstrap_field sform.organizer_info_text layout="control" %}
|
||||
{% bootstrap_field sform.event_team_provisioning layout="control" %}
|
||||
{% if sform.allowed_restricted_plugins %}
|
||||
|
||||
@@ -19,9 +19,7 @@
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
<script type="application/json" id="editor-data">
|
||||
{{ layout|safe }}
|
||||
</script>
|
||||
{{ layout|json_script:"editor-data" }}
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
<div class="panel panel-default panel-pdf-editor">
|
||||
@@ -562,11 +560,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/plain" id="schema-url">{% static "schema/pdf-layout.schema.json" %}</script>
|
||||
<script type="text/javascript" src="{% static "pdfjs/pdf.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "ajv/ajv2020.bundle.min.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "fabric/fabric.min.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/editor.js" %}"></script>
|
||||
<script type="text/javascript" src="{% static "schema/pdf-layout.validate.js" %}"></script>
|
||||
<img src="{% static 'pretixpresale/pdf/powered_by_pretix_dark.png' %}" id="poweredby-dark" class="sr-only">
|
||||
<img src="{% static 'pretixpresale/pdf/powered_by_pretix_white.png' %}" id="poweredby-white" class="sr-only">
|
||||
{% for family, styles in fonts.items %}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user