Compare commits

...
Author SHA1 Message Date
Kara Engelhardt 256316e4bc WIP
proper fileupload handling, better store
2026-08-12 16:43:41 +02:00
Kara Engelhardt 4730241d76 WIP
mvp (file)settings
2026-08-12 13:21:48 +02:00
Kara Engelhardt a82d3178e2 WIP
prettify & cleanup preview

support multiline fields in preview

add venue

store active state for predefined fieldgroups
2026-08-11 11:27:17 +02:00
Kara Engelhardt 4e02edc7d6 WIP 2026-08-11 09:54:14 +02:00
Kara Engelhardt d703cfa877 WIP 2026-08-11 09:54:14 +02:00
Kara Engelhardt 7dda90dfaa WIP: predefined preview, google api, custom placeholder class 2026-08-11 09:54:14 +02:00
Kara Engelhardt 16d654ebaf WIP 2026-08-11 09:54:14 +02:00
Kara Engelhardt 291a1dcd53 WIP 2026-08-11 09:54:14 +02:00
Kara Engelhardt bbce710667 WIP 2026-08-11 09:54:14 +02:00
Kara Engelhardt 9ecf6892a7 preview from server, qrcode in apple event pass 2026-08-11 09:54:14 +02:00
Kara Engelhardt 05d5986159 WIP preview 2026-08-11 09:54:14 +02:00
Kara Engelhardt fe5e7c7600 WIP preview 2026-08-11 09:54:14 +02:00
Kara Engelhardt 38afc5319d start preview 2026-08-11 09:54:14 +02:00
Kara Engelhardt 6143a29ce8 wallet wip 2026-08-11 09:54:14 +02:00
Kara Engelhardt b23f65d058 Fix typo 2026-08-11 09:54:14 +02:00
Kara Engelhardt c148d36c34 vite dev server: do not clear screen 2026-08-11 09:54:14 +02:00
Kara Engelhardt ffc2d3ebce WIP 2026-08-11 09:54:14 +02:00
Kara Engelhardt a31359b03c Remove static layout name from editor page 2026-08-11 09:54:14 +02:00
Kara Engelhardt d6e76c250c use placeholder name as label default 2026-08-11 09:54:14 +02:00
Kara Engelhardt 4bb7292245 WIP: first successful pass creation 2026-08-11 09:54:14 +02:00
Kara Engelhardt 49d3eda203 WalletLayout now contain layouts for all platforms 2026-08-11 09:54:14 +02:00
Kara Engelhardt d442c9b282 WIP 2026-08-11 09:54:14 +02:00
Kara Engelhardt 863d147ffa WIP: i18n editor, start apple wallet generation 2026-08-11 09:54:14 +02:00
Kara Engelhardt f6614d49c2 WIP: i18nfields, refactoring, jsonschema-validatoin 2026-08-11 09:54:14 +02:00
Kara Engelhardt 9e7e36055a WIP: use api 2026-08-11 09:54:14 +02:00
Kara Engelhardt 4f1e9f7e2a WIP 2026-08-11 09:54:14 +02:00
Kara Engelhardt 718c69718a WIP 2026-08-11 09:54:14 +02:00
Kara Engelhardt a8a1b9f66f WIP 2026-08-11 09:54:14 +02:00
Kara Engelhardt 777978ad04 WIP 2026-08-11 09:54:14 +02:00
Kara Engelhardt 95c6f14c4d WIP 2026-08-11 09:54:14 +02:00
Kara Engelhardt cf06596597 Add wallet plugins stub 2026-08-11 09:54:14 +02:00
f25c233e91 Fix performance issues in download reminder (#6393)
* Fix performance issues in download reminder

* Update src/pretix/base/services/orders.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Fixes after review

* Fix check in loop

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-08-10 16:13:54 +02:00
robbi5andGitHub 5cf28f1b81 Fix typo in devicesecurity blocking checkinrpc.annul (#6467) 2026-08-10 15:58:15 +02:00
Raphael MichelandGitHub d08216d8c5 API: Allow to simulate check-ins (#6360)
* API: Allow to simulate check-ins

* Add missing file
2026-08-07 18:59:01 +02:00
Lukas BockstallerandGitHub 4a28689690 log paypal payment durations (#6461)
* log payment processing durations

* remove log noise

* fix attribute access

* remove debugging import
2026-08-07 16:34:36 +02:00
luelistaandGitHub 958f75b109 Add tests to prevent reintroducing CSP nonces (Z#23240534) (#6409)
As discussed in PR #6387
2026-08-07 14:36:46 +02:00
Richard SchreiberandGitHub 81f58456e5 Fix API-docs example for addon_to on order-change (#6451)
* Fix API-docs example for addon_to on order-change

* Update orders.rst
2026-08-07 11:03:36 +02:00
4d9dfa88fe Paypal2: handle incomming capture webhooks (Z#23240966) (#6456)
* store the state of the payment regardless of the state

control.html shows the banner that the payment is in review depending on payment.info

* handle capture ressource

* Update src/pretix/plugins/paypal2/views.py

Co-authored-by: Phin Wolkwitz <wolkwitz@pretix.eu>

* add test

* cleanup logic regarding uninteresting resource_type

* store payment.info during _execute_payment asap

---------

Co-authored-by: Phin Wolkwitz <wolkwitz@pretix.eu>
2026-08-06 15:06:20 +02:00
Raphael MichelandGitHub ea792e76b2 Device list: Show online status (#6455)
* Device list: Show online status

* Fixes post-review
2026-08-06 09:07:20 +02:00
Raphael MichelandGitHub 5c448279f0 API: Make all mutating endpoints transactional (#6430)
All of our API endpoints that do something in the system do at least two
SQL queries, one for the actual change and one for the log entry. Often
many more. We want all of this to happen in a transaction so we know an
API call was executed or not at all, not half-way.
2026-08-05 16:52:28 +02:00
dependabot[bot]andGitHub e3fa1aed7f Bump django-localflavor from 5.0 to 5.1 (#6449) 2026-08-05 14:56:53 +02:00
Luca Sorace \"StranckandRaphael Michel b164cf44c0 Translations: Update Italian
Currently translated at 39.8% (2544 of 6387 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/it/

powered by weblate
2026-08-05 14:56:14 +02:00
Luca Sorace \"StranckandRaphael Michel 7bc79337c3 Translations: Update Italian
Currently translated at 99.6% (259 of 260 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/it/

powered by weblate
2026-08-05 14:56:14 +02:00
Luca Sorace \"StranckandRaphael Michel 5d8ba189fa Translations: Update Italian
Currently translated at 39.6% (2530 of 6387 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/it/

powered by weblate
2026-08-05 14:56:14 +02:00
szurofkamarciidfbe08444ef04788andRaphael Michel 1ea1c1789c Translations: Update Hungarian
Currently translated at 99.6% (6362 of 6387 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/hu/

powered by weblate
2026-08-05 14:56:14 +02:00
NikolaiandRaphael Michel 1a6edae769 Translations: Update Danish
Currently translated at 58.0% (151 of 260 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/da/

powered by weblate
2026-08-05 14:56:14 +02:00
NikolaiandRaphael Michel c4950e714d Translations: Update Danish
Currently translated at 62.7% (4011 of 6387 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/da/

powered by weblate
2026-08-05 14:56:14 +02:00
Raphael MichelandGitHub 407728cc55 Bulk refund: Fix only first refund being made (Z#23239965) (#6376) 2026-08-05 11:46:52 +02:00
Raphael MichelandGitHub 9fe25544c1 Email settings: Fix DMARC warning (#6452) 2026-08-05 11:04:56 +02:00
Raphael MichelandGitHub 8133061fe1 Devices: Store timestamp of last contact (#6453) 2026-08-05 10:57:13 +02:00
Raphael Michel 288ac50600 Merge branch 'html-injection-placeholder' into 'master'
Prevent HTML injection in email preview (Z#23241741)

See merge request pretix/pretix!47
2026-08-05 10:56:46 +02:00
Raphael Michel e7657a3dd3 Prevent HTML injection in email preview (Z#23241741) 2026-08-05 10:56:46 +02:00
Martin Gross b659534772 isort 2026-08-04 23:01:29 +02:00
Martin Gross c11ebb1391 Skip over Exporters with ExportError-Exceptions (such as ORMQL-Exporter) (Fixes PRETIXEU-F3X) 2026-08-04 22:59:19 +02:00
pajowuandGitHub b20557a996 Show correct number of currency placed in widget (Z#23241522) (#6425) 2026-08-04 11:19:43 +02:00
Martin Gross e4a0bba3bc Fix font enumeration 2026-08-04 10:22:09 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c369ba5f60 Update cryptography requirement from >=49.0.0 to >=50.0.0 (#6443)
Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/49.0.0...50.0.0)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 50.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 08:47:14 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2c876057bf Bump markdown from 3.10.2 to 3.10.3 (#6442)
Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.10.2 to 3.10.3.
- [Release notes](https://github.com/Python-Markdown/markdown/releases)
- [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md)
- [Commits](https://github.com/Python-Markdown/markdown/compare/3.10.2...3.10.3)

---
updated-dependencies:
- dependency-name: markdown
  dependency-version: 3.10.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 08:46:52 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b70c1b02c2 Bump django-bootstrap3 from 26.1 to 26.2 (#6445)
Bumps [django-bootstrap3](https://github.com/zostera/django-bootstrap3) from 26.1 to 26.2.
- [Changelog](https://github.com/zostera/django-bootstrap3/blob/main/CHANGELOG.md)
- [Commits](https://github.com/zostera/django-bootstrap3/compare/v26.1...v26.2)

---
updated-dependencies:
- dependency-name: django-bootstrap3
  dependency-version: '26.2'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 08:46:25 +02:00
pajowuandGitHub 01d736361d Cart Manager: Round custom price to currency when adding (#6426) 2026-08-03 14:26:57 +02:00
112 changed files with 5866 additions and 615 deletions
+2
View File
@@ -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'
+2
View File
@@ -71,6 +71,8 @@ Checking a ticket in
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
:>json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``.
:>json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``.
:>json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response
object will not reflect the simulated changes.
**Example request**:
+2 -2
View File
@@ -2038,7 +2038,7 @@ Manipulating individual positions
* ``order`` (mandatory, specified as a string mapping to a ``code``)
* ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` of the parent position)
* ``addon_to`` (optional, specified as an integer mapping to ``positionid`` - the number of the position within the order, see :ref:`_order-position-resource` - of the parent position)
* ``item`` (mandatory)
@@ -2348,7 +2348,7 @@ otherwise, such as splitting an order or changing fees.
"subevent": 562,
"seat": "seat-guid-2",
"price": "99.99",
"addon_to": 12374,
"addon_to": 1,
"attendee_name": "Peter",
}
],
+2 -2
View File
@@ -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": {
+4 -4
View File
@@ -33,12 +33,12 @@ dependencies = [
"bleach==6.4.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=49.0.0",
"cryptography>=50.0.0",
"css-inline==0.21.*",
"defusedcsv>=3.0.0",
"dnspython==2.*",
"Django[argon2]==5.2.*",
"django-bootstrap3==26.1",
"django-bootstrap3==26.2",
"django-compressor==4.6.0",
"django-countries==9.0.*",
"django-filter==26.1",
@@ -48,7 +48,7 @@ dependencies = [
"django-hijack==3.7.*",
"django-i18nfield==1.11.*",
"django-libsass==0.9",
"django-localflavor==5.0",
"django-localflavor==5.1",
"django-markup",
"django-oauth-toolkit==2.3.*",
"django-otp==1.7.*",
@@ -67,7 +67,7 @@ dependencies = [
"kombu==5.6.*",
"libsass==0.23.*",
"lxml",
"markdown==3.10.2", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
"markdown==3.10.3", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*",
"oauthlib==3.3.*",
+1
View File
@@ -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
+1
View File
@@ -66,6 +66,7 @@ INSTALLED_APPS = [
'pretix.plugins.returnurl',
'pretix.plugins.autocheckin',
'pretix.plugins.webcheckin',
'pretix.plugins.wallet',
'django_countries',
'oauth2_provider',
'phonenumber_field',
+25 -1
View File
@@ -20,8 +20,11 @@
# <https://www.gnu.org/licenses/>.
#
import logging
from datetime import timedelta
from django.contrib.auth.models import AnonymousUser
from django.db import DatabaseError
from django.utils.timezone import now
from django_scopes import scopes_disabled
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
@@ -30,6 +33,7 @@ from pretix.api.auth.devicesecurity import (
FullAccessSecurityProfile, get_all_security_profiles,
)
from pretix.base.models import Device
from pretix.base.models.devices import DeviceLastSeen
logger = logging.getLogger(__name__)
@@ -42,7 +46,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
model = self.get_model()
try:
with scopes_disabled():
device = model.objects.select_related('organizer').get(api_token=key)
device = model.objects.select_related('organizer', 'last_seen').get(api_token=key)
except model.DoesNotExist:
raise exceptions.AuthenticationFailed('Invalid token.')
@@ -53,6 +57,7 @@ class DeviceTokenAuthentication(TokenAuthentication):
logging.warning(f'Connection attempt of revoked device {device.pk}.')
raise exceptions.AuthenticationFailed('Device access has been revoked.')
self._update_last_seen(device)
return AnonymousUser(), device
def authenticate(self, request):
@@ -63,3 +68,22 @@ class DeviceTokenAuthentication(TokenAuthentication):
if not profile.is_allowed(request):
raise exceptions.PermissionDenied('Request denied by device security profile.')
return r
def _update_last_seen(self, device: Device):
try:
try:
last_seen_obj = device.last_seen
except DeviceLastSeen.DoesNotExist:
# First request from device, create model, ignore result. Use get_or_create to be safe
# against concurrent create requests
DeviceLastSeen.objects.get_or_create(device=device, last_seen=now())
else:
if now() - last_seen_obj.last_seen < timedelta(seconds=10):
# We don't need to know the last seen info of a device to more precision than this,
# so we can avoid some database writes if the device is bursting a lot of requests.
return
last_seen_obj.last_seen = now()
last_seen_obj.save(update_fields=["last_seen"])
except DatabaseError:
# Do not stop the request from happening
logger.exception("Database error while updating last_seen")
+3 -3
View File
@@ -115,7 +115,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('POST', 'api-v1:checkinrpc.annul'),
('GET', 'api-v1:checkinrpc.search'),
('GET', 'api-v1:reusablemedium-list'),
('POST', 'api-v1:reusablemedium-lookup'),
@@ -154,7 +154,7 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('POST', 'api-v1:checkinrpc.annul'),
('GET', 'api-v1:checkinrpc.search'),
)
@@ -191,7 +191,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('POST', 'api-v1:checkinrpc.annul'),
('GET', 'api-v1:checkinrpc.search'),
)
+25
View File
@@ -0,0 +1,25 @@
from pretix.base.models import (
CachedFile
)
from rest_framework.exceptions import ValidationError
from django.core.exceptions import ValidationError as DjangoValidationError
from django.conf import settings
def handle_file_upload(data, user, auth, allowed_types):
try:
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(user or auth))}-{(user or auth).pk}',
file__isnull=False,
pk=data[len("file:"):],
)
except (ValidationError, DjangoValidationError, IndexError): # invalid uuid
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except CachedFile.DoesNotExist:
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
if cf.type not in allowed_types:
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
return cf.file
+1
View File
@@ -90,6 +90,7 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
answers = serializers.JSONField(required=False, allow_null=True)
exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES)
exchange_medium_identifier = serializers.CharField(required=False)
simulate = serializers.BooleanField(default=False, required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+16 -23
View File
@@ -63,7 +63,7 @@ from pretix.api.views import RichOrderingFilter
from pretix.api.views.order import OrderPositionFilter
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
Checkin, CheckinList, Device, Event, Order, OrderPosition,
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
)
from pretix.base.models.orders import PrintLog
@@ -75,6 +75,7 @@ from pretix.base.services.checkin import (
from pretix.base.services.media import perform_media_exchange
from pretix.base.signals import checkin_annulled
from pretix.helpers import OF_SELF
from pretix.api.helpers import handle_file_upload
with scopes_disabled():
class CheckinListFilter(FilterSet):
@@ -139,6 +140,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
)
return qs
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -153,6 +155,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event
return ctx
@transaction.atomic()
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -326,27 +329,6 @@ with scopes_disabled():
)
def _handle_file_upload(data, user, auth):
try:
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(user or auth))}-{(user or auth).pk}',
file__isnull=False,
pk=data[len("file:"):],
)
except (ValidationError, BaseValidationError, IndexError): # invalid uuid
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except CachedFile.DoesNotExist:
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
allowed_types = (
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
)
if cf.type not in allowed_types:
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
return cf.file
def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_products=False, pdf_data=False, expand=None):
@@ -793,7 +775,12 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
try:
if q.type == Question.TYPE_FILE:
if answers_data[str(q.pk)]:
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
given_answers[q] = handle_file_upload(
answers_data[str(q.pk)],
user,
auth,
allowed_types=('image/png', 'image/jpeg', 'image/gif', 'application/pdf')
)
else:
given_answers[q] = None
else:
@@ -837,6 +824,11 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
)
if exchange_medium_identifier: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate
if simulate:
raise CheckInError(
gettext('You cannot simulate a medium exchange.'),
'error'
)
with transaction.atomic():
# Do exchange and check-in atomically, i.e. both succeed or both fail
medium = perform_media_exchange(
@@ -1064,6 +1056,7 @@ class CheckinRPCRedeemView(views.APIView):
legacy_url_support=False,
exchange_medium_type=s.validated_data.get('exchange_medium_type'),
exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'),
simulate=s.validated_data.get('simulate'),
)
+4
View File
@@ -32,6 +32,7 @@
# 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.
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
@@ -64,6 +65,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
'limit_sales_channels',
)
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -78,6 +80,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['event'] = self.request.event
return ctx
@transaction.atomic()
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -87,6 +90,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied('You cannot delete this discount because it already has '
+11
View File
@@ -257,6 +257,7 @@ class EventViewSet(viewsets.ModelViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_create(self, serializer):
copy_from = None
if 'clone_from' in self.request.GET:
@@ -320,6 +321,7 @@ class EventViewSet(viewsets.ModelViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied('The event can not be deleted as it already contains orders. Please set \'live\''
@@ -355,6 +357,7 @@ class CloneEventViewSet(viewsets.ModelViewSet):
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_create(self, serializer):
# Weird edge case: Requires settings permission on the event (to read) but also on the organizer (two write)
perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken))
@@ -513,6 +516,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
resp['X-Page-Generated'] = date
return resp
@transaction.atomic()
def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
super().perform_update(serializer)
@@ -529,6 +533,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -538,6 +543,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied('The sub-event can not be deleted as it has already been used in orders. Please set'
@@ -566,6 +572,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self):
return self.request.event.tax_rules.all()
@transaction.atomic()
def perform_update(self, serializer):
super().perform_update(serializer)
serializer.instance.log_action(
@@ -575,6 +582,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -584,6 +592,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied('This tax rule can not be deleted as it is currently in use.')
@@ -757,6 +766,7 @@ class SeatViewSet(ConditionalListView, UpdateModelMixin, viewsets.ReadOnlyModelV
}
return ctx
@transaction.atomic()
def perform_update(self, serializer):
super().perform_update(serializer)
serializer.instance.event.log_action(
@@ -766,6 +776,7 @@ class SeatViewSet(ConditionalListView, UpdateModelMixin, viewsets.ReadOnlyModelV
data={"seats": [serializer.instance.pk]},
)
@transaction.atomic()
def bulk_change_blocked(self, blocked):
s = SeatBulkBlockInputSerializer(
data=self.request.data,
+19 -5
View File
@@ -23,6 +23,7 @@ from datetime import timedelta
from celery.result import AsyncResult
from django.conf import settings
from django.db import transaction
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
@@ -45,7 +46,8 @@ from pretix.base.models import (
)
from pretix.base.models.organizer import TeamQuerySet
from pretix.base.services.export import (
export, init_event_exporters, init_organizer_exporters, multiexport,
ExportError, export, init_event_exporters, init_organizer_exporters,
multiexport,
)
from pretix.helpers.http import ChunkBasedFileResponse
@@ -149,8 +151,11 @@ class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
))
exporters = []
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex)
try:
ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex)
except ExportError:
pass
return exporters
def do_export(self, cf, instance, data):
@@ -180,8 +185,11 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
))
exporters = []
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex)
try:
ex._serializer = JobRunSerializer(exporter=ex)
exporters.append(ex)
except ExportError:
pass
return exporters
def do_export(self, cf, instance, data):
@@ -220,6 +228,7 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
qs = self.request.event.scheduled_exports
return qs.select_related("owner")
@transaction.atomic()
def perform_create(self, serializer):
if not self.request.user.is_authenticated:
raise PermissionDenied('Creation of exports requires user-specific API access.')
@@ -250,6 +259,7 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
))
return {e.identifier: e for e in exporters}
@transaction.atomic()
def perform_update(self, serializer):
if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner:
# This is to prevent a possible privilege escalation where user A creates a scheduled export and
@@ -275,6 +285,7 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
self.request.event.log_action(
'pretix.event.export.schedule.deleted',
@@ -302,6 +313,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
qs = self.request.organizer.scheduled_exports
return qs.select_related("owner")
@transaction.atomic()
def perform_create(self, serializer):
if not self.request.user.is_authenticated:
raise PermissionDenied('Creation of exports requires user-specific API access.')
@@ -332,6 +344,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
))
return {e.identifier: e for e in exporters}
@transaction.atomic()
def perform_update(self, serializer):
if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner:
# This is to prevent a possible privilege escalation where user A creates a scheduled export and
@@ -382,6 +395,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
self.request.organizer.log_action(
'pretix.organizer.export.schedule.deleted',
+28
View File
@@ -33,6 +33,7 @@
# License for the specific language governing permissions and limitations under the License.
import django_filters
from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
@@ -109,6 +110,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times'
).all()
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -123,6 +125,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['event'] = self.request.event
return ctx
@transaction.atomic()
def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
@@ -139,6 +142,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied('This item cannot be deleted because it has already been ordered '
@@ -183,6 +187,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event
return ctx
@transaction.atomic()
def perform_create(self, serializer):
item = self.item
if not item.has_variations:
@@ -197,6 +202,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
{'value': serializer.instance.value})
)
@transaction.atomic()
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.item.log_action(
@@ -207,6 +213,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
{'value': serializer.instance.value})
)
@transaction.atomic()
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied('This variation cannot be deleted because it has already been ordered '
@@ -249,6 +256,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
ctx['item'] = self.item
return ctx
@transaction.atomic()
def perform_create(self, serializer):
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
serializer.save(base_item=item)
@@ -259,6 +267,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
@transaction.atomic()
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.base_item.log_action(
@@ -268,6 +277,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
@transaction.atomic()
def perform_destroy(self, instance):
super().perform_destroy(instance)
instance.base_item.log_action(
@@ -303,6 +313,7 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
ctx['item'] = self.item
return ctx
@transaction.atomic()
def perform_create(self, serializer):
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
serializer.save(item=item)
@@ -313,6 +324,7 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
@transaction.atomic()
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.item.log_action(
@@ -322,6 +334,7 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
@transaction.atomic()
def perform_destroy(self, instance):
super().perform_destroy(instance)
instance.item.log_action(
@@ -354,6 +367,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
ctx['item'] = self.item
return ctx
@transaction.atomic()
def perform_create(self, serializer):
item = self.item
category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category'])
@@ -365,6 +379,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
)
@transaction.atomic()
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.base_item.log_action(
@@ -374,6 +389,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
)
@transaction.atomic()
def perform_destroy(self, instance):
super().perform_destroy(instance)
instance.base_item.log_action(
@@ -403,6 +419,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self):
return self.request.event.categories.all()
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -417,6 +434,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['event'] = self.request.event
return ctx
@transaction.atomic()
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -426,6 +444,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
for item in instance.items.all():
item.category = None
@@ -458,6 +477,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self):
return self.request.event.questions.prefetch_related('options').all()
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -472,6 +492,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['event'] = self.request.event
return ctx
@transaction.atomic()
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -481,6 +502,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action(
'pretix.event.question.deleted',
@@ -509,6 +531,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
ctx['question'] = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
return ctx
@transaction.atomic()
def perform_create(self, serializer):
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
serializer.save(question=q)
@@ -519,6 +542,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
)
@transaction.atomic()
def perform_update(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.question.log_action(
@@ -528,6 +552,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk})
)
@transaction.atomic()
def perform_destroy(self, instance):
instance.question.log_action(
'pretix.event.question.option.deleted',
@@ -586,6 +611,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -608,6 +634,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
ctx['request'] = self.request
return ctx
@transaction.atomic()
def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
@@ -663,6 +690,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
)
serializer.instance.rebuild_cache()
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action(
'pretix.event.quota.deleted',
+3
View File
@@ -394,6 +394,7 @@ class TeamViewSet(viewsets.ModelViewSet):
)
return inst
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action('pretix.team.deleted', user=self.request.user, auth=self.request.auth)
instance.delete()
@@ -693,6 +694,7 @@ class MembershipTypeViewSet(viewsets.ModelViewSet):
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied("Can only be deleted if unused.")
@@ -833,6 +835,7 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
)
return inst
@transaction.atomic()
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied("Can only be deleted if unused.")
+4
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import django_filters
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
@@ -62,6 +63,7 @@ class WaitingListViewSet(viewsets.ModelViewSet):
ctx['event'] = self.request.event
return ctx
@transaction.atomic()
def perform_create(self, serializer):
serializer.save(event=self.request.event)
serializer.instance.log_action(
@@ -70,6 +72,7 @@ class WaitingListViewSet(viewsets.ModelViewSet):
auth=self.request.auth,
)
@transaction.atomic()
def perform_update(self, serializer):
if serializer.instance.voucher:
raise PermissionDenied('This entry can not be changed as it has already been assigned a voucher.')
@@ -80,6 +83,7 @@ class WaitingListViewSet(viewsets.ModelViewSet):
auth=self.request.auth,
)
@transaction.atomic()
def perform_destroy(self, instance):
if instance.voucher:
raise PermissionDenied('This entry can not be deleted as it has already been assigned a voucher.')
+4
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
import django_filters
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import viewsets
@@ -48,6 +49,7 @@ class WebHookViewSet(viewsets.ModelViewSet):
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
@@ -57,6 +59,7 @@ class WebHookViewSet(viewsets.ModelViewSet):
data=merge_dicts(self.request.data, {'id': inst.pk})
)
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
@@ -67,6 +70,7 @@ class WebHookViewSet(viewsets.ModelViewSet):
)
return inst
@transaction.atomic()
def perform_destroy(self, instance):
self.request.organizer.log_action(
'pretix.webhook.changed',
+1 -1
View File
@@ -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)
@@ -0,0 +1,43 @@
# Generated by Django 5.2.16 on 2026-08-05 08:00
import django.db.models.deletion
from django.db import migrations, models
import pretix.helpers.database
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0306_alter_eventmetaproperty_unique_together"),
]
operations = [
migrations.CreateModel(
name="DeviceLastSeen",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("last_seen", models.DateTimeField(auto_now=True)),
(
"device",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.device",
),
),
],
),
migrations.AddIndex(
model_name="devicelastseen",
index=pretix.helpers.database.BrinIndexIgnoredOnSQLite(
models.F("last_seen"),
autosummarize=True,
name="pretixbase_device_last_seen",
),
),
]
+27
View File
@@ -20,11 +20,13 @@
# <https://www.gnu.org/licenses/>.
#
import string
from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Max
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
@@ -32,6 +34,7 @@ from pretix.base.models import LoggedModel
from pretix.base.permissions import (
AnyPermissionOf, assert_valid_event_permission,
)
from pretix.helpers import BrinIndexIgnoredOnSQLite
@scopes_disabled()
@@ -287,3 +290,27 @@ class Device(LoggedModel):
return self.get_events_with_any_permission()
else:
return self.organizer.events.none()
class DeviceLastSeen(models.Model):
# This is a separate model since we expect it to get A LOT of writes and PostgreSQL always
# writes full rows and then needs to update all indexes on the row, so this is going to save a
# lot of write traffic on the databse
device = models.OneToOneField("Device", on_delete=models.CASCADE, related_name="last_seen")
last_seen = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
BrinIndexIgnoredOnSQLite(
# BRIN indexes are highly efficient on lots of updates, especially of chronological data
# and especially if we later want to query them by range, as we likely want to.
"last_seen",
name="pretixbase_device_last_seen",
autosummarize=True
)
]
@property
def is_recent(self):
# pretixSCAN/pretixPOS sync every 5 minutes, so 7 minutes can be considered "offline"
return now() - self.last_seen < timedelta(minutes=7)
+5
View File
@@ -53,6 +53,7 @@ from django.utils.translation import (
)
from django_scopes import scopes_disabled
from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
@@ -916,6 +917,8 @@ class CartManager:
if custom_price > 99_999_999_999:
raise CartError(error_messages['price_too_high'])
custom_price = round_decimal(custom_price, currency=self.event.currency)
op = self.AddOperation(
count=i['count'],
item=item,
@@ -1038,6 +1041,8 @@ class CartManager:
if custom_price > 99_999_999_999:
raise CartError(error_messages['price_too_high'])
custom_price = round_decimal(custom_price, currency=self.event.currency)
# Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky)
for ca in current_addons[cp][a['item'], a['variation']]:
if ca.listed_price != listed_price:
+4 -3
View File
@@ -40,7 +40,7 @@ import dateutil
import dateutil.parser
from dateutil.tz import datetime_exists
from django.core.files import File
from django.db import IntegrityError, transaction
from django.db import IntegrityError
from django.db.models import (
BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min,
OuterRef, Q, Subquery, TextField, Value, When,
@@ -59,6 +59,7 @@ from pretix.base.models import (
)
from pretix.base.signals import checkin_created, periodic_task
from pretix.helpers import OF_SELF
from pretix.helpers.database import conditional_atomic
from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
from pretix.helpers.jsonlogic_query import (
@@ -1043,10 +1044,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
if not simulate:
_save_answers(op, answers, given_answers)
with transaction.atomic():
with conditional_atomic(not simulate):
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
opqs = OrderPosition.all.select_related("order", "item")
if type != Checkin.TYPE_EXIT:
if type != Checkin.TYPE_EXIT and not simulate:
opqs = opqs.select_for_update(of=OF_SELF)
op = opqs.get(pk=op.pk)
+92 -71
View File
@@ -48,12 +48,12 @@ from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db import models, transaction
from django.db.models import (
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
Value,
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Subquery,
Sum, Value,
)
from django.db.models.functions import Coalesce, Greatest
from django.db.models.functions import Cast, Greatest
from django.db.transaction import get_connection
from django.dispatch import receiver
from django.utils.functional import cached_property
@@ -71,7 +71,7 @@ from pretix.base.models import (
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
SeatCategoryMapping, User, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.event import Event_SettingsStore, SubEvent
from pretix.base.models.orders import (
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
generate_secret,
@@ -1494,83 +1494,104 @@ def send_expiry_warnings(sender, **kwargs):
@scopes_disabled()
def send_download_reminders(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0, microsecond=0)
qs = Order.objects.annotate(
first_date=Coalesce(
Min('all_positions__subevent__date_from'),
F('event__date_from')
events = Event.objects.filter(
Q(has_subevents=False, date_from__gte=now()) |
(Q(has_subevents=True) & Q(Exists(
SubEvent.objects.filter(event_id=OuterRef('id'), date_from__gte=now())
)))
).annotate(
reminder_days=Subquery(
Event_SettingsStore.objects.filter(
object=OuterRef('id'),
key='mail_days_download_reminder'
).exclude(
value="None"
).annotate(
val=Cast(F("value"), output_field=models.IntegerField()),
).values("val")
)
).filter(
download_reminder_sent=False,
datetime__lte=now() - timedelta(hours=2),
first_date__gte=today,
).only(
'pk', 'event_id', 'sales_channel', 'datetime',
).order_by('event_id')
event_id = None
days = None
event = None
reminder_days__isnull=False,
).order_by()
for o in qs:
if o.event_id != event_id:
days = o.event.settings.get('mail_days_download_reminder', as_type=int)
event = o.event
event_id = o.event_id
for event in events.iterator(chunk_size=10_000):
qs = event.orders.filter(
download_reminder_sent=False,
datetime__lte=now() - timedelta(hours=2),
)
if days is None:
continue
if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder:
continue
reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
if now() < reminder_date or o.datetime > reminder_date:
continue
with transaction.atomic():
o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk)
if o.download_reminder_sent:
# Race condition
continue
positions = list(o.positions_with_tickets)
if not positions:
if event.has_subevents:
qs = qs.annotate(
first_date=Min('all_positions__subevent__date_from')
).filter(
Q(first_date__gte=today)
)
else:
event_reminder_date = (event.date_from - timedelta(days=event.reminder_days)).replace(hour=0, minute=0, second=0, microsecond=0)
if now() < event_reminder_date:
continue
if not o.ticket_download_available:
qs = qs.only(
'pk', 'event_id', 'sales_channel', 'datetime',
).order_by()
for o in qs:
if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder:
continue
if o.status != Order.STATUS_PAID:
if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending):
if event.has_subevents:
reminder_date = ((o.first_date or event.date_from) - timedelta(days=event.reminder_days)).replace(hour=0, minute=0, second=0, microsecond=0)
else:
reminder_date = event_reminder_date
if now() < reminder_date or o.datetime > reminder_date:
continue
with transaction.atomic():
o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk)
if o.download_reminder_sent:
# Race condition
continue
positions = list(o.positions_with_tickets)
if not positions:
continue
with language(o.locale, o.event.settings.region):
o.download_reminder_sent = True
o.save(update_fields=['download_reminder_sent'])
email_template = event.settings.mail_text_download_reminder
email_subject = event.settings.mail_subject_download_reminder
email_context = get_email_context(event=event, order=o)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
if not o.ticket_download_available:
continue
if event.settings.mail_send_download_reminder_attendee:
for p in positions:
if p.subevent_id:
reminder_date = (p.subevent.date_from - timedelta(days=days)).replace(
hour=0, minute=0, second=0, microsecond=0
)
if now() < reminder_date:
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_template = event.settings.mail_text_download_reminder_attendee
email_subject = event.settings.mail_subject_download_reminder_attendee
email_context = get_email_context(event=event, order=o, position=p)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
if o.status != Order.STATUS_PAID:
if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending):
continue
with language(o.locale, o.event.settings.region):
o.download_reminder_sent = True
o.save(update_fields=['download_reminder_sent'])
email_template = event.settings.mail_text_download_reminder
email_subject = event.settings.mail_subject_download_reminder
email_context = get_email_context(event=event, order=o)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
if event.settings.mail_send_download_reminder_attendee:
for p in positions:
if p.subevent_id:
reminder_date = (p.subevent.date_from - timedelta(days=event.reminder_days)).replace(
hour=0, minute=0, second=0, microsecond=0
)
if now() < reminder_date:
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_template = event.settings.mail_text_download_reminder_attendee
email_subject = event.settings.mail_subject_download_reminder_attendee
email_context = get_email_context(event=event, order=o, position=p)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
+11 -3
View File
@@ -801,11 +801,10 @@ def get_available_placeholders(event, base_parameters, rich=False):
return params
def get_sample_context(event, context_parameters, rich=True):
def prepare_sample_context_for_preview(placeholder_to_sample):
context_dict = {}
lbl = _('This value will be replaced based on dynamic parameters.')
for k, v in get_available_placeholders(event, context_parameters, rich=rich).items():
sample = v.render_sample(event)
for k, sample in placeholder_to_sample.items():
if isinstance(sample, PlainHtmlAlternativeString):
context_dict[k] = PlainHtmlAlternativeString(
'<{el} class="placeholder" title="{title}">{plain}</{el}>'.format(
@@ -830,3 +829,12 @@ def get_sample_context(event, context_parameters, rich=True):
escape(sample)
))
return context_dict
def get_sample_context(event, context_parameters, rich=True):
return prepare_sample_context_for_preview(
{
k: v.render_sample(event)
for k, v in get_available_placeholders(event, context_parameters, rich=rich).items()
}
)
+29 -21
View File
@@ -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):
+1 -1
View File
@@ -2930,7 +2930,7 @@ Your {event} team""")) # noqa: W291
},
'mail_days_download_reminder': {
'type': int,
'default': None
'default': None # when this default is changed, send_download_reminders needs to change
},
'mail_send_download_reminder_attendee': {
'type': bool,
+12 -1
View File
@@ -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:
"""
+1 -1
View File
@@ -951,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):
@@ -118,6 +118,10 @@
</td>
{% endif %}
<td>
<span class="fa fa-fw {% if d.last_seen and d.last_seen.is_recent %}fa-check-circle text-success{% else %}fa-circle text-danger{% endif %}"
data-toggle="tooltip"
title="{% if d.last_seen %}{% blocktrans with time=d.last_seen.last_seen|date:"SHORT_DATETIME_FORMAT" %}Last seen: {{ time }}{% endblocktrans %}{% else %}{% trans "No recent contact" %}{% endif %}"
></span>
{{ d.device_id }}
</td>
<td>
@@ -125,6 +129,7 @@
<del>{% endif %}
{{ d.name }}
{% if d.revoked %}</del>{% endif %}
{% if d.gate %}
<br>
<small class="text-muted">{{ d.gate.name }}</small>
+9 -4
View File
@@ -982,7 +982,7 @@ class TicketSettingsPreview(EventPermissionRequiredMixin, View):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
if provider.identifier == self.kwargs.get('output'):
if provider.identifier == self.kwargs.get('output') and not provider.is_meta:
return provider
def get(self, request, *args, **kwargs):
@@ -1085,6 +1085,11 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
provider_settings_fields = provider.settings_form_fields
provider_settings_content = provider.settings_content_render(self.request)
if not provider_settings_fields and not provider_settings_content:
continue
provider.form = ProviderForm(
obj=self.request.event,
settingspref='ticketoutput_%s_' % provider.identifier,
@@ -1094,17 +1099,17 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
provider.form.fields = OrderedDict(
[
('ticketoutput_%s_%s' % (provider.identifier, k), v)
for k, v in provider.settings_form_fields.items()
for k, v in provider_settings_fields.items()
]
)
provider.settings_content = provider.settings_content_render(self.request)
provider.settings_content = provider_settings_content
provider.form.prepare_fields()
provider.evaluated_preview_allowed = True
if not provider.preview_allowed:
provider.evaluated_preview_allowed = False
else:
for k, v in provider.settings_form_fields.items():
for k, v in provider_settings_fields.items():
if v.required and not self.request.event.settings.get('ticketoutput_%s_%s' % (provider.identifier, k)):
provider.evaluated_preview_allowed = False
break
+2 -2
View File
@@ -269,8 +269,8 @@ class MailSettingsSetupView(TemplateView):
if settings.MAIL_CUSTOM_SENDER_DMARC_REQUIRED:
dmarc_record = get_dmarc_record(hostname)
if not dmarc_record:
spf_warning = _(
'We did not find DMARC record for your domain. This means that there is a very high chance '
dmarc_warning = _(
'We did not find a DMARC record for your domain. This means that there is a very high chance '
'most of the emails will be rejected or marked as spam. You should update the DNS settings '
'of your domain.'
)
+3 -1
View File
@@ -382,7 +382,7 @@ class OrderOverpaidRefundBulkActionView(BaseOrderBulkActionView):
'provider': refund.provider,
}, user=self.request.user)
payment.payment_provider.execute_refund(refund)
return True
return bool(proposals)
except (ValueError, PaymentException):
return False
@@ -582,6 +582,8 @@ class OrderDetail(OrderView):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
if provider.is_meta:
continue
buttons.append({
'text': provider.download_button_text or 'Ticket',
'icon': provider.download_button_icon or 'fa-download',
+9 -11
View File
@@ -108,6 +108,9 @@ from pretix.base.services.export import (
init_organizer_exporters, multiexport, scheduled_organizer_export,
)
from pretix.base.services.mail import mail, prefix_subject
from pretix.base.services.placeholders import (
prepare_sample_context_for_preview,
)
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.tasks import AsyncAction
from pretix.control.forms.exports import ScheduledOrganizerExportForm
@@ -345,16 +348,11 @@ class MailSettingsPreview(OrganizerPermissionRequiredMixin, View):
# get all supported placeholders with dummy values
def placeholders(self, item):
ctx = {}
for p, s in MailSettingsForm(obj=self.request.organizer)._get_sample_context(
MailSettingsForm.base_context[item]).items():
if s.strip().startswith('*'):
ctx[p] = s
else:
ctx[p] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
s
)
ctx = prepare_sample_context_for_preview(
MailSettingsForm(obj=self.request.organizer)._get_sample_context(
MailSettingsForm.base_context[item]
)
)
return self.SafeDict(ctx)
def post(self, request, *args, **kwargs):
@@ -1210,7 +1208,7 @@ class DeviceQueryMixin:
def get_queryset(self):
qs = self.request.organizer.devices.prefetch_related(
'limit_events', 'gate',
).order_by('revoked', '-device_id')
).select_related('last_seen').order_by('revoked', '-device_id')
if 'device' in self.request_data and '__ALL' not in self.request_data:
qs = qs.filter(
+28
View File
@@ -22,6 +22,7 @@
import contextlib
from django.conf import settings
from django.contrib.postgres.indexes import BrinIndex
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.db import connection, transaction
from django.db.models import (
@@ -285,3 +286,30 @@ def get_deterministic_ordering(model, ordering):
# on the primary key to provide total ordering.
ordering.append("-pk")
return ordering
@contextlib.contextmanager
def conditional_atomic(do_atomic, **kwargs):
if do_atomic:
with transaction.atomic(**kwargs):
yield
else:
yield
class IgnoreOnSQLiteMixin:
# Mixin to allow defining PostgreSQL-specific indexes that will just not be created
# on SQLite. SQLite is supported for testing only anyways!
def create_sql(self, model, schema_editor, *args, **kwargs):
if "sqlite" in settings.DATABASES["default"]["ENGINE"]:
return ""
return super().create_sql(model, schema_editor, *args, **kwargs)
def remove_sql(self, model, schema_editor, **kwargs):
if "sqlite" in settings.DATABASES["default"]["ENGINE"]:
return ""
return super().remove_sql(model, schema_editor, **kwargs)
class BrinIndexIgnoredOnSQLite(IgnoreOnSQLiteMixin, BrinIndex):
pass
+9
View File
@@ -21,11 +21,13 @@
#
import copy
from decimal import Decimal
import typing
from django.core.files import File
from django.db import models
from django.db.models.fields import DecimalField
T = typing.TypeVar('T', bound=models.Model)
class Thumbnail(models.Model):
source = models.CharField(max_length=255)
@@ -47,6 +49,13 @@ def modelcopy(obj: models.Model, **kwargs):
setattr(n, f.name, copy.deepcopy(val))
return n
def modelclone(obj: T, **kwargs) -> T:
new = copy.copy(obj)
new.pk = None
new._state.adding = True
for k,v in kwargs.items():
setattr(new, k, v)
return new
# django 5 contains this in django.utils.choices.flatten_choices
def flatten_choices(choices):
+133 -211
View File
@@ -4,10 +4,10 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-30 13:52+0000\n"
"PO-Revision-Date: 2026-07-27 17:00+0000\n"
"PO-Revision-Date: 2026-07-31 18:00+0000\n"
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n"
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix/da/"
">\n"
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix/"
"da/>\n"
"Language: da\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -1381,10 +1381,8 @@ msgid "Membership type"
msgstr "Medlemskabstype"
#: pretix/base/exporters/customers.py
#, fuzzy
#| msgid "Purchase time"
msgid "Purchase ticket"
msgstr "Købsdato"
msgstr "Køb billet"
#: pretix/base/exporters/customers.py pretix/base/exporters/orderlist.py
#: pretix/base/exporters/waitinglist.py pretix/base/forms/questions.py
@@ -1401,10 +1399,8 @@ msgid "Start date"
msgstr "Starttidspunkt"
#: pretix/base/exporters/customers.py
#, fuzzy
#| msgid "Start time from"
msgid "Start time"
msgstr "Starttidspunkt fra"
msgstr "Starttidspunkt"
#: pretix/base/exporters/customers.py pretix/base/exporters/orderlist.py
#: pretix/base/exporters/waitinglist.py pretix/base/models/memberships.py
@@ -1417,10 +1413,8 @@ msgid "End date"
msgstr "Sluttidspunkt"
#: pretix/base/exporters/customers.py
#, fuzzy
#| msgid "End: %(time)s"
msgid "End time"
msgstr "Slut: %(time)s"
msgstr "Sluttidspunkt"
#: pretix/base/exporters/dekodi.py pretix/base/exporters/invoices.py
msgctxt "export_category"
@@ -4641,16 +4635,12 @@ msgid "This event is remote or partially remote."
msgstr "Dette arrangement afholdes online eller delvist online."
#: pretix/base/models/event.py
#, fuzzy
#| msgid ""
#| "This will be used to let users know if the event is in a different "
#| "timezone and lets us calculate users local times."
msgid ""
"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."
msgstr ""
"Dette vil blive brugt til at informere brugerne om, hvorvidt begivenheden "
"finder sted i en anden tidszone, og til at beregne brugernes lokale tid."
"finder sted i en anden tidszone, og til at beregne brugerens lokale tid."
#: pretix/base/models/event.py pretix/base/models/organizer.py
#: pretix/control/navigation.py
@@ -5472,14 +5462,6 @@ msgid "Reusable media policy"
msgstr "Politik for genanvendelige medier"
#: pretix/base/models/items.py
#, fuzzy
#| msgid ""
#| "If this product should be stored on a re-usable physical medium, you can "
#| "attach a physical media policy. This is not required for regular tickets, "
#| "which just use a one-time barcode, but only for products like renewable "
#| "season tickets or re-chargeable gift card wristbands. This is an advanced "
#| "feature that also requires specific configuration of ticketing and "
#| "printing settings."
msgid ""
"If this product should be stored on a reusable physical medium, you can "
"attach a physical media policy. This is not required for regular tickets, "
@@ -5488,12 +5470,12 @@ msgid ""
"feature that also requires specific configuration of ticketing and printing "
"settings."
msgstr ""
"Hvis dette produkt skal opbevares på et genanvendeligt fysisk medie, kan du "
"vedhæfte en politik for fysiske medier. Dette er ikke nødvendigt for "
"almindelige billetter, som bare bruger en engangs-stregkode, men kun for "
"produkter som f.eks. fornyelige sæsonkort eller genopladelige gavekort-"
"armbånd. Dette er en avanceret funktion, som også kræver en specifik "
"konfiguration af billet- og printindstillinger."
"Hvis dette produkt skal opbevares på et genanvendeligt fysisk medium, kan du "
"knytte en politik for fysiske medier til det. Dette er ikke påkrævet for "
"almindelige billetter, som blot bruger en engangsstregkode, men gælder kun "
"for produkter som sæsonkort, der kan fornyes, eller gavekortarmbånd, som kan "
"blive genopladet. Dette er en avanceret funktion, der også kræver en "
"specifik konfiguration af billet- og udskrivningsindstillingerne."
#: pretix/base/models/items.py
msgid "Reusable media type"
@@ -5537,6 +5519,9 @@ msgid ""
"prior to their usage. Therefore, the selected media policy does not make "
"sense for this media type."
msgstr ""
"Den valgte medietype kræver, at alle medier registreres i systemet, inden de "
"tages i brug. Derfor giver den valgte mediepolitik ikke mening for denne "
"medietype."
#: pretix/base/models/items.py
msgid ""
@@ -6090,18 +6075,16 @@ msgstr "afvist"
#: pretix/base/models/media.py
msgctxt "reusable_medium"
msgid "Claim token"
msgstr ""
msgstr "Hent token"
#: pretix/base/models/media.py
msgctxt "reusable_medium"
msgid "Label"
msgstr ""
msgstr "Etiket"
#: pretix/base/models/media.py
#, fuzzy
#| msgid "Linked ticket"
msgid "Linked tickets"
msgstr "Forbundet billet"
msgstr "Sammenkædede billetter"
#: pretix/base/models/media.py
msgid ""
@@ -6109,6 +6092,9 @@ msgid ""
"validity. If multiple tickets are valid at once, this will lead to failed "
"check-ins."
msgstr ""
"Hvis du linker til mere end én billet, skal du sikre dig, at der ikke er "
"nogen overlapning i gyldighedsperioden. Hvis flere billetter er gyldige på "
"samme tid, vil det medføre, at check-in mislykkes."
#: pretix/base/models/memberships.py
#: pretix/presale/templates/pretixpresale/organizers/customer_memberships.html
@@ -7568,6 +7554,8 @@ msgid ""
"This payment provider exists for historical purposes only and is no longer "
"usable."
msgstr ""
"Denne betalingsudbyder findes udelukkende af historiske årsager og kan ikke "
"længere benyttes."
#: pretix/base/pdf.py
msgid "Ticket code (barcode content)"
@@ -7802,15 +7790,12 @@ msgid "Atlantis"
msgstr "Eksempelland"
#: pretix/base/pdf.py
#, fuzzy
msgid "Invoice custom recipient field"
msgstr "Fakturamodtager:"
msgstr "Brugerdefineret modtagerfelt på fakturaen"
#: pretix/base/pdf.py
#, fuzzy
#| msgid "Custom recipient field label"
msgid "Custom recipient field"
msgstr "Brugerdefineret etiket til modtagerfelt"
msgstr "Brugerdefineret modtagerfelt"
#: pretix/base/pdf.py
msgid "List of Add-Ons"
@@ -8252,7 +8237,7 @@ msgstr "Arrangement aflyst"
#: pretix/base/services/cancelevent.py
msgid "Confirm event cancellation and bulk refund"
msgstr ""
msgstr "Bekræft aflysning af begivenhed og samlet refusion"
#: pretix/base/services/cart.py pretix/base/services/modelimport.py
#: pretix/base/services/orders.py
@@ -8781,13 +8766,15 @@ msgstr "Du skal svare på spørgsmål for at gennemføre dette tjek-ind."
#: pretix/base/services/checkin.py
msgid "Ticket needs to be exchanged to a suitable medium."
msgstr ""
msgstr "Billetten skal ombyttes til et passende medium."
#: pretix/base/services/checkin.py
msgid ""
"This ticket has already been exchanged for a reusable medium that now needs "
"to be used instead."
msgstr ""
"Denne billet er allerede blevet ombyttet til et genanvendeligt medium, som "
"nu skal bruges i stedet."
#: pretix/base/services/checkin.py
msgid "This ticket has already been redeemed."
@@ -8818,9 +8805,8 @@ msgid "Your export did not contain any data."
msgstr "Din eksport indeholdt ingen data."
#: pretix/base/services/export.py
#, fuzzy
msgid "Scheduled export failed"
msgstr "Start eksport"
msgstr "Den planlagte eksport mislykkedes"
#: pretix/base/services/export.py
msgid "Permission denied."
@@ -8947,60 +8933,44 @@ msgid "You are receiving this email because you placed an order for {event}."
msgstr "Du modtager denne e-mail, fordi du har afgivet en ordre til {event}."
#: pretix/base/services/media.py
#, fuzzy
#| msgid "Invalid input type."
msgid "Invalid medium type."
msgstr "Ugyldig indtastning."
msgstr "Ugyldig medietype."
#: pretix/base/services/media.py
#, fuzzy
#| msgid "The selected media type is not enabled in your organizer settings."
msgid "Medium type is not enabled for organizer."
msgstr "Den valgte medietype er ikke aktiveret i dine arrangør-indstillinger."
msgstr "Medie-typen er ikke aktiveret for arrangøren."
#: pretix/base/services/media.py
msgid "Incorrect medium type for product."
msgstr ""
msgstr "Forkert medietype for produktet."
#: pretix/base/services/media.py
#, fuzzy
#| msgid "This ticket has already been redeemed."
msgid "Ticket is already exchanged for reusable medium."
msgstr "Denne billet er allerede blevet indløst."
msgstr "Billetten er allerede ombyttet til et genanvendeligt medium."
#: pretix/base/services/media.py
#, fuzzy
#| msgid "Reusable Medium ID"
msgid "Reusable medium not found."
msgstr "ID for genanvendeligt medie"
msgstr "Genanvendeligt medie blev ikke fundet."
#: pretix/base/services/media.py
#, fuzzy
#| msgid "The reusable medium has been created."
msgid "Reusable medium is inactive or expired."
msgstr "Det genanvendelige medie er blevet oprettet."
msgstr "Det genanvendelige medium er inaktivt eller udløbet."
#: pretix/base/services/media.py
#, fuzzy
#| msgid "The reusable medium has been created."
msgid "Reusable medium not found and could not be created."
msgstr "Det genanvendelige medie er blevet oprettet."
msgstr "Genanvendeligt medie blev ikke fundet og kunne ikke oprettes."
#: pretix/base/services/media.py
#, fuzzy
#| msgid "Reusable media type"
msgid "Reusable medium already exists."
msgstr "Genanvendelig medietype"
msgstr "Det genanvendelige medie findes allerede."
#: pretix/base/services/media.py
#, fuzzy
#| msgid "The reusable medium has been created."
msgid "Reusable medium could not be created."
msgstr "Det genanvendelige medie er blevet oprettet."
msgstr "Det var ikke muligt at oprette det genanvendelige medie."
#: pretix/base/services/media.py
msgid "Product does not support medium exchange."
msgstr ""
msgstr "Produktet understøtter ikke udskiftning af medier."
#: pretix/base/services/memberships.py
#, python-brace-format
@@ -9164,6 +9134,7 @@ msgstr "En voucher kan ikke oprettes uden kode."
msgid ""
"Voucher codes must be unique. Code \"{code}\" already exists in this import."
msgstr ""
"Kuponkoder skal være unikke. Koden \"{code}\" findes allerede i denne import."
#: pretix/base/services/modelimport.py
#, python-brace-format
@@ -9172,13 +9143,19 @@ msgid ""
msgid_plural ""
"Voucher codes must be unique. Import contains existing voucher codes {code}."
msgstr[0] ""
"Rabatkoder skal være unikke. Importen indeholder den eksisterende rabatkode "
"{code}."
msgstr[1] ""
"Rabatkoder skal være unikke. Importen indeholder de eksisterende rabatkoder "
"{code}."
#: pretix/base/services/modelimport.py
msgid ""
"Vouchers could not be imported, probably due to a voucher code already being "
"in use."
msgstr ""
"Rabatkoder kunne ikke importeres, sandsynligvis fordi en kuponkode allerede "
"var i brug."
#: pretix/base/services/orders.py
msgid ""
@@ -9462,16 +9439,12 @@ msgstr ""
"gavekort."
#: pretix/base/services/orders.py
#, fuzzy
#| msgid ""
#| "You cannot change the price of a position that has been used to issue a "
#| "gift card."
msgid ""
"You cannot change the ticket secret of a position that has been used to "
"issue a gift card."
msgstr ""
"Du kan ikke ændre prisen af en post, som er blevet brugt til at udstede et "
"gavekort."
"Du kan ikke ændre billetkoden for en position, der er blevet brugt til at "
"udstede et gavekort."
#: pretix/base/services/orders.py
#, python-brace-format
@@ -9558,10 +9531,9 @@ msgid "Something happened in your event after the export, please try again."
msgstr "Der skete noget i din begivenhed efter eksporten, prøv venligst igen."
#: pretix/base/services/shredder.py
#, fuzzy, python-format
#| msgid "Data shredding completed"
#, python-format
msgid "Data shredding completed for %(event)s"
msgstr "Makulering af data afsluttet"
msgstr "Datadestruktion for %(event)s er afsluttet"
#: pretix/base/services/stats.py
msgid "Uncategorized"
@@ -9738,29 +9710,24 @@ msgstr ""
"ind under købet."
#: pretix/base/settings.py
#, fuzzy
#| msgid "Activate re-usable media"
msgid "Activate reusable media"
msgstr "Aktiver genanvendelige medier"
#: pretix/base/settings.py
#, fuzzy
#| msgid ""
#| "The re-usable media feature allows you to connect tickets and gift cards "
#| "with physical media such as wristbands or chip cards that may be re-used "
#| "for different tickets or gift cards later."
msgid ""
"The reusable media feature allows you to connect tickets and gift cards with "
"physical media such as wristbands or chip cards that may be reused for "
"different tickets or gift cards later."
msgstr ""
"Funktionen \"Genanvendelige medier\" gør det muligt at forbinde billetter og "
"voucher med fysiske medier som armbånd eller chipkort, der kan genbruges til "
"andre billetter eller voucher senere."
"Med funktionen \"Genanvendelige medier\" kan du knytte billetter og gavekort "
"til fysiske medier som armbånd eller chipkort, som senere kan genbruges "
"til andre billetter eller gavekort."
#: pretix/base/settings.py
msgid "Enforce the usage of issued reusable media for check-in"
msgstr ""
"Gennemtving, at der udelukkende anvendes de udleverede genanvendelige medier "
"ved check-in"
#: pretix/base/settings.py
msgid ""
@@ -9768,6 +9735,10 @@ msgid ""
"medium has been created and linked to a ticket. Keeping this option turned "
"off will treat the reusable medium and ticket as equals."
msgstr ""
"Hvis denne indstilling er aktiveret, accepteres en billetstregkode ikke "
"længere, hvis der er oprettet et genanvendeligt medie, som er knyttet til en "
"billet. Hvis denne indstilling forbliver deaktiveret, behandles det "
"genanvendelige medie og billetten på samme måde."
#: pretix/base/settings.py
msgid "Length of barcodes"
@@ -11336,10 +11307,8 @@ msgid "We'll show this publicly to allow attendees to contact you."
msgstr "Vi vil vise dette offentligt, så deltagerne kan kontakte dig."
#: pretix/base/settings.py
#, fuzzy
#| msgid "Contact"
msgid "Contact URL"
msgstr "Kontakt"
msgstr "Kontakt-URL"
#: pretix/base/settings.py
msgid ""
@@ -11347,6 +11316,9 @@ msgid ""
"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."
msgstr ""
"Hvis du indstiller dette, vil kontaktlinket i sidefoden pege hertil i stedet "
"for at bruge ovenstående e-mailadresse. Bemærk, at du stadig skal tilføje en "
"kontakt-e-mailadresse, som vil blive angivet i alle de e-mails, du sender."
#: pretix/base/settings.py pretix/control/forms/event.py
msgid "Imprint URL"
@@ -13213,30 +13185,19 @@ msgstr ""
"os."
#: pretix/base/templates/pretixbase/email/cancel_confirm.txt
#, fuzzy
#| msgid ""
#| "You have requested us to cancel an event which includes a larger bulk-"
#| "refund:"
msgid "You requested to cancel an event that involves a large bulk refund:"
msgstr ""
"Du har bedt os om at aflyse et arrangement, der indebærer en større samlet "
"Du har anmodet om at annullere en begivenhed, der medfører en stor samlet "
"refusion:"
#: pretix/base/templates/pretixbase/email/cancel_confirm.txt
#, fuzzy
#| msgid "Estimated refund amount"
msgid "Estimated refund"
msgstr "Anslået refusionsbeløb"
msgstr "Anslået tilbagebetaling"
#: pretix/base/templates/pretixbase/email/cancel_confirm.txt
#, fuzzy
#| msgid ""
#| "Please confirm that you want to proceed by coping the following "
#| "confirmation code into the cancellation form:"
msgid "To confirm, paste the following code into the cancellation form:"
msgstr ""
"Bekræft venligst, at du ønsker at fortsætte ved at indsætte følgende "
"bekræftelseskode i afmeldingsformularen:"
"For at bekræfte skal du indsætte følgende kode i afbestillingsformularen:"
#: pretix/base/templates/pretixbase/email/cancel_confirm.txt
#, python-format
@@ -13244,6 +13205,8 @@ msgid ""
"Don't share this code with anyone. The %(instance)s team will never ask you "
"for it."
msgstr ""
"Del ikke denne kode med nogen. %(instance)s-teamet vil aldrig bede dig om "
"den."
#: pretix/base/templates/pretixbase/email/cancel_confirm.txt
#: pretix/base/templates/pretixbase/email/export_failed.txt
@@ -13252,6 +13215,8 @@ msgid ""
"Thanks, \n"
"The %(instance)s Team"
msgstr ""
"Tak, \n"
"%(instance)s-teamet"
#: pretix/base/templates/pretixbase/email/email_footer.html
#, python-format
@@ -13259,51 +13224,38 @@ msgid "powered by <a %(a_attr)s>pretix</a>"
msgstr "drevet af <a %(a_attr)s>pretix</a>"
#: pretix/base/templates/pretixbase/email/export_failed.txt
#, fuzzy
#| msgid "Your export failed."
msgid "Your scheduled export failed."
msgstr "Din eksport mislykkedes."
msgstr "Din planlagte eksport mislykkedes."
#: pretix/base/templates/pretixbase/email/export_failed.txt
#: pretix/control/templates/pretixcontrol/event/tax_edit.html
#, fuzzy
msgid "Reason"
msgstr "Tilbagebetal bestilling"
msgstr "Begrundelse"
#: pretix/base/templates/pretixbase/email/export_failed.txt
#, fuzzy
#| msgid "If your export fails five times in a row, it will no longer be sent."
msgid "If an export fails five times in a row, we'll stop sending it."
msgstr ""
"Hvis din eksport mislykkes fem gange i træk, vil den ikke længere blive "
"sendt."
"Hvis en eksport mislykkes fem gange i træk, vil vi stoppe med at sende den."
#: pretix/base/templates/pretixbase/email/export_failed.txt
#, fuzzy
#| msgid "You can request to cancel this order."
msgid "You can adjust or remove this export here:"
msgstr "Du kan anmode om at annullere denne bestilling."
msgstr "Du kan ændre eller fjerne denne eksport her:"
#: pretix/base/templates/pretixbase/email/notification.html
#: pretix/base/templates/pretixbase/email/notification.txt
#, fuzzy
#| msgid "You receive these emails based on your notification settings."
msgid "You're receiving this email based on your notification settings."
msgstr ""
"Du modtager disse e-mails i henhold til dine indstillinger for "
"notifikationer."
"Du modtager denne e-mail i henhold til dine indstillinger for meddelelser."
#: pretix/base/templates/pretixbase/email/notification.html
#: pretix/base/templates/pretixbase/email/notification.txt
#, fuzzy
msgid "Manage settings"
msgstr "Basisindstillinger"
msgstr "Administrer indstillinger"
#: pretix/base/templates/pretixbase/email/notification.html
#: pretix/base/templates/pretixbase/email/notification.txt
#, fuzzy
msgid "Disable all notifications"
msgstr "E-mailnotifikationer"
msgstr "Deaktiver alle notifikationer"
#: pretix/base/templates/pretixbase/email/order_details.html
msgid ""
@@ -13362,25 +13314,7 @@ msgid "Contact"
msgstr "Kontakt"
#: pretix/base/templates/pretixbase/email/shred_completed.txt
#, fuzzy, python-format
#| msgid ""
#| "Hello,\n"
#| "\n"
#| "we hereby confirm that the following data shredding job has been "
#| "completed:\n"
#| "\n"
#| "Organizer: %(organizer)s\n"
#| "\n"
#| "Event: %(event)s\n"
#| "\n"
#| "Data selection: %(shredders)s\n"
#| "\n"
#| "Start time: %(start_time)s (new data added after this time might not have "
#| "been deleted)\n"
#| "\n"
#| "Best regards,\n"
#| "\n"
#| "Your %(instance)s team\n"
#, python-format
msgid ""
"Hello,\n"
"\n"
@@ -13398,20 +13332,19 @@ msgid ""
msgstr ""
"Hej,\n"
"\n"
"vi bekræfter hermed, at følgende datadestruktionsopgave er afsluttet:\n"
"Følgende datadestruktionsopgave er afsluttet:\n"
"\n"
"Arrangør: %(organizer)s\n"
"- Arrangør: %(organizer)s\n"
"- Begivenhed: %(event)s\n"
"- Dataudvælgelse: %(shredders)s\n"
"- Starttidspunkt: %(start_time)s\n"
"\n"
"Begivenhed: %(event)s\n"
"Data, der er tilføjet til begivenheden efter starttidspunktet, er muligvis "
"ikke blevet slettet.\n"
"\n"
"Dataudvælgelse: %(shredders)s\n"
"Med venlig hilsen, \n"
"\n"
"Starttidspunkt: %(start_time)s (nye data tilføjet efter dette tidspunkt er "
"muligvis ikke blevet slettet)\n"
"\n"
"Med venlig hilsen,\n"
"\n"
"Dit %(instance)s-team\n"
"%(instance)s-teamet\n"
#: pretix/base/templates/pretixbase/forms/widgets/checkbox_sales_channel_option.html
msgid ""
@@ -13443,22 +13376,23 @@ msgstr "%(number)s dage %(relation)s %(relation_to)s på %(time_of_day)s"
#: pretix/base/templates/pretixbase/framebreak.html
#: pretix/presale/templates/pretixpresale/event/cookies.html
msgid "Please continue in a new tab"
msgstr ""
msgstr "Fortsæt venligst i en ny fane"
#: pretix/base/templates/pretixbase/framebreak.html
msgid "For security reasons, the following step is only possible in a new tab."
msgstr ""
"Af sikkerhedsmæssige årsager kan det følgende trin kun udføres i en ny fane."
#: pretix/base/templates/pretixbase/framebreak.html
msgid ""
"If the new tab did not open automatically, please click the following button:"
msgstr ""
"Hvis den nye fane ikke åbnede automatisk, skal du klikke på følgende knap:"
#: pretix/base/templates/pretixbase/framebreak.html
#: pretix/presale/templates/pretixpresale/event/cookies.html
#, fuzzy
msgid "Continue in new tab"
msgstr "Opret gruppe"
msgstr "Fortsæt i en ny fane"
#: pretix/base/templates/pretixbase/redirect.html
msgid "Redirect"
@@ -15184,13 +15118,11 @@ msgstr "Søg efter e-mailadresse eller emne"
#: pretix/control/templates/pretixcontrol/order/index.html
#: pretix/control/templates/pretixcontrol/orders/refunds.html
msgid "Source"
msgstr ""
msgstr "Kilde"
#: pretix/control/forms/filter.py
#, fuzzy
#| msgid "All vouchers"
msgid "All sources"
msgstr "Alle vouchere"
msgstr "Alle kilder"
#: pretix/control/forms/filter.py
msgid "Team actions"
@@ -15201,21 +15133,16 @@ msgid "Customer actions"
msgstr "Kundehandlinger"
#: pretix/control/forms/filter.py
#, fuzzy
#| msgid "Device status"
msgid "Device actions"
msgstr "Enhedsstatus"
msgstr "Enhedshandlinger"
#: pretix/control/forms/filter.py
#, fuzzy
#| msgid "Order email"
msgid "User email"
msgstr "Bestillings-e-mail"
msgstr "Brugerens e-mailadresse"
#: pretix/control/forms/filter.py pretix/control/navigation.py
#, fuzzy
msgid "All users"
msgstr "Alle vouchere"
msgstr "Alle brugere"
#: pretix/control/forms/global_settings.py
msgid "Additional footer text"
@@ -16724,40 +16651,34 @@ msgid ""
"because at least one of the selected vouchers has already been redeemed "
"%(max_redeemed)s times."
msgstr ""
"Du kan ikke reducere det maksimale antal indløsninger til %(max_usages)s, da "
"mindst én af de valgte kuponer allerede er blevet indløst %(max_redeemed)s "
"gange."
#: pretix/control/forms/vouchers.py
#, fuzzy
#| msgid ""
#| "You cannot create a voucher that blocks quota as the selected product or "
#| "quota is currently sold out or completely reserved."
msgid ""
"You cannot create a voucher that allows selection of a quota but has no date "
"selected."
msgstr ""
"Du kan ikke oprette en rabatkode der blokerer en kvote idet det valgte "
"produkt eller kvote pt. er udsolgt eller fuldt reserveret."
"Du kan ikke oprette en kupon, hvor det er muligt at vælge en kvote, men hvor "
"der ikke er valgt nogen dato."
#: pretix/control/forms/vouchers.py
#, fuzzy
#| msgid "The selected product does not allow to select a seat."
msgid "The selected quota does not match the selected subevent."
msgstr "Det valgte produkt tillader ikke, at du vælger en plads."
msgstr "Den valgte kvote stemmer ikke overens med den valgte underbegivenhed."
#: pretix/control/forms/vouchers.py
#, fuzzy
#| msgid ""
#| "There is not enough quota available on quota \"{}\" to perform the "
#| "operation."
msgid "There is no sufficient quota available to perform this change."
msgstr ""
"Der er ikke enheder nok tilbage på kvoten \"{}\" til at udføre denne "
"operation."
"Der er ikke tilstrækkelig kvote til rådighed til at gennemføre denne ændring."
#: pretix/control/forms/vouchers.py
msgid ""
"Changing the maximum number of usages in bulk is not supported if any of the "
"selected vouchers is assigned a seat."
msgstr ""
"Det er ikke muligt at ændre det maksimale antal anvendelser samlet, hvis en "
"af de valgte kuponer er tildelt en plads."
#: pretix/control/forms/vouchers.py
msgctxt "subevent"
@@ -16765,18 +16686,24 @@ msgid ""
"Changing the date in bulk is not supported if any of the selected vouchers "
"is assigned a seat."
msgstr ""
"Det er ikke muligt at ændre datoen for flere billetter på én gang, hvis en "
"af de valgte billetter er tildelt en plads."
#: pretix/control/forms/vouchers.py
msgid ""
"Changing the product to a quota is not supported if any of the selected "
"vouchers is assigned a seat."
msgstr ""
"Det er ikke muligt at ændre produktet til en kvote, hvis en af de valgte "
"kuponer er tildelt en plads."
#: pretix/control/forms/vouchers.py
msgid ""
"This change cannot be completed because not all assigned seats of the "
"vouchers are still available"
msgstr ""
"Denne ændring kan ikke gennemføres, da ikke alle de tildelte pladser på "
"billetterne stadig er ledige"
#: pretix/control/forms/vouchers.py
msgid "Codes"
@@ -17897,16 +17824,12 @@ msgid "The reusable medium has been changed."
msgstr "Det genanvendelige medie er blevet udskiftet."
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The new member has been added to the team."
msgid "A new ticket has been added to the medium."
msgstr "Det nye medlem er blevet føjet til gruppen."
msgstr "Der er blevet tilføjet en ny billet til mediet."
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "{user} has been removed from the team."
msgid "A ticket has been removed from the medium."
msgstr "{user} er fjernet fra gruppen."
msgstr "En billet er blevet fjernet fra mediet."
#: pretix/control/logdisplay.py
msgid "The medium has been connected to a new ticket."
@@ -17918,6 +17841,8 @@ msgid ""
"The ticket #{positionid} was exchanged for reusable medium "
"{medium_identifier}."
msgstr ""
"Billetten #{positionid} blev ombyttet til et genanvendeligt medie "
"{medium_identifier}."
#: pretix/control/logdisplay.py
msgid "The medium has been connected to a new gift card."
@@ -18328,14 +18253,14 @@ msgid "Payment {local_id} has been confirmed."
msgstr "Betalingen {local_id} er blevet bekræftet."
#: pretix/control/logdisplay.py
#, fuzzy, python-brace-format
#, python-brace-format
msgid "Payment {local_id} has been canceled."
msgstr "Bestillingen er blevet annulleret."
msgstr "Betalingen {local_id} er blevet annulleret."
#: pretix/control/logdisplay.py
#, fuzzy, python-brace-format
#, python-brace-format
msgid "Canceling payment {local_id} has failed."
msgstr "Bestillingen er blevet annulleret."
msgstr "Det lykkedes ikke at annullere betalingen {local_id}."
#: pretix/control/logdisplay.py
#, fuzzy, python-brace-format
@@ -21795,54 +21720,51 @@ msgstr ""
#: pretix/control/templates/pretixcontrol/global_sysreport.html
msgid "January"
msgstr ""
msgstr "januar"
#: pretix/control/templates/pretixcontrol/global_sysreport.html
msgid "February"
msgstr ""
msgstr "februar"
#: pretix/control/templates/pretixcontrol/global_sysreport.html
#, fuzzy
msgid "March"
msgstr "Marts"
msgstr "marts"
#: pretix/control/templates/pretixcontrol/global_sysreport.html
msgid "April"
msgstr ""
msgstr "april"
#: pretix/control/templates/pretixcontrol/global_sysreport.html
#, fuzzy
#| msgid "Day"
msgid "May"
msgstr "Dag"
msgstr "maj"
#: pretix/control/templates/pretixcontrol/global_sysreport.html
msgid "June"
msgstr ""
msgstr "juni"
#: pretix/control/templates/pretixcontrol/global_sysreport.html
msgid "July"
msgstr ""
msgstr "juli"
#: pretix/control/templates/pretixcontrol/global_sysreport.html
msgid "August"
msgstr ""
msgstr "august"
#: pretix/control/templates/pretixcontrol/global_sysreport.html
msgid "September"
msgstr "September"
msgstr "september"
#: pretix/control/templates/pretixcontrol/global_sysreport.html
msgid "October"
msgstr ""
msgstr "oktober"
#: pretix/control/templates/pretixcontrol/global_sysreport.html
msgid "November"
msgstr "November"
msgstr "november"
#: pretix/control/templates/pretixcontrol/global_sysreport.html
msgid "December"
msgstr "December"
msgstr "december"
#: pretix/control/templates/pretixcontrol/global_sysreport.html
#, fuzzy
+14 -14
View File
@@ -7,7 +7,7 @@ msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-06 15:52+0000\n"
"PO-Revision-Date: 2026-04-22 18:00+0000\n"
"PO-Revision-Date: 2026-07-31 18:00+0000\n"
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n"
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"
"da/>\n"
@@ -16,7 +16,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.17\n"
"X-Generator: Weblate 2026.7.1\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
msgid "Marked as paid"
@@ -1306,62 +1306,62 @@ msgstr ""
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "January"
msgstr "Januar"
msgstr "januar"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "February"
msgstr "Februar"
msgstr "februar"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "March"
msgstr "Marts"
msgstr "marts"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "April"
msgstr "April"
msgstr "april"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "May"
msgstr "Maj"
msgstr "maj"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "June"
msgstr "Juni"
msgstr "juni"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "July"
msgstr "Juli"
msgstr "juli"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "August"
msgstr "August"
msgstr "august"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "September"
msgstr "September"
msgstr "september"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "October"
msgstr "Oktober"
msgstr "oktober"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "November"
msgstr "November"
msgstr "november"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "December"
msgstr "December"
msgstr "december"
#~ msgid "iDEAL"
#~ msgstr "iDEAL"
+5 -19
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-30 13:52+0000\n"
"PO-Revision-Date: 2026-07-23 15:35+0000\n"
"PO-Revision-Date: 2026-08-01 21:00+0000\n"
"Last-Translator: szurofkamarciidfbe08444ef04788 <szurofkamarcii@gmail.com>\n"
"Language-Team: Hungarian <https://translate.pretix.eu/projects/pretix/pretix/"
"hu/>\n"
@@ -1377,10 +1377,8 @@ msgid "Membership type"
msgstr "Tagságtípus"
#: pretix/base/exporters/customers.py
#, fuzzy
#| msgid "Purchase time"
msgid "Purchase ticket"
msgstr "Vásárlás időpontja"
msgstr "Jegy vásárlása"
#: pretix/base/exporters/customers.py pretix/base/exporters/orderlist.py
#: pretix/base/exporters/waitinglist.py pretix/base/forms/questions.py
@@ -1397,10 +1395,8 @@ msgid "Start date"
msgstr "Kezdő dátum"
#: pretix/base/exporters/customers.py
#, fuzzy
#| msgid "Start time from"
msgid "Start time"
msgstr "Kezdési időpont ettől"
msgstr "Kezdési időpont"
#: pretix/base/exporters/customers.py pretix/base/exporters/orderlist.py
#: pretix/base/exporters/waitinglist.py pretix/base/models/memberships.py
@@ -1413,10 +1409,8 @@ msgid "End date"
msgstr "Záró dátum"
#: pretix/base/exporters/customers.py
#, fuzzy
#| msgid "End: %(time)s"
msgid "End time"
msgstr "Vége: %(time)s"
msgstr "Záró időpont"
#: pretix/base/exporters/dekodi.py pretix/base/exporters/invoices.py
msgctxt "export_category"
@@ -4641,10 +4635,6 @@ msgid "This event is remote or partially remote."
msgstr "Ez az rendezvény távoli vagy részben távoli."
#: pretix/base/models/event.py
#, fuzzy
#| msgid ""
#| "This will be used to let users know if the event is in a different "
#| "timezone and lets us calculate users local times."
msgid ""
"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."
@@ -7578,15 +7568,11 @@ msgid "This gift card was used in the meantime. Please try again."
msgstr "Ezt az ajándékkártyát időközben felhasználták. Kérjük, próbálja újra."
#: pretix/base/payment.py
#, fuzzy
#| msgid ""
#| "This payment provider does not exist or the respective plugin is disabled."
msgid ""
"This payment provider exists for historical purposes only and is no longer "
"usable."
msgstr ""
"Ez a fizetési szolgáltató nem létezik, vagy a hozzá tartozó bővítmény le van "
"tiltva."
"Ez a fizetési szolgáltató egy korábbi funkció volt, már nem használható."
#: pretix/base/pdf.py
msgid "Ticket code (barcode content)"
+96 -44
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-30 13:52+0000\n"
"PO-Revision-Date: 2026-05-12 04:00+0000\n"
"Last-Translator: Stefano Campus <stefano.campus@regione.piemonte.it>\n"
"PO-Revision-Date: 2026-08-03 23:00+0000\n"
"Last-Translator: \"Luca Sorace \\\"Stranck\\\"\" <strdjn@gmail.com>\n"
"Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix/"
"it/>\n"
"Language: it\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.17.1\n"
"X-Generator: Weblate 2026.7.1\n"
#: pretix/_base_settings.py
msgid "English"
@@ -149,7 +149,7 @@ msgstr "Spagnolo (America Latina)"
#: pretix/_base_settings.py
msgid "Thai"
msgstr ""
msgstr "Tailandese"
#: pretix/_base_settings.py
msgid "Turkish"
@@ -413,7 +413,7 @@ msgstr ""
#: pretix/api/serializers/organizer.py pretix/control/views/organizer.py
#, python-format
msgid "You've been invited to join %(organizer)s"
msgstr ""
msgstr "Sei stato invitato ad entrare in %(organizer)s"
#: pretix/api/serializers/organizer.py pretix/control/views/organizer.py
msgid "This user already has been invited for this team."
@@ -605,6 +605,9 @@ msgid ""
"This includes related events like creation, deletion, opening or closing of "
"quotas. No webhook is sent for changes to the resulting availability."
msgstr ""
"Questo include gli eventi relativi quali la creazione, cancellazione, "
"apertura o chiusura delle quote. Nessun webhook verrà inviato per modifiche "
"alla disponibilità."
#: pretix/api/webhooks.py
msgid "Shop taken live"
@@ -1234,8 +1237,9 @@ msgstr "Indirizzi Email (file di testo)"
#: pretix/control/templates/pretixcontrol/organizers/customer.html
#: pretix/presale/templates/pretixpresale/organizers/customer_memberships.html
#: pretix/presale/views/customer.py
#, fuzzy
msgid "Memberships"
msgstr ""
msgstr "Membership"
#: pretix/base/exporters/customers.py pretix/base/models/customers.py
#: pretix/control/templates/pretixcontrol/organizers/customer.html
@@ -3030,11 +3034,12 @@ msgid ""
"The field \"%(label)s\" may not contain special characters such as "
"\"%(chars)s\"."
msgstr ""
"Il campo \"%(label)s\" non può contenere i caratteri speciali \"%(chars)s\"."
#: pretix/base/forms/questions.py
#, python-format
msgid "The field \"%(label)s\" may not contain an URL (%(url)s)."
msgstr ""
msgstr "Il campo \"%(label)s\" non può contenere un URL (%(url)s)."
#: pretix/base/forms/questions.py
msgctxt "phonenumber"
@@ -4272,8 +4277,9 @@ msgid "Ticket type not allowed here"
msgstr "Biglietto non consentito qui"
#: pretix/base/models/checkin.py
#, fuzzy
msgid "Ticket code is ambiguous on list"
msgstr "Codice biglietto ambiguo nella lista"
msgstr "Il codice del biglietto è ambiguo sulla lista"
#: pretix/base/models/checkin.py
msgid "Server error"
@@ -6167,14 +6173,15 @@ msgid "bounced"
msgstr "rimbalzato"
#: pretix/base/models/media.py
#, fuzzy
msgctxt "reusable_medium"
msgid "Claim token"
msgstr ""
msgstr "Applica token"
#: pretix/base/models/media.py
msgctxt "reusable_medium"
msgid "Label"
msgstr ""
msgstr "Etichetta"
#: pretix/base/models/media.py
#, fuzzy
@@ -6188,6 +6195,9 @@ msgid ""
"validity. If multiple tickets are valid at once, this will lead to failed "
"check-ins."
msgstr ""
"Se colleghi più di un biglietto, assicurati che la loro validità non si "
"sovrapponga. Se più biglietti sono validi contemporaneamente i check-in "
"andranno in errore."
#: pretix/base/models/memberships.py
#: pretix/presale/templates/pretixpresale/organizers/customer_memberships.html
@@ -7676,6 +7686,8 @@ msgid ""
"This payment provider exists for historical purposes only and is no longer "
"usable."
msgstr ""
"Questo provider di pagamenti esiste per ragioni storiche e non ne è più "
"possibile l'utilizzo."
#: pretix/base/pdf.py
msgid "Ticket code (barcode content)"
@@ -7843,7 +7855,7 @@ msgstr "Giorno di inizio evento"
#: pretix/base/pdf.py pretix/base/services/checkin.py
#: pretix/control/forms/filter.py
msgid "Friday"
msgstr "venerdì"
msgstr "Venerdì"
#: pretix/base/pdf.py
msgid "Event end date and time"
@@ -8387,7 +8399,7 @@ msgstr "Evento annullato"
#: pretix/base/services/cancelevent.py
msgid "Confirm event cancellation and bulk refund"
msgstr ""
msgstr "Conferma la cancellazione dell'evento e i rimborsi di tutti gli ordini"
#: pretix/base/services/cart.py pretix/base/services/modelimport.py
#: pretix/base/services/orders.py
@@ -9333,6 +9345,8 @@ msgstr "Non è possibile creare un buono senza un codice."
msgid ""
"Voucher codes must be unique. Code \"{code}\" already exists in this import."
msgstr ""
"Il codice del voucher deve essere univoco. \"{code}\" esiste già in questo "
"import."
#: pretix/base/services/modelimport.py
#, python-brace-format
@@ -9341,13 +9355,19 @@ msgid ""
msgid_plural ""
"Voucher codes must be unique. Import contains existing voucher codes {code}."
msgstr[0] ""
"Il codice del voucher deve essere univoco. \"{code}\" esiste già in questo "
"import."
msgstr[1] ""
"Il codice dei voucher deve essere univoco. \"{code}\" esiste già in questo "
"import."
#: pretix/base/services/modelimport.py
msgid ""
"Vouchers could not be imported, probably due to a voucher code already being "
"in use."
msgstr ""
"Non è stato possibile importare i voucher, probabilmente perché il codice di "
"un voucher è già in uso."
#: pretix/base/services/orders.py
msgid ""
@@ -10267,6 +10287,10 @@ msgid ""
"ID in all countries. VAT ID will be required for all business addresses in "
"the selected countries."
msgstr ""
"La partita IVA è opzionale di default, perché non tutte le aziende la "
"posseggono in tutte le nazioni. Il codice della partita IVA sarà "
"obbligatorio per tutti gli indirizzi aziendali locati nelle nazioni "
"selezionate."
#: pretix/base/settings.py
msgid "Invoice address explanation"
@@ -11316,50 +11340,60 @@ msgstr ""
#: pretix/base/settings.py
msgid "Customers can change the variation of the products they purchased"
msgstr ""
msgstr "I clienti potranno cambiare la variation dei prodotti già acquistati"
#: pretix/base/settings.py
#, fuzzy
msgid "Customers can change their selected add-on products"
msgstr "I clienti non possono più modificare i loro ordini"
msgstr "I clienti non potranno più modificare i gli add-on selezionati"
#: pretix/base/settings.py
msgid ""
"Only allow changes if the resulting price is higher or equal than the "
"previous price."
msgstr ""
"Permetti le modifiche solamente se il prezzo finale è maggiore o uguale al "
"prezzo precedente."
#: pretix/base/settings.py
msgid ""
"Only allow changes if the resulting price is higher than the previous price."
msgstr ""
"Permetti le modifiche solamente se il prezzo finale è maggiore del prezzo "
"precedente."
#: pretix/base/settings.py
msgid ""
"Only allow changes if the resulting price is equal to the previous price."
msgstr ""
"Permetti le modifiche solamente se il prezzo finale è uguale al prezzo "
"precedente."
#: pretix/base/settings.py
msgid ""
"Allow changes regardless of price, as long as no refund is required (i.e. "
"the resulting price is not lower than what has already been paid)."
msgstr ""
"Permetti le modifiche a prescindere del prezzo, a meno che non sia "
"necessario un rimborso (Il prezzo finale non è inferiore a quanto il cliente "
"ha già pagato)."
#: pretix/base/settings.py
msgid "Allow changes regardless of price, even if this results in a refund."
msgstr ""
"Permetti le modifiche a prescindere del prezzo, anche se portano ad un "
"rimborso."
#: pretix/base/settings.py
msgid "Requirement for changed prices"
msgstr ""
msgstr "Requisiti per il cambiamento di prezzi"
#: pretix/base/settings.py
msgid "Do not allow changes after"
msgstr ""
msgstr "Non permettere modifiche dopo il"
#: pretix/base/settings.py
msgid "Allow change even though the ticket has already been checked in"
msgstr ""
msgstr "Permetti modifiche anche se il biglietto ha già effettuato il check-in"
#: pretix/base/settings.py
msgid ""
@@ -11369,10 +11403,16 @@ msgid ""
"in individually. Use with care, and preferably only in combination with a "
"limitation on price changes above."
msgstr ""
"Di default, le modifiche degli ordini sono disabilitate dopo che è stato "
"effettuato il check-in con qualsiasi biglietto contenuto nell'ordine. "
"Abilitando questa opzione, disabiliterai questo controllo. Non sarà comunque "
"possibile rimuovere un add-on con cui è già stato effettuato un check-in. "
"Usa questa opzione con attenzione e, preferibilmente, solamente in "
"combinazione con una delle limitazioni sul prezzo elencate sopra."
#: pretix/base/settings.py
msgid "Allow individual attendees to change their ticket"
msgstr ""
msgstr "Permetti ai singoli partecipanti di modificare il proprio biglietto"
#: pretix/base/settings.py
msgid ""
@@ -11382,15 +11422,20 @@ msgid ""
"total price of the order. Such changes can always only be made by the main "
"customer."
msgstr ""
"Di default, solo la persona che ha effettuato l'ordine di questo biglietto "
"può eseguire modifiche. Selezionando questa opzione, anche i singoli "
"partecipanti potranno fare cambiamenti. In ogni caso, i singoli partecipanti "
"non potranno effettuare operazioni che cambiano il costo dell'ordine. Queste "
"operazioni sono possibili solamente per chi originariamente ha creato "
"l'ordine."
#: pretix/base/settings.py
msgid "Customers can cancel their unpaid orders"
msgstr ""
msgstr "I clienti possono annullare i loro ordini non pagati"
#: pretix/base/settings.py
#, fuzzy
msgid "Charge a fixed cancellation fee"
msgstr "Cancellazione di"
msgstr "Addebita una quota di cancellazione fissa"
#: pretix/base/settings.py
msgid ""
@@ -11398,50 +11443,58 @@ msgid ""
"never charged. Note that it will be your responsibility to claim the "
"cancellation fee from the user."
msgstr ""
"Interessa solamente gli ordini con pagamenti in attesa, la quota di "
"cancellazione non viene mai addebitata sugli ordini gratuiti. Nota: è tua "
"responsabilità collezionare la quota di cancellazione dall'utente."
#: pretix/base/settings.py
msgid "Charge payment, shipping and service fees"
msgstr ""
msgstr "Addebita la quota di spedizione, pagamento e servizio"
#: pretix/base/settings.py
msgid "Charge a percentual cancellation fee"
msgstr ""
msgstr "Addebita una quota di quota di cancellazione in percentuale"
#: pretix/base/settings.py
msgid "Do not allow cancellations after"
msgstr ""
msgstr "Non permettere di annullare gli ordine dopo il"
#: pretix/base/settings.py
msgid "Customers can cancel their paid orders"
msgstr ""
msgstr "I clienti possono annullare i loro ordini già pagati"
#: pretix/base/settings.py
msgid ""
"Paid money will be automatically paid back if the payment method allows it. "
"Otherwise, a manual refund will be created for you to process manually."
msgstr ""
"Il denaro già incassato sarà automaticamente restituito se il metodo di "
"pagamento lo permette. Altrimenti, un rimborso manuale verrà creato così che "
"tu possa processarlo manualmente."
#: pretix/base/settings.py pretix/control/forms/orders.py
msgid "Keep a fixed cancellation fee"
msgstr ""
msgstr "Trattieni una quota di cancellazione fissa"
#: pretix/base/settings.py
msgid "Keep payment, shipping and service fees"
msgstr ""
msgstr "Trattieni la quota di spedizione, pagamento e servizio"
#: pretix/base/settings.py pretix/control/forms/orders.py
msgid "Keep a percentual cancellation fee"
msgstr ""
msgstr "Trattieni una quota di cancellazione in percentuale"
#: pretix/base/settings.py
msgid "Allow customers to voluntarily choose a lower refund"
msgstr ""
msgstr "Permetti si clienti di scegliere volontariamente un rimborso minore"
#: pretix/base/settings.py
msgid ""
"With this option enabled, your customers can choose to get a smaller refund "
"to support you."
msgstr ""
"Con questa opzione attiva, i tuoi clienti potranno scegliere di ottenere un "
"rimborso minore al dovuto, per effettuare una donazione nei vostri confronti."
#: pretix/base/settings.py
msgid ""
@@ -20421,7 +20474,7 @@ msgstr "Abilita webhook"
#: pretix/control/templates/pretixcontrol/organizers/device_logs.html
#: pretix/control/templates/pretixcontrol/organizers/logs.html
msgid "No results"
msgstr ""
msgstr "Nessun risultato"
#: pretix/control/templates/pretixcontrol/event/mail.html
#: pretix/control/templates/pretixcontrol/organizers/mail.html
@@ -20462,7 +20515,7 @@ msgstr ""
#: pretix/control/templates/pretixcontrol/vouchers/bulk.html
#: pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html
msgid "Edit"
msgstr ""
msgstr "Modifica"
#: pretix/control/templates/pretixcontrol/event/mail.html
#, fuzzy
@@ -25479,7 +25532,7 @@ msgstr ""
#: pretix/control/templates/pretixcontrol/pdf/index.html
msgid "Text box"
msgstr ""
msgstr "Riquadro testo"
#: pretix/control/templates/pretixcontrol/pdf/index.html
#, fuzzy
@@ -25520,7 +25573,7 @@ msgstr ""
#: pretix/control/templates/pretixcontrol/pdf/index.html
msgid "Duplicate"
msgstr ""
msgstr "Duplicato"
#: pretix/control/templates/pretixcontrol/pdf/index.html
msgid "Undo"
@@ -27145,11 +27198,11 @@ msgstr ""
#: pretix/control/views/dashboards.py
msgid "Attendees (ordered)"
msgstr ""
msgstr "Partecipanti (ordini effettuati)"
#: pretix/control/views/dashboards.py
msgid "Attendees (paid)"
msgstr ""
msgstr "Partecipanti (ordini pagati)"
#: pretix/control/views/dashboards.py
#, python-brace-format
@@ -31650,11 +31703,11 @@ msgstr ""
#: pretix/plugins/stripe/payment.py
msgid "Bancontact"
msgstr ""
msgstr "Bancontact"
#: pretix/plugins/stripe/payment.py
msgid "SEPA Direct Debit"
msgstr ""
msgstr "Addebito diretto SEPA"
#: pretix/plugins/stripe/payment.py
msgid ""
@@ -31684,7 +31737,7 @@ msgstr ""
#: pretix/plugins/stripe/payment.py
msgid "Przelewy24"
msgstr ""
msgstr "Przelewy24"
#: pretix/plugins/stripe/payment.py
#, fuzzy
@@ -31700,7 +31753,7 @@ msgstr ""
#: pretix/plugins/stripe/payment.py
msgid "WeChat Pay"
msgstr ""
msgstr "WeChat Pay"
#: pretix/plugins/stripe/payment.py
msgid "Swish"
@@ -31853,7 +31906,7 @@ msgstr ""
#: pretix/plugins/stripe/payment.py
msgid "giropay"
msgstr ""
msgstr "giropay"
#: pretix/plugins/stripe/payment.py
msgid ""
@@ -31877,7 +31930,7 @@ msgstr ""
#: pretix/plugins/stripe/payment.py
msgid "iDEAL | Wero"
msgstr ""
msgstr "iDEAL | Wero"
#: pretix/plugins/stripe/payment.py
msgid ""
@@ -33621,9 +33674,8 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html
#: pretix/presale/templates/pretixpresale/fragment_modals.html
#, fuzzy
msgid "Renew reservation"
msgstr "Descrizione"
msgstr "Rinnova scadenza"
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html
#, fuzzy
+98 -135
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-07-06 15:52+0000\n"
"PO-Revision-Date: 2026-03-25 14:14+0000\n"
"Last-Translator: Pietro Isotti <isottipietro@gmail.com>\n"
"PO-Revision-Date: 2026-08-02 22:00+0000\n"
"Last-Translator: \"Luca Sorace \\\"Stranck\\\"\" <strdjn@gmail.com>\n"
"Language-Team: Italian <https://translate.pretix.eu/projects/pretix/pretix-"
"js/it/>\n"
"Language: it\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.16.2\n"
"X-Generator: Weblate 2026.7.1\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
msgid "Marked as paid"
@@ -42,93 +42,91 @@ msgstr "Apple Pay"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "Itaú"
msgstr ""
msgstr "Ita"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
#, fuzzy
msgid "PayPal Credit"
msgstr "PayPal"
msgstr "PayPal (Pagamento a rate)"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "Credit Card"
msgstr ""
msgstr "Carta di credito"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "PayPal Pay Later"
msgstr ""
msgstr "PayPal (Acquista ora, paga dopo)"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "iDEAL | Wero"
msgstr ""
msgstr "iDEAL | Wero"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "SEPA Direct Debit"
msgstr ""
msgstr "Addebito diretto SEPA"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "Bancontact"
msgstr ""
msgstr "Bancontact"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "giropay"
msgstr ""
msgstr "giropay"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "SOFORT"
msgstr ""
msgstr "SOFORT"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
#, fuzzy
msgid "eps"
msgstr "Si"
msgstr "eps"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "MyBank"
msgstr ""
msgstr "MyBank"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "Przelewy24"
msgstr ""
msgstr "Przelewy24"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "Verkkopankki"
msgstr ""
msgstr "Verkkopankki"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "PayU"
msgstr ""
msgstr "PayU"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "BLIK"
msgstr ""
msgstr "BLIK"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "Trustly"
msgstr ""
msgstr "Trustly"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "Zimpler"
msgstr ""
msgstr "Zimpler"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "Maxima"
msgstr ""
msgstr "Maxima"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "OXXO"
msgstr ""
msgstr "OXXO"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "Boleto"
msgstr ""
msgstr "Boleto"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "WeChat Pay"
msgstr ""
msgstr "WeChat Pay"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "Mercado Pago"
msgstr ""
msgstr "Mercado Pago"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
@@ -143,7 +141,7 @@ msgstr "Stiamo processando il tuo pagamento …"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "Payment method unavailable"
msgstr ""
msgstr "Metodo di pagamento non disponibile"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js
msgid "Placed orders"
@@ -155,11 +153,11 @@ msgstr "Ordini pagati"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js
msgid "Attendees (ordered)"
msgstr ""
msgstr "Partecipanti (ordini effettuati)"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js
msgid "Attendees (paid)"
msgstr ""
msgstr "Partecipanti (ordini pagati)"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js
msgid "Total revenue"
@@ -239,11 +237,11 @@ msgstr "Eliminato"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Confirmed"
msgstr ""
msgstr "Confermato"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Approval pending"
msgstr ""
msgstr "In attesa di approvazione"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Redeemed"
@@ -310,12 +308,13 @@ msgid "Order canceled"
msgstr "Ordine cancellato"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
#, fuzzy
msgid "Ticket code is ambiguous on list"
msgstr ""
msgstr "Il codice del biglietto è ambiguo sulla lista"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Order not approved"
msgstr ""
msgstr "Ordine non approvato"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Checked-in Tickets"
@@ -430,11 +429,11 @@ msgstr "Usa i tasti Ctrl-C per copiare!"
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
msgid "Edit"
msgstr ""
msgstr "Modifica"
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
msgid "Visualize"
msgstr ""
msgstr "Visualizza"
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
msgid ""
@@ -442,10 +441,13 @@ msgid ""
"or variations are not contained in any of your rule parts so people with "
"these tickets will not get in:"
msgstr ""
"La tua regola filtra sempre per prodotto o variation, ma i seguenti prodotti "
"o variation non sono presenti in nessuna parte delle tue regole. Questo "
"impedirà alle persone con questi biglietti di poter accedere all'evento:"
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
msgid "Please double-check if this was intentional."
msgstr ""
msgstr "Per favore, controlla se è stato intenzionale."
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "All of the conditions below (AND)"
@@ -489,17 +491,17 @@ msgstr "minuti"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "Duplicate"
msgstr ""
msgstr "Duplicato"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgctxt "entry_status"
msgid "present"
msgstr ""
msgstr "Presente"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgctxt "entry_status"
msgid "absent"
msgstr ""
msgstr "Assente"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "is one of"
@@ -515,7 +517,7 @@ msgstr "è dopo"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "="
msgstr ""
msgstr "="
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "Product"
@@ -535,63 +537,55 @@ msgstr "Data e orario corrente"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "Current day of the week (1 = Monday, 7 = Sunday)"
msgstr ""
msgstr "Giorno corrente della settimana (1 = Lunedì, 7 = Domenica)"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "Current entry status"
msgstr ""
msgstr "Stato dell'ingresso corrente"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "Number of previous entries"
msgstr "Numero di inserimenti precedenti"
msgstr "Numero di precedenti ingressi"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "Number of previous entries since midnight"
msgstr "Numero di inserimenti precedenti fino a mezzanotte"
msgstr "Numero di precedenti ingressi fino a mezzanotte"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
#, fuzzy
#| msgid "Number of previous entries"
msgid "Number of previous entries since"
msgstr "Numero di inserimenti precedenti"
msgstr "Numero di precedenti ingressi dal"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
#, fuzzy
#| msgid "Number of previous entries"
msgid "Number of previous entries before"
msgstr "Numero di inserimenti precedenti"
msgstr "Numero di precedenti ingressi prima del"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "Number of days with a previous entry"
msgstr "Nunmero di giorni con un inserimento precedente"
msgstr "Numero di giorni con un precedente ingresso"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
#, fuzzy
#| msgid "Number of days with a previous entry"
msgid "Number of days with a previous entry since"
msgstr "Nunmero di giorni con un inserimento precedente"
msgstr "Numero di giorni con un precedente ingresso dal"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
#, fuzzy
#| msgid "Number of days with a previous entry"
msgid "Number of days with a previous entry before"
msgstr "Nunmero di giorni con un inserimento precedente"
msgstr "Numero di giorni con un precedente ingresso prima del"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "Minutes since last entry (-1 on first entry)"
msgstr ""
msgstr "Minuti dall'ultimo ingresso (-1 al primo ingresso)"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "Minutes since first entry (-1 on first entry)"
msgstr ""
msgstr "Minuti dal primo ingresso (-1 al primo ingresso)"
#: pretix/static/pretixcontrol/js/ui/checkinrules/django-interop.ts
msgid "Error: Product not found!"
msgstr ""
msgstr "Errore: Prodotto non trovato!"
#: pretix/static/pretixcontrol/js/ui/checkinrules/django-interop.ts
msgid "Error: Variation not found!"
msgstr ""
msgstr "Errore: Variation non trovata!"
#: pretix/static/pretixcontrol/js/ui/editor.js
msgid "Check-in QR"
@@ -606,16 +600,12 @@ msgid "Group of objects"
msgstr "Gruppo di oggetti"
#: pretix/static/pretixcontrol/js/ui/editor.js
#, fuzzy
#| msgid "Text object"
msgid "Text object (deprecated)"
msgstr "Oggetto testo"
msgstr "Oggetto testo (deprecato)"
#: pretix/static/pretixcontrol/js/ui/editor.js
#, fuzzy
#| msgid "Text object"
msgid "Text box"
msgstr "Oggetto testo"
msgstr "Riquadro testo"
#: pretix/static/pretixcontrol/js/ui/editor.js
msgid "Barcode area"
@@ -662,25 +652,24 @@ msgid "Unknown error."
msgstr "Errore sconosciuto."
#: pretix/static/pretixcontrol/js/ui/main.js
#, fuzzy
#| msgid "Your color has great contrast and is very easy to read!"
msgid "Your color has great contrast and will provide excellent accessibility."
msgstr "Il colore scelto ha un ottimo contrasto ed è molto leggibile!"
msgstr "Il colore scelto ha un ottimo contrasto ed è molto leggibile."
#: pretix/static/pretixcontrol/js/ui/main.js
#, fuzzy
#| msgid "Your color has decent contrast and is probably good-enough to read!"
msgid ""
"Your color has decent contrast and is sufficient for minimum accessibility "
"requirements."
msgstr ""
"Il colore scelto ha un buon contrasto e probabilmente è abbastanza leggibile!"
"Il colore scelto ha un buon contrasto ed è sufficiente per i requisiti di "
"accessibilità."
#: pretix/static/pretixcontrol/js/ui/main.js
msgid ""
"Your color has insufficient contrast to white. Accessibility of your site "
"will be impacted."
msgstr ""
"Il colore scelto ha un contrasto insufficiente rispetto al bianco. "
"L'accessibilità nel tuo sito sarà ridotta."
#: pretix/static/pretixcontrol/js/ui/main.js
msgid "Search query"
@@ -700,11 +689,11 @@ msgstr "Solo i selezionati"
#: pretix/static/pretixcontrol/js/ui/main.js
msgid "Enter page number between 1 and %(max)s."
msgstr ""
msgstr "Inserisci il numero di pagina tra 1 e %(max)s."
#: pretix/static/pretixcontrol/js/ui/main.js
msgid "Invalid page number."
msgstr ""
msgstr "Numero di pagina invalido."
#: pretix/static/pretixcontrol/js/ui/main.js
msgid "Use a different name internally"
@@ -723,10 +712,8 @@ msgid "Calculating default price…"
msgstr "Calcolando il prezzo di default…"
#: pretix/static/pretixcontrol/js/ui/plugins.js
#, fuzzy
#| msgid "Search results"
msgid "No results"
msgstr "Risultati ricerca"
msgstr "Nessun risultato"
#: pretix/static/pretixcontrol/js/ui/question.js
msgid "Others"
@@ -756,39 +743,33 @@ msgstr "Carrello scaduto"
#: pretix/static/pretixpresale/js/ui/cart.js
msgid "Your cart is about to expire."
msgstr ""
msgstr "La selezione nel tuo carrello sta per scadere."
#: pretix/static/pretixpresale/js/ui/cart.js
msgid "The items in your cart are reserved for you for one minute."
msgid_plural "The items in your cart are reserved for you for {num} minutes."
msgstr[0] "Gli elementi nel tuo carrello sono riservati per 1 minuto."
msgstr[0] "Gli elementi nel tuo carrello sono riservati per un minuto."
msgstr[1] "Gli elementi nel tuo carrello sono riservati per {num} minuti."
#: pretix/static/pretixpresale/js/ui/cart.js
#, fuzzy
#| msgid "Cart expired"
msgid "Your cart has expired."
msgstr "Carrello scaduto"
msgstr "Il tuo carrello è scaduto."
#: pretix/static/pretixpresale/js/ui/cart.js
#, fuzzy
#| msgid ""
#| "The items in your cart are no longer reserved for you. You can still "
#| "complete your order as long as theyre available."
msgid ""
"The items in your cart are no longer reserved for you. You can still "
"complete your order as long as they're available."
msgstr ""
"Gli articoli nel tuo carrello non sono più riservati per te. Puoi ancora "
"Gli articoli nel tuo carrello non sono più riservati a te. Puoi ancora "
"completare il tuo ordine finché sono disponibili."
#: pretix/static/pretixpresale/js/ui/cart.js
msgid "Do you want to renew the reservation period?"
msgstr ""
msgstr "Vuoi rinnovare la scadenza del tuo carrello?"
#: pretix/static/pretixpresale/js/ui/cart.js
msgid "Renew reservation"
msgstr ""
msgstr "Rinnova scadenza"
#: pretix/static/pretixpresale/js/ui/main.js
msgid "The organizer keeps %(currency)s %(amount)s"
@@ -807,69 +788,64 @@ msgid "Your local time:"
msgstr "Ora locale:"
#: pretix/static/pretixpresale/js/walletdetection.js
#, fuzzy
#| msgid "Apple Pay"
msgid "Google Pay"
msgstr "Apple Pay"
msgstr "Google Pay"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Quantity"
msgstr ""
msgstr "Quantità"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Decrease quantity"
msgstr ""
msgstr "Diminuisci quantità"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Increase quantity"
msgstr ""
msgstr "Aumenta quantità"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Filter events by"
msgstr ""
msgstr "Filtra eventi per"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Filter"
msgstr ""
msgstr "Filtra"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Price"
msgstr ""
msgstr "Prezzo"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, javascript-format
msgctxt "widget"
msgid "Original price: %s"
msgstr ""
msgstr "Prezzo originale: %s"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, javascript-format
msgctxt "widget"
msgid "New price: %s"
msgstr ""
msgstr "Nuovo prezzo: %s"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, fuzzy
#| msgctxt "widget"
#| msgid "Select %s"
msgctxt "widget"
msgid "Select"
msgstr "Seleziona %s"
msgstr "Seleziona"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
@@ -926,7 +902,7 @@ msgstr "da %(currency)s %(price)s"
#, javascript-format
msgctxt "widget"
msgid "Image of %s"
msgstr ""
msgstr "Immagine di %s"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
@@ -967,27 +943,21 @@ msgstr "Disponibile solo con voucher"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, fuzzy
#| msgctxt "widget"
#| msgid "currently available: %s"
msgctxt "widget"
msgid "Not yet available"
msgstr "attualmente disponibile: %s"
msgstr "Ancora non disponibile"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "Not available anymore"
msgstr ""
msgstr "Non più disponibile"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, fuzzy
#| msgctxt "widget"
#| msgid "currently available: %s"
msgctxt "widget"
msgid "Currently not available"
msgstr "attualmente disponibile: %s"
msgstr "Attualmente non disponibile"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
@@ -1095,18 +1065,17 @@ msgstr "Chiudi"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, fuzzy
#| msgctxt "widget"
#| msgid "Resume checkout"
msgctxt "widget"
msgid "Close checkout"
msgstr "Ricarica checkout"
msgstr "Finisci checkout"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgctxt "widget"
msgid "You cannot cancel this operation. Please wait for loading to finish."
msgstr ""
"Non puoi annullare questa operazione. Per favore, aspetta che il caricamento "
"finisca."
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
@@ -1116,21 +1085,15 @@ msgstr "Continua"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, fuzzy
#| msgctxt "widget"
#| msgid "Select variant %s"
msgctxt "widget"
msgid "Show variants"
msgstr "Seleziona variante %s"
msgstr "Mostra varianti"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
#, fuzzy
#| msgctxt "widget"
#| msgid "Select variant %s"
msgctxt "widget"
msgid "Hide variants"
msgstr "Seleziona variante %s"
msgstr "Nascondi varianti"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
@@ -1236,37 +1199,37 @@ msgstr "Do"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Monday"
msgstr ""
msgstr "Lunedì"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Tuesday"
msgstr ""
msgstr "Martedì"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Wednesday"
msgstr ""
msgstr "Mercoledì"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Thursday"
msgstr ""
msgstr "Giovedì"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Friday"
msgstr ""
msgstr "Venerdì"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Saturday"
msgstr ""
msgstr "Sabato"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Sunday"
msgstr ""
msgstr "Domenica"
#: pretix/static/pretixpresale/js/widget/widget.js
#: pretix/static/pretixpresale/widget/src/i18n.ts
+19 -2
View File
@@ -23,7 +23,7 @@ import json
import logging
import urllib.parse
from collections import OrderedDict
from datetime import timedelta
from datetime import datetime, timedelta
from decimal import Decimal
from django import forms
@@ -645,7 +645,7 @@ class PaypalMethod(BasePaymentProvider):
def _execute_payment(self, request: HttpRequest, payment: OrderPayment):
payment = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=payment.pk)
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
logger.warning('payment is already confirmed; possible return-view/webhook race-condition')
# payment is already confirmed; possible return-view/webhook race-condition
return
try:
@@ -677,6 +677,8 @@ class PaypalMethod(BasePaymentProvider):
raise PaymentException(_('We had trouble communicating with PayPal'))
else:
pp_captured_order = response.result
payment.info = json.dumps(pp_captured_order.dict())
payment.save()
try:
ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=pp_captured_order.id)
@@ -830,6 +832,7 @@ class PaypalMethod(BasePaymentProvider):
payment.info = json.dumps(pp_captured_order.dict())
payment.save(update_fields=['info'])
payment.confirm()
self.log_payment_duration(payment)
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
# Payment has not any captures yet - so it's probably in created status
@@ -839,6 +842,20 @@ class PaypalMethod(BasePaymentProvider):
if 'payment_paypal_oid' in request.session:
del request.session['payment_paypal_oid']
@staticmethod
def log_payment_duration(payment: OrderPayment):
try:
capture = payment.info_data["purchase_units"][0]["payments"]["captures"][0]
create_time: str | None = capture["create_time"]
update_time: str | None = capture["update_time"]
except (KeyError, IndexError, TypeError):
create_time = None
update_time = None
if create_time is not None and update_time is not None:
duration = datetime.fromisoformat(update_time) - datetime.fromisoformat(create_time)
logger.info('{}: {} - paypal payment processing time'.format(str(payment.global_id), str(duration)))
def payment_pending_render(self, request, payment) -> str:
retry = True
try:
+7 -5
View File
@@ -357,14 +357,13 @@ def webhook(request, *args, **kwargs):
if 'resource_type' not in event_json:
return HttpResponse("Invalid body, no resource_type given", status=400)
if event_json['resource_type'] not in ["checkout-order", "refund", "capture"]:
return HttpResponse("Not interested in this resource type", status=200)
# Retrieve the Charge ID of the refunded payment
if event_json['resource_type'] == 'refund':
if event_json['resource_type'] == 'checkout-order':
payloadid = event_json['resource']['id']
elif event_json['resource_type'] == 'refund' or event_json['resource_type'] == 'capture':
payloadid = get_link(event_json['resource']['links'], 'up')['href'].split('/')[-1]
else:
payloadid = event_json['resource']['id']
return HttpResponse("Not interested in this resource type", status=200)
refs = [payloadid]
if event_json['resource'].get('supplementary_data', {}).get('related_ids', {}).get('order_id'):
@@ -424,6 +423,8 @@ def webhook(request, *args, **kwargs):
**event_json,
'_order_state': sale.dict(),
})
payment.info = json.dumps(sale.dict())
payment.save()
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['status'] in ('PARTIALLY_REFUNDED', 'REFUNDED', 'COMPLETED'):
if event_json['resource_type'] == 'refund':
@@ -489,6 +490,7 @@ def webhook(request, *args, **kwargs):
payment.info = json.dumps(sale.dict())
payment.save(update_fields=['info'])
payment.confirm()
prov.log_payment_duration(payment)
except Quota.QuotaExceededException:
pass
elif sale['status'] == 'APPROVED':
+4
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
from django.core.exceptions import ValidationError
from django.db import transaction
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
@@ -118,6 +119,7 @@ class RuleViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return Rule.objects.filter(event=self.request.event)
@transaction.atomic()
def perform_create(self, serializer):
super().perform_create(serializer)
serializer.instance.log_action(
@@ -128,6 +130,7 @@ class RuleViewSet(viewsets.ModelViewSet):
)
@transaction.atomic()
def perform_update(self, serializer):
super().perform_update(serializer)
serializer.instance.log_action(
@@ -137,6 +140,7 @@ class RuleViewSet(viewsets.ModelViewSet):
data=self.request.data
)
@transaction.atomic()
def perform_destroy(self, instance):
instance.log_action(
'pretix.plugins.sendmail.rule.deleted',
+21
View File
@@ -0,0 +1,21 @@
#
# 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/>.
#
+127
View File
@@ -0,0 +1,127 @@
from rest_framework import viewsets
from django.db import transaction
from .styles import AVAILABLE_STYLES_DICT, AVAILABLE_PLATFORMS
from .models import WalletLayout, WalletPlatformLayout
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
import os
class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
platform = serializers.ChoiceField(
choices=[p.identifier for p in AVAILABLE_PLATFORMS]
)
style = serializers.CharField(allow_null=True, required=False)
file_settings = serializers.JSONField(default=dict, required=False)
class Meta:
model = WalletPlatformLayout
fields = ("platform", "style", "layout", "file_settings")
def validate_layout(self, value):
if not isinstance(value, dict):
raise ValidationError(_("Layout must be a dict"))
return value
def validate(self, data):
platform = data.get("platform")
style = data.get("style")
layout = data.get("layout")
if platform and style and layout:
platform_styles = AVAILABLE_STYLES_DICT[platform]
if data["style"] not in platform_styles:
raise ValidationError(_("Invalid style"))
style = platform_styles[data["style"]]
style = style(event=self.context["event"], layout=data["layout"])
style.validate()
data["file_settings"] = style.extract_file_settings(
self.context["request"], data.get("file_settings", {})
)
return data
def to_representation(self, instance):
ret = super().to_representation(instance)
ret["file_settings"] = {}
for file_setting in instance.file_settings.all():
try:
url = file_setting.file.url
except AttributeError:
continue
request = self.context["request"]
ret["file_settings"][file_setting.key] = {
"url": request.build_absolute_uri(url),
"name": os.path.basename(file_setting.file.name).split('.', 1)[-1]
}
return ret
class WalletLayoutSerializer(I18nAwareModelSerializer):
platform_layouts = WalletPlatformLayoutSerializer(many=True)
class Meta:
model = WalletLayout
fields = ("id", "name", "platform_layouts")
read_only_fields = ("id",)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def save(self, *args, **kwargs):
super().save(*args, **kwargs, event=self.context["event"])
def update(self, instance, validated_data):
platform_layouts = validated_data.pop("platform_layouts")
for layout in platform_layouts:
if layout["style"]:
file_settings = layout.pop("file_settings", {})
obj, _ = instance.platform_layouts.update_or_create(
platform=layout["platform"], defaults=layout
)
for key, file in file_settings.items():
if not file:
obj.file_settings.filter(key=key).delete()
elif file != "keep":
obj, _ = obj.file_settings.get_or_create(
key=key
)
obj.file.save(os.path.basename(file.name), file)
instance.platform_layouts.exclude(
platform__in={
layout["platform"]
for layout in platform_layouts
if layout["style"] is not None
}
).delete()
return super().update(instance, validated_data)
class WalletLayoutViewSet(viewsets.ModelViewSet):
model = WalletLayout
queryset = WalletLayout.objects.none()
serializer_class = WalletLayoutSerializer
permission = "event.settings.general:write"
def get_queryset(self):
return self.request.event.wallet_layouts.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx["event"] = self.request.event
return ctx
@transaction.atomic()
def perform_update(self, serializer):
super().perform_update(serializer)
serializer.instance.log_action(
action="pretix.plugins.wallet.layout.changed",
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
+41
View File
@@ -0,0 +1,41 @@
#
# 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.apps import AppConfig
from django.utils.translation import gettext_lazy as _
from pretix import __version__ as version
class WalletApp(AppConfig):
name = 'pretix.plugins.wallet'
verbose_name = _("wallet")
class PretixPluginMeta:
name = _("wallet")
author = _("the pretix team")
version = version
category = 'FORMAT'
description = _("Issue wallet passes for tickets (e.g. apple wallet, google wallet)")
def ready(self):
from . import receivers # NOQA
+101
View File
@@ -0,0 +1,101 @@
import logging
import re
import subprocess
import tempfile
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.utils.translation import gettext_lazy as _
from pretix.control.forms import ClearableBasenameFileInput
from django.core.files import File
logger = logging.getLogger(__name__)
def validate_rsa_privkey(value: File):
value = value.read().strip()
if isinstance(value, bytes):
value = value.decode()
if not value:
return
if not re.match(r"^-----BEGIN( (RSA |ENCRYPTED )?PRIVATE KEY-----).*-----END\1$", value, re.DOTALL):
raise ValidationError(
_(
"This does not look like an RSA private key in PEM format (it misses the correct begin or end signifiers)"
),
)
class CertificateFileField(forms.FileField):
widget = ClearableBasenameFileInput
def clean(self, value, *args, **kwargs):
value = super().clean(value, *args, **kwargs)
if isinstance(value, UploadedFile):
value.open("rb")
value.seek(0)
content = value.read()
if (
content.startswith(b"-----BEGIN CERTIFICATE-----")
and b"-----BEGIN CERTIFICATE-----" in content
):
return SimpleUploadedFile("cert.pem", content, "text/plain")
openssl_cmd = [
"openssl",
"x509",
"-inform",
"DER",
"-outform",
"PEM",
]
process = subprocess.Popen(
openssl_cmd,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
)
process.stdin.write(content)
pem, error = process.communicate()
if process.returncode != 0:
logger.info("Trying to convert a DER to PEM failed: {}".format(error))
raise ValidationError(
_(
"This does not look like a X509 certificate in either PEM or DER format"
),
)
return SimpleUploadedFile("cert.pem", pem, "text/plain")
return value
class PNGImageField(forms.FileField):
widget = ClearableBasenameFileInput
def clean(self, value, *args, **kwargs):
value = super().clean(value, *args, **kwargs)
if isinstance(value, UploadedFile):
try:
from PIL import Image
except ImportError:
return value
value.open("rb")
value.seek(0)
try:
with (
Image.open(value, formats=settings.PILLOW_FORMATS_IMAGE) as im,
tempfile.NamedTemporaryFile("rb", suffix=".png") as tmpfile,
):
im.save(tmpfile.name)
tmpfile.seek(0)
return SimpleUploadedFile(
"picture.png", tmpfile.read(), "image png"
)
except IOError:
logger.exception("Could not convert image to PNG.")
raise ValidationError(
_("The file you uploaded could not be converted to PNG format.")
)
return value
@@ -0,0 +1,98 @@
# Generated by Django 5.2.13 on 2026-05-19 15:39
import django.db.models.deletion
import pretix.base.models.base
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("pretixbase", "0297_outgoingmail"),
]
operations = [
migrations.CreateModel(
name="WalletLayout",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("name", models.CharField(max_length=190)),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="wallet_layouts",
to="pretixbase.event",
),
),
],
options={
"abstract": False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name="WalletLayoutItem",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
(
"item",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="walletlayout_assignments",
to="pretixbase.item",
),
),
(
"layout",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="item_assignments",
to="wallet.walletlayout",
),
),
],
options={
"unique_together": {("item", "layout")},
},
),
migrations.CreateModel(
name="WalletPlatformLayout",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("platform", models.CharField(max_length=10)),
("style", models.CharField(max_length=255)),
("layout", models.JSONField(default=dict)),
(
"parent",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="platform_layouts",
to="wallet.walletlayout",
),
),
],
options={
"unique_together": {("parent", "platform")},
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
]
@@ -0,0 +1,41 @@
# Generated by Django 5.2.13 on 2026-06-09 18:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("wallet", "0001_initial"),
]
operations = [
migrations.AlterUniqueTogether(
name="walletlayoutitem",
unique_together=set(),
),
migrations.AddField(
model_name="walletlayout",
name="default",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="walletlayoutitem",
name="item",
field=models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="walletlayout",
to="pretixbase.item",
),
),
migrations.AddConstraint(
model_name="walletlayout",
constraint=models.UniqueConstraint(
models.F("event"),
condition=models.Q(("default", True)),
name="one_default_wallet_per_event",
),
),
]
@@ -0,0 +1,35 @@
# Generated by Django 5.2.13 on 2026-08-11 15:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("wallet", "0002_alter_walletlayoutitem_unique_together_and_more"),
]
operations = [
migrations.CreateModel(
name="WalletLayoutFileSetting",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("key", models.CharField()),
("file", models.FileField(upload_to="")),
(
"layout",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="file_settings",
to="wallet.walletplatformlayout",
),
),
],
),
]
+98
View File
@@ -0,0 +1,98 @@
#
# 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.db import models
from django.db.models import constraints, Q
from django.utils.translation import gettext_lazy as _
from pretix.base.models import LoggedModel, OrderPosition
from django_scopes import ScopedManager
from django.core.exceptions import ValidationError
class WalletLayout(LoggedModel):
event = models.ForeignKey(
'pretixbase.Event',
on_delete=models.CASCADE,
related_name='wallet_layouts'
)
name = models.CharField(
max_length=190,
verbose_name=_('Name')
)
default = models.BooleanField(
verbose_name=_('Default'),
default=False,
)
objects = ScopedManager(organizer='event__organizer')
class Meta:
constraints = [
constraints.UniqueConstraint("event", condition=Q(default=True), name="one_default_wallet_per_event")
]
class WalletPlatformLayout(LoggedModel):
parent = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name="platform_layouts")
platform = models.CharField(max_length=10)
style = models.CharField(max_length=255)
layout = models.JSONField(default=dict)
objects = ScopedManager(organizer='parent__event__organizer')
class Meta:
unique_together = (('parent', 'platform'),)
@property
def pass_layout(self):
from pretix.plugins.wallet.styles import get_style
style = get_style(self.platform, self.style)
if style:
file_settings = dict(self.file_settings.values_list("key", "file"))
print(file_settings)
return style(event=self.parent.event, layout=self.layout, file_settings=file_settings)
else:
raise RuntimeError(f"Style {self.platform}.{self.style} not found")
class WalletLayoutItem(models.Model):
item = models.OneToOneField('pretixbase.Item', null=True, blank=True, related_name='walletlayout',
on_delete=models.CASCADE)
layout = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name='item_assignments')
def clean(self):
if self.item.event != self.layout.event:
raise ValidationError("cannot bind layout to item of different event")
class WalletLayoutFileSetting(models.Model):
layout = models.ForeignKey(WalletPlatformLayout, on_delete=models.CASCADE, related_name="file_settings")
key = models.CharField()
file = models.FileField()
# smth like this for apple, lets see what the best architecture for google will be
# class AppleWalletPass(models.Model):
# platform_layout = models.ForeignKey(WalletPlatformLayout, on_delete=models.PROTECT)
# order_position = models.ForeignKey(OrderPosition, on_delete=models.PROTECT)
# content = models.BinaryField()
# updated_at = models.DateTimeField(null=True, auto_now=True)
+280
View File
@@ -0,0 +1,280 @@
from collections.abc import Callable
from .signals import (
register_wallet_text_placeholders,
register_wallet_image_placeholders,
)
from django.core.files import File
from django.dispatch import receiver
from pretix.base.templatetags.money import money_filter
from django.contrib.staticfiles import finders
from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _
from i18nfield.strings import LazyI18nString
class BaseWalletPlaceholder:
"""
This is the base class for all wallet placeholders.
"""
@property
def required_context(self) -> set[str]:
"""
A a set of all attribute names that need to be contained in the base context so that this placeholder is available.
"""
return set()
@property
def identifier(self) -> str:
"""The unique identifier of this placeholder"""
raise NotImplementedError()
@property
def label(self) -> LazyI18nString:
"""The human readable name of this placeholder"""
raise NotImplementedError()
@property
def control_label(self) -> LazyI18nString:
"""
The human readable name of this placeholder shown in the backend.
Defaults to `label`
"""
return self.label
def render(self, **context):
raise NotImplementedError()
def render_sample(self, **context):
raise NotImplementedError()
class BaseWalletTextPlaceholder(BaseWalletPlaceholder):
def render(self, **context) -> str | None:
"""
This method is called to generate the text that is being shown on the pass.
You will be passed the keyword arguments specified in ``required_context``.
You are expected to return a plain-text string.
"""
raise NotImplementedError()
def render_sample(self, **context) -> str:
"""
This method is called to generate a text to be used in previews.
You will be passed sample instances of the arguments specified in ``required_context``.
If those instances contain all data needed, you do not need to implement this.
"""
sample = self.render(**context)
if sample is None:
raise RuntimeError("`render` returned None when rendering a sample")
return sample
class BaseWalletImagePlaceholder(BaseWalletPlaceholder):
def render(self, **context) -> File | None:
"""
This method is called to generate the image that is being shown on the pass.
You will be passed the keyword arguments specified in ``required_context``.
You are expected to return a `File` object.
"""
raise NotImplementedError()
def render_sample(self, **context) -> str | None:
"""
This method is called to generate a text to be used in previews.
You will be passed sample instances of the arguments specified in ``required_context``.
You are expected to return a URL to a sample image or `None` if no sample can be shown.
"""
return None
class FunctionalWalletTextPlaceholder(BaseWalletTextPlaceholder):
def __init__(
self,
identifier: str,
label: LazyI18nString,
args: set[str],
func: Callable[..., str | None],
sample: None | str | Callable[..., str] = None,
):
self._identifier = identifier
self._label = label
self._args = args
self.render = func
self._sample = sample
@property
def identifier(self):
return self._identifier
@property
def label(self):
return self._label
@property
def required_context(self) -> set[str]:
return self._args
def render_sample(self, **context) -> str:
if isinstance(self._sample, Callable):
return self._sample(**context)
elif self._sample:
return self._sample
else:
return super().render_sample(**context)
class FunctionalWalletImagePlaceholder(BaseWalletImagePlaceholder):
def __init__(
self,
identifier: str,
label: LazyI18nString,
args: set[str],
func: Callable[..., File | None],
sample: None | str | Callable[..., str] = None,
):
self._identifier = identifier
self._label = label
self._args = args
self.render = func
self._sample = sample
@property
def required_context(self) -> set[str]:
return self._args
@property
def identifier(self):
return self._identifier
@property
def label(self):
return self._label
def render_sample(self, **context) -> str | None:
if isinstance(self._sample, Callable):
return self._sample(**context)
return self._sample
class MissingContextException(Exception):
pass
class WalletPlaceholderContext:
def __init__(self, **kwargs):
self.context_args = kwargs
self.cache = {}
def _get_placeholder_context(self, placeholder: BaseWalletPlaceholder):
missing_context = placeholder.required_context - self.context_args.keys()
if missing_context:
raise MissingContextException(
f"Missing context args for '{placeholder.identifier}': {', '.join(missing_context)}"
)
return {
k: v for k, v in self.context_args.items() if k in placeholder.required_context
}
@classmethod
def is_available(cls, placeholder: BaseWalletPlaceholder, context_args: set[str]):
missing_context = placeholder.required_context - context_args
return not missing_context
def render_placeholder(self, placeholder: BaseWalletPlaceholder):
if placeholder.identifier in self.cache:
return self.cache[placeholder.identifier]
value = self.cache[placeholder.identifier] = placeholder.render(**self._get_placeholder_context(placeholder))
return value
def render_sample(self, placeholder: BaseWalletPlaceholder):
return placeholder.render_sample(**self._get_placeholder_context(placeholder))
def get_wallet_placeholders(event) -> dict[str, dict[str, BaseWalletPlaceholder]]:
placeholders = {
"text": {
v.identifier: v
for r, vs in register_wallet_text_placeholders.send(sender=event)
for v in vs
},
"image": {
v.identifier: v
for r, vs in register_wallet_image_placeholders.send(sender=event)
for v in vs
},
}
return placeholders
def get_static_file(name) -> File | None:
path: str | None = finders.find(name) # type: ignore
if not path:
return
return File(open(path, "rb"))
@receiver(
register_wallet_text_placeholders,
dispatch_uid="plugin_wallet_register_wallet_text_placeholders",
)
def base_text_placeholders(sender, **kwargs):
return [
FunctionalWalletTextPlaceholder("name", LazyI18nString.from_gettext("Event Name"), {"event"}, lambda event: event.name),
FunctionalWalletTextPlaceholder(
"event_slug", LazyI18nString.from_gettext("Event Slug"), {"event"}, lambda event: event.slug
),
FunctionalWalletTextPlaceholder("order", LazyI18nString.from_gettext("Order Code"), {"order"}, lambda order: order.code),
FunctionalWalletTextPlaceholder(
"total",
LazyI18nString.from_gettext("Order Total"),
{"event", "order"},
lambda event, order: money_filter(order.total, event.currency),
),
FunctionalWalletTextPlaceholder(
"order_email",LazyI18nString.from_gettext("Order Email"), {"order"}, lambda order: order.email
),
FunctionalWalletTextPlaceholder(
"price",LazyI18nString.from_gettext("Item Price"), {"event", "order_position"}, lambda event, order_position: money_filter(order_position.price, event.currency)
),
FunctionalWalletTextPlaceholder(
"secret",LazyI18nString.from_gettext("Order Secret (QR-Code-Content)"), {"order_position"}, lambda order_position: order_position.secret
),
]
@receiver(
register_wallet_image_placeholders,
dispatch_uid="plugin_wallet_register_wallet_image_placeholders",
)
def base_image_placeholders(sender, **kwargs):
return [
FunctionalWalletImagePlaceholder(
"poweredby",
LazyI18nString.from_gettext("Logo"),
set(),
# TODO: replace with paths not from another plugin
lambda: get_static_file("pretix_passbook/logo.png"),
static("pretix_passbook/logo.png"),
),
FunctionalWalletImagePlaceholder(
"poweredby_icon",
LazyI18nString.from_gettext("Icon"),
set(),
lambda: get_static_file("pretix_passbook/icon.png"),
static("pretix_passbook/icon.png"),
),
FunctionalWalletImagePlaceholder(
"example_no_preview",
LazyI18nString.from_gettext("Image with no preview"),
set(),
lambda: get_static_file("pretix_passbook/icon.png"),
),
# TODO: Image upload
]
+37
View File
@@ -0,0 +1,37 @@
#
# 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 pretix.base.signals import register_ticket_outputs, register_global_settings, EventPluginSignal
from .ticketoutput import OUTPUTS
def connect_signals():
for output in OUTPUTS:
# DIY functools.partial to make get_defining_app happy
def get_register_func(o):
def register(sender, **kwargs):
return o
return register
register_ticket_outputs.connect(get_register_func(output), dispatch_uid=f"wallet_output_{output.identifier}")
if hasattr(output, "get_global_settings"):
register_global_settings.connect(output.get_global_settings, dispatch_uid=f"wallet_global_settings_{output.identifier}")
connect_signals()
+39
View File
@@ -0,0 +1,39 @@
#
# 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 pretix.base.signals import EventPluginSignal
register_wallet_text_placeholders = EventPluginSignal()
"""
This signal is sent out to get all known wallet placeholders. Receivers should return
an list of subclasses of pretix.plugins.wallet.placeholders.BaseWalletTextPlaceholder.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_wallet_image_placeholders = EventPluginSignal()
"""
This signal is sent out to get all known wallet placeholders. Receivers should return
an list of subclasses of pretix.plugins.wallet.placeholders.BaseWalletImagePlaceholder.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { computed, inject, ref, watchEffect } from "vue";
import StyleSettings from "./style-settings.vue";
import Select from "./input/select.vue";
import Input from "./input/input.vue";
import PassPreview from "./preview/pass-preview.vue";
import { StoreKey } from "../walletStore";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
function openForm(url: string, data: Record<string, string>) {
let form = document.createElement("form");
form.target = "_blank";
form.method = "POST";
form.action = url;
form.style.display = "none";
for (var key in data) {
var input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = data[key];
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
function openPreview(e: Event) {
e.preventDefault();
openForm("../../preview/", {
csrfmiddlewaretoken: store.csrfToken,
platform: store.currentPlatform,
style: store.layout.style,
layout: JSON.stringify(store.layout.layout),
});
}
const platformChoices = computed(() => {
return [
[null, "Do not generate pass"],
...Object.values(store.platform.styles).map((x) => [x.identifier, x.name]),
];
});
</script>
<template lang="pug">
// TODO: add :key for all `v-for`s
// TODO: i18n textfields
// TODO: proper spinner
template(v-if="!store.loaded") {{ gettext("Loading...") }}
form(v-else @submit.prevent="store.save")
.form-group
Input(label="Name" v-model="store.name")
nav
ul.nav.nav-tabs
li(v-for="platform in store.platforms" :class="{'active': store.platform.identifier === platform.identifier}")
a(role="tab" @click="store.setPlatform(platform.identifier)") {{ platform.name }}
.tabbed-form.tab-content
.tab-pane.active.row
.col-md-6.col-lg-8
Select.form-group(label="Style" :modelValue="store.style?.identifier || null" @update:modelValue="store.setStyle" :choices="platformChoices")
StyleSettings(v-if="!!store.style")
.col-md-6.col-lg-4
.panel.panel-default
.panel-heading Preview
.panel-body
div(v-if="!!store.style?.preview_layout")
span.text-muted {{ gettext("The preview below is only a rough representation of what the pass might look like. Please check the generated pass.") }}
div(style="display: grid; gap: 1em; grid-template-columns: repeat(auto-fit, minmax(auto, 360px));")
PassPreview(v-for="layout in store.style.preview_layout" :layout="layout")
div(v-else) Preview not supported
//- pre
//- code {{ store.currentPlatformLayout }}
//- pre(v-if="store.currentPlatformLayout.style")
//- code {{ store.currentPlatformStyles[store.currentPlatformLayout.style] }}
//- pre
//- code {{ store.walletLayout }}
.form-group.submit-group
button.btn.btn-lg.btn-default(type="button" @click="openPreview") Preview
button.btn.btn-primary.btn-save(type="submit") Submit
</template>
<style lang="css">
.walletsettings-panel .panel-heading {
.checkbox {
padding: 0;
margin: 0;
display: inline-block;
input[type="checkbox"] {
margin-top: 0;
margin-right: 5px;
margin-left: 0px;
position: relative;
top: 1px;
}
label {
padding-left: 0;
}
}
}
</style>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { useId } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label?: string
}>()
const modelValue = defineModel<boolean|null>();
const id = useId()
</script>
<template lang="pug">
.checkbox
label
input(:id="id" v-model="modelValue" v-bind="$attrs" type="checkbox")
| {{ props.label }}
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { ref, useId, watchEffect } from "vue";
const gettext = (window as any).gettext;
defineOptions({
inheritAttrs: false,
});
const emit = defineEmits<{change: [File]}>()
const props = defineProps<{
label?: I18nString;
errors?: string[];
help_text?: string;
filename?: string;
current_url?: string;
}>();
const id = useId();
function onChange(e) {
emit("change", (e.target as HTMLInputElement).files[0])
}
// Reset input field if a url is provided
const inputRef = ref<HTMLInputElement>();
watchEffect(() => {
if (props.current_url && inputRef.value?.value) {
inputRef.value.value = '';
}
})
</script>
<template lang="pug">
.form-group.row
label.control-label.col-md-3(:for="id", v-if="!!label") {{ label }}
br(v-if="!$attrs.required")
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
div.col-md-9
template(v-if="!!current_url")
| {{ gettext("Currently") + ': ' }}
a(:href="current_url") {{ filename }}
| {{ " " }}
button.btn.btn-sm(@click.prevent="() => {console.log('clear'); emit('change', null)}") {{ gettext("Clear") }}
//- br
//- a(:href="modelValue" data-lightbox="input")
//- img.thumb-img(:src="modelValue")
br
| {{ gettext("Change") + ': ' }}
input(:id="id" @change="onChange" v-bind="$attrs" type="file" style="display: inline" ref="inputRef")
.help-block(v-if="!!help_text") {{ help_text }}
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
</template>
<style lang="css" scoped>
.thumb-img {
max-height: 100px;
max-width: 200px;
object-fit: contain;
}
</style>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { inject, watchEffect } from 'vue'
import { StoreKey } from "../../walletStore";
const store = inject(StoreKey)!
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
errors?: string[],
}>();
const modelValue = defineModel<Record<string, string> | string>();
watchEffect(() => {
if (typeof modelValue.value === "string") {
const oldVal = modelValue.value;
modelValue.value = Object.fromEntries(Object.keys(store.locales).map((x): [string, string] => [x, oldVal]))
}
})
</script>
<template lang="pug">
input.form-control(v-for="(human_readable, locale) in store.locales" v-model="modelValue[locale]" v-bind="$attrs" :lang="locale" :title="human_readable" :placeholder="human_readable")
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { useId } from 'vue'
const gettext = (window as any).gettext;
defineOptions({
inheritAttrs: false
})
const {label, errors, type = "text"} = defineProps<{
label?: I18nString,
errors?: string[],
type?: string
help_text?: string,
}>()
const modelValue = defineModel<string|null>();
const id = useId()
</script>
<template lang="pug">
label.control-label(:for="id", v-if="label") {{ label }}
br(v-if="!$attrs.required")
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
div
input(:id="id" v-model="modelValue" v-bind="$attrs" :type="type" :class="{'form-control': type == 'text'}")
.help-block(v-if="!!help_text") {{ help_text }}
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { useId, watchEffect } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label?: string
choices: Array<[string, string]>
errors?: string[],
class?: string
}>()
const modelValue = defineModel<string|null>();
const id = useId()
watchEffect(() => {
if (props.choices.length === 1) {
modelValue.value = props.choices[0][0]
} else if (props.choices.length < 1) {
modelValue.value = null
}
})
</script>
<template lang="pug">
template(v-if="choices.length >= 1" :class="props.class")
label.control-label(v-if="props.label" :for="id") {{ props.label }}
select.form-control(:id="id" v-model="modelValue" v-bind="$attrs" required)
option(v-for="choice in props.choices" :key="choice[0]" :value="choice[0]") {{ choice[1] }}
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed, inject, watchEffect } from "vue";
import Select from "./input/select.vue";
import Checkbox from "./input/checkbox.vue";
import I18nInput from "./input/i18ninput.vue";
import TextContent from "./text-content.vue";
import { StoreKey } from "../walletStore";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
const props = defineProps<{
fieldgroup: PlaceholderFieldGroupDefinition;
overflows: FieldGroupDefinition[];
}>();
const fieldConfig = defineModel<PlaceholderFieldGroupConfig>({
required: true,
});
const overflowOptions = computed((): Array<[string | null, string]> => {
if (props.overflows.length) {
return [
...props.overflows.map((x): [string, string] => [x.identifier, x.name]),
[null, "Do not overflow"],
];
} else {
return [];
}
});
function addVariable() {
fieldConfig.value.entries.push({ type: "placeholder", label: "" });
}
watchEffect(() => {
if (!fieldConfig.value) {
fieldConfig.value = {
overflow: null,
entries: JSON.parse(JSON.stringify(props.fieldgroup.default_entries)),
active:
props.fieldgroup.required ||
props.fieldgroup.default_entries.length > 0,
};
}
if (fieldConfig.value && !fieldConfig.value.entries) {
fieldConfig.value.entries = JSON.parse(
JSON.stringify(props.fieldgroup.default_entries),
);
}
});
const placeholderChoices = computed(() => {
const availableContext = new Set(props.fieldgroup.context_args);
const choices = Object.entries(store.variables.text)
.filter(([_, { required_context }]) =>
new Set(required_context).isSubsetOf(availableContext),
)
.map(([k, v]): [string, string] => [k, v.label]);
choices.push(["other", gettext("Other…")]);
return choices;
});
</script>
<template lang="pug">
.panel.panel-default.walletsettings-panel
.panel-heading
h3.panel-title {{ fieldgroup.name }}
.panel-body(v-if="fieldConfig")
.form-group()
span.text-muted(v-if="fieldgroup.description") {{ fieldgroup.description }}
h4 {{ gettext("Content") }}
table.table.table-hover
thead
tr
th.col-md-5(v-if="fieldgroup.display == 'with_label'") {{ gettext('Label') }}
th(:class="'col-md-' + (fieldgroup.display == 'with_label' ? '6' : '11')") {{ gettext('Content') }}
th.col-xs-1
tbody
tr(v-for="n, i in fieldConfig.entries.length" :key="i")
td(v-if="fieldgroup.display == 'with_label'")
.i18n-form-group
I18nInput(v-model="fieldConfig.entries[n - 1].label")
td
TextContent(v-if='fieldgroup.content_type == "text"'
v-model="fieldConfig.entries[n - 1]"
:placeholderChoices="placeholderChoices")
Select(v-else-if='fieldgroup.content_type == "image"'
v-model="fieldConfig.entries[n - 1].content"
:choices="Object.entries(store.variables.image).map(([k, v]) => [k, v.label])"
)
td.text-right
button.btn.btn-danger.form-control-static(type="button" @click="fieldConfig.entries.splice(n - 1, 1)")
i.fa.fa-trash
span.sr-only {{ gettext('Delete') }}
button.btn.btn-default(type="button" @click="addVariable")
i.fa.fa-plus
| {{ gettext("Add field") }}
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow")
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import { watchEffect } from 'vue';
import Input from './input/input.vue';
import Checkbox from './input/checkbox.vue';
const gettext = (window as any).gettext;
const props = defineProps<{
fieldgroup: FieldGroupDefinition;
}>();
const fieldConfig = defineModel<PredefinedFieldGroupConfig>({ required: true });
watchEffect(() => {
if (!fieldConfig.value) {
fieldConfig.value = {active: props.fieldgroup.required};
}
});
</script>
<template lang="pug">
.panel.panel-default.walletsettings-panel
.panel-heading
h3.panel-title.form-inline
.form-group
Checkbox(v-if="!!fieldConfig" :label="fieldgroup.name" v-model="fieldConfig.active")
.panel-body(v-if="!!fieldgroup.description")
.form-group
span.text-muted {{ fieldgroup.description }}
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
import { i18nstringLocalize } from "../../helpers";
const store = inject(StoreKey)!;
const props = defineProps<{
label?: I18nString;
content: I18nString;
content_type: FieldContentType;
display: FieldGroupDisplay;
display_class?: string | string[];
}>();
</script>
<template lang="pug">
div.fieldgroup-item
div.fieldgroup-item-qrcode(v-if="display == 'code'") {{ label }}
template(v-else)
div.fieldgroup-label.nowrap(v-if="display == 'with_label'") {{ i18nstringLocalize(label) }}
div.nowrap.content(v-if="content_type == 'text'" :class="display_class") {{ i18nstringLocalize(content) }}
img.fieldgroup-item-image(v-else-if="content_type == 'image' && content" :src="i18nstringLocalize(content)")
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
import PlaceholderFieldgroupPreview from "./placeholder-fieldgroup-preview.vue";
import PredefinedFieldgroupPreview from "./predefined-fieldgroup-preview.vue";
const store = inject(StoreKey)!;
const props = defineProps<{
config: PreviewFieldgroup;
}>();
const style_def = computed(() => {
return Object.fromEntries(
store.styles[
store.layout.style
].fieldgroups.map((x) => [x.identifier, x]),
)[props.config.fieldgroup];
});
function focusGroup() {
// TODO: highlight group? or change ui concept completely
const elem = document.getElementById('fieldgroup-' + props.config.fieldgroup)
elem && elem.scrollIntoView()
}
</script>
<template lang="pug">
div(@click="focusGroup")
PlaceholderFieldgroupPreview(v-if="style_def && style_def.type == 'placeholder'" :config="config" :style_def="style_def")
PredefinedFieldgroupPreview(v-else-if="style_def && style_def.type == 'predefined'" :config="config" :style_def="style_def")
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { inject } from "vue";
import { StoreKey } from "../../walletStore.js";
import RowPreview from "./row-preview.vue";
const store = inject(StoreKey)!;
const props = defineProps<{ layout: Array<PreviewLayout> }>();
</script>
<template lang="pug">
div.pass-container
div.pass-content
RowPreview(v-for="row of layout" :config="row")
</template>
<style lang="css" scoped>
.pass-container {
width: 100%;
max-width: 360px;
vertical-align: top;
display: inline-block;
border: solid 1px #949494;
border-radius: 1em;
padding: 1em;
overflow: hidden
}
.pass-content {
overflow: scroll;
display: flex;
flex-direction: column;
gap: 1em;
}
</style>
<style lang="css">
.pass-container {
.fieldgroup-container {
min-width: 0;
display: flex;
gap: 0.5em;
min-height: 2lh;
}
.fieldgroup-label {
font-weight: bold;
font-size: 0.8em;
}
.fieldgroup-item {
flex: 0 1 100%;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
white-space: pre-wrap;
}
.fieldgroup-item-image {
max-height: 3lh;
object-fit: contain;
}
.fieldgroup-item-qrcode {
font-weight: bold;
background-color: lightgray;
width: 50%;
aspect-ratio: 1;
margin: auto;
/* center content */
display: flex;
align-items: center;
text-align: center;
justify-content: center;
padding: 1em;
overflow-wrap: anywhere;
}
.nowrap {
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
}
.bold {
font-weight: bold;
}
.large {
font-size: 1.5em;
}
.tight {
line-height: 1;
}
}
</style>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
const store = inject(StoreKey)!;
const props = defineProps<{
config: PreviewFieldgroup;
style_def: PlaceholderFieldGroupDefinition;
}>();
</script>
<template lang="pug">
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
FieldgroupItemPreview(v-for="{ label, content } of store.renderedFieldGroups[config.fieldgroup]" :label="label" :content="content" :content_type="style_def.content_type" :display_class="config.display" :display="style_def.display")
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
const store = inject(StoreKey)!;
const props = defineProps<{
style_def: PredefinedFieldGroupDefinition;
config: PredefinedFieldgroupPreview;
}>();
const isActive = computed(
() =>
store.layout.fieldgroups[props.style_def.identifier]?.active,
);
</script>
<template lang="pug">
div.fieldgroup-container(v-if="isActive" :style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
FieldgroupItemPreview(v-for="{ label, content } of config.sample" :label="label" :content="content" content_type="text" display="with_label")
</template>
@@ -0,0 +1,29 @@
<script setup lang="ts">
import FieldgroupItemPreview from './fieldgroup-item-preview.vue';
import FieldgroupPreview from './fieldgroup-preview.vue';
import SettingPreview from './setting-preview.vue';
const props = defineProps<{
config: PreviewRow;
}>();
</script>
<template lang="pug">
div.preview-row(v-if="'children' in config" :style="{ flexDirection: config.direction || 'row' }" :class="config.display")
RowPreview(v-for="child of config.children" :config="child" )
FieldgroupPreview(v-else-if="'fieldgroup' in config" :config="config")
FieldgroupItemPreview(v-else-if="'value' in config" :label="config.label" :content="config.value" content_type="text" :display="!!config.label ? 'with_label' : 'plain'" :display_class="config.display")
SettingPreview(v-else-if="'setting' in config" :config="config")
</template>
<style lang="css" scoped>
.preview-row {
width: 100%;
display: flex;
gap: 1em;
display: flex
}
.tight {
gap: 0;
}
</style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
import PredefinedFieldgroupPreview from "./predefined-fieldgroup-preview.vue";
const store = inject(StoreKey)!;
const { config } = defineProps<{
config: SettingPreview;
}>();
const settingDef = computed(() => {
return Object.fromEntries(
store.styles[store.layout.style].settings.map(
(x) => [x.identifier, x],
),
)[config.setting];
});
const settingValue = computed(() => {
const val = store.settings[config.setting];
if (settingDef.value.type == "image" && val instanceof File) {
return URL.createObjectURL(val);
} else {
return val;
}
});
</script>
<template lang="pug">
//- pre
//- code {{ settingDef }}
//- pre
//- code {{ config }}
//- pre
//- code {{ settingValue }}
//- PlaceholderFieldgroupPreview(v-if="style_def && style_def.type == 'placeholder'" :config="config" :style_def="style_def")
//- PredefinedFieldgroupPreview(v-else-if="style_def && style_def.type == 'predefined'" :config="config" :style_def="style_def")
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
FieldgroupItemPreview(:content="settingValue" :content_type="settingDef.type" :display_class="config.display" display="plain")
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import Input from "./input/input.vue";
import FileInput from "./input/file-input.vue";
import { inject } from "vue";
import { StoreKey } from "../walletStore";
const props = defineProps<{
field?: Setting;
}>();
const store = inject(StoreKey)!;
</script>
<template lang="pug">
div.form-group
Input(v-if='field.type == "text"' :label="field.label" :type="field.type" :required="field.required" @update:modelValue="(v) => store.setSetting(field.identifier, v)" :modelValue="store.settings[field.identifier]" :help_text="field.help_text")
FileInput(v-if='field.type == "image"' :label="field.label" :required="field.required" :help_text="field.help_text" @change="(v) => store.setSetting(field.identifier, v)" :filename="store.settings[field.identifier]?.name" :current_url="store.settings[field.identifier]?.name")
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { inject } from "vue";
import PlaceholderFieldSettings from "./placeholder-field-settings.vue";
import PredefinedFieldSettings from "./predefined-field-settings.vue";
import SettingsField from "./settings-field.vue";
import { StoreKey } from "../walletStore.js";
const gettext = (window as any).gettext;
const store = inject(StoreKey)!;
</script>
<template lang="pug">
h2.h3 {{ gettext("Settings") }}
SettingsField(v-for="field of store.style.settings" :field="field" :key="field.identifier")
h2.h3 {{ gettext("Field Groups") }}
div(v-for="(fieldgroup, fieldgroupId) in store.style.fieldgroups" :id="'fieldgroup-' + fieldgroup.identifier")
PlaceholderFieldSettings(
v-if="fieldgroup.type == 'placeholder'"
v-model="store.layout.fieldgroups[fieldgroup.identifier]"
:fieldgroup="fieldgroup"
:overflows="store.style.fieldgroups.slice(fieldgroupId + 1) \
.filter(x => x.type == 'placeholder' && x.content_type === fieldgroup.content_type)"
)
PredefinedFieldSettings(v-else-if="fieldgroup.type == 'predefined'"
v-model="store.layout.fieldgroups[fieldgroup.identifier]"
:fieldgroup="fieldgroup")
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed, inject, reactive } from 'vue'
import Select from './input/select.vue'
import I18nInput from './input/i18ninput.vue'
import { StoreKey } from "../walletStore";
const store = inject(StoreKey)!
const gettext = (window as any).gettext
const props = defineProps<{
placeholderChoices: [string|null, string][];
}>();
const entry = defineModel<FieldEntry>({ required: true })
const selection = computed({
get() {
if (entry.value.type === 'placeholder') {
return entry.value.content
} else if (entry.value.type === 'custom') {
return "other"
}
},
set(newValue) {
if (newValue == "other") {
entry.value.type = "custom"
entry.value.content = {};
} else {
entry.value.type = "placeholder"
entry.value.content = newValue
}
}
})
const textContent = computed({
get() {
if (entry.value.type === 'placeholder') {
return ""
} else if (entry.value.type === 'custom') {
return entry.value.content
}
},
set(newValue) {
entry.value.content = newValue
}
})
</script>
<template lang="pug">
.i18n-form-group
Select(
v-model="selection"
:choices="placeholderChoices"
)
I18nInput(v-model="textContent" v-if="selection === 'other'" :locales="store.locales")
</template>
@@ -0,0 +1,32 @@
export function i18nstringLocalize(s: I18nString): string {
if (typeof s === 'string') {
return s
}
if (s === null) {
return null
}
var locale = document.body.attributes['data-pretixlocale'].value
var short_locale = locale.split('-')[0]
if (locale in s)
return s[locale]
if (short_locale in s)
return s[short_locale]
for (const k of Object.keys(s)) {
if (k.split('-')[0] === short_locale && s[k]) {
return s[k]
}
}
if (s['en'])
return s['en']
for (const k of Object.keys(s)) {
if (s[k]) {
return s[k]
}
}
}
@@ -0,0 +1,164 @@
type Platform = {
identifier: string;
name: string;
styles: Styles;
};
type Style = {
identifier: string;
name: string;
fieldgroups: FieldGroupDefinition[];
settings: Setting[];
};
type Variable = {
label: string;
sample: string;
required_context: string[];
};
type Styles = Record<string, Style>;
type Variables = Record<string, Variable>;
type VariableConfig = Record<string, Variables>;
type Platforms = Platform[];
//
type BaseFieldGroupDefinition = {
type: string;
identifier: string;
name: string;
required: boolean;
description: string;
};
type FieldGroupDefinition =
| PlaceholderFieldGroupDefinition
| PredefinedFieldGroupDefinition;
type FieldGroupDisplay = "plain" | "with_label" | "code";
type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
type: "placeholder";
content_type: FieldContentType;
default_entries: FieldEntry[];
display: FieldGroupDisplay;
min_entries: number | null;
max_entries: number | null;
context_args: string[];
};
type PredefinedFieldGroupDefinition = BaseFieldGroupDefinition & {
type: "predefined";
};
type I18nString = null | string | Record<string, string>;
type FieldContentType = "text" | "image";
type PlaceholderFieldEntry = {
type: "placeholder";
label?: I18nString;
content?: string;
};
type CustomFieldEntry = {
type: "custom";
label?: I18nString;
content?: I18nString;
};
type FieldEntry = PlaceholderFieldEntry | CustomFieldEntry;
type Setting = {
identifier: string;
label: string;
type: "text" | "image";
required: boolean;
help_text: string;
};
type PlaceholderFieldGroupConfig = {
entries: Array<FieldEntry>;
overflow: string | null;
};
type PredefinedFieldGroupConfig = {
active: boolean;
};
type FieldGroupConfig =
| PlaceholderFieldGroupConfig
| PredefinedFieldGroupConfig;
type LayoutData = {
fieldgroups?: Record<string, FieldGroupConfig>;
settings?: Record<string, any>;
};
type PlatformLayout = {
platform: string;
style: string | null;
layout: LayoutData;
};
type WalletLayout = {
name?: string;
platform_layouts: PlatformLayout[];
};
type WalletStore = {
platforms: Platforms;
variables: VariableConfig;
locales: Record<string, string>;
csrfToken: String;
walletLayout: WalletLayout | null;
};
type PreviewLayout = Array<PreviewRow>;
type PreviewRow =
| {
children: Array<PreviewRow>;
direction?: "row" | "column";
display?: Array<string>;
}
| PreviewFieldgroup
| FixedPreview
| SettingPreview;
type PreviewProps = {
relSize?: number;
direction?: "row" | "column";
display?: Array<string>;
};
type SettingPreview = {
setting: string;
} & PreviewProps;
type PreviewFieldgroup =
| PredefinedFieldgroupPreview
| PlaceholderFieldGroupPreview;
type FixedPreview = { value: I18nString; label?: I18nString } & PreviewProps;
type PlaceholderFieldGroupPreview = {
fieldgroup: string;
} & PreviewProps;
type PredefinedFieldgroupPreview = {
fieldgroup: string;
sample: PreviewSample[];
} & PreviewProps;
type PreviewSample = {
content: I18nString;
label: I18nString;
};
type NewPlatformLayout = {
style: string;
fieldgroups: {};
settings: {};
file_settings: Record<string, WalletFile>;
};
type ServerSideFile = { url: string; name: string };
type ClientSideFile = { file: File | null; identifier?: string };
type WalletFile = ServerSideFile | ClientSideFile;
@@ -0,0 +1 @@
../../../../../../../pretix/static/pretixpresale/widget/src/lib/store.ts
@@ -0,0 +1,28 @@
import { createApp } from "vue";
import App from "./components/app.vue";
import { createWalletStore, StoreKey } from "./walletStore";
const mountEl = document.querySelector<HTMLElement>("#editor")!;
const store = createWalletStore({
platforms: JSON.parse(
document.querySelector("#platforms")?.textContent ?? "{}",
),
variables: JSON.parse(
document.querySelector("#variables")?.textContent ?? "{}",
),
locales: JSON.parse(document.querySelector("#locales")?.textContent ?? "{}"),
csrfToken: document.querySelector<HTMLInputElement>(
"input[name=csrfmiddlewaretoken]",
)?.value!!,
layoutId: mountEl.dataset.layoutId!
});
store.load();
const app = createApp(App);
app.provide(StoreKey, store);
app.mount(mountEl);
app.config.errorHandler = (error, _vm, info) => {
// vue fatals on errors by default, which is a weird choice
// https://github.com/vuejs/core/issues/3525
// https://github.com/vuejs/router/discussions/2435
console.error("[VUE]", info, error);
};
@@ -0,0 +1,319 @@
import { serialize } from "node:v8";
import { i18nstringLocalize } from "./helpers.js";
import { createStore } from "./lib/store.ts";
import { toRaw, type InjectionKey } from "vue";
export type WidgetStore = ReturnType<typeof createWalletStore>;
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
function getDefaultFieldgroupState(
fieldgroup: FieldGroupDefinition,
): FieldGroupConfig {
if (fieldgroup.type == "predefined") {
return { active: fieldgroup.required };
} else if (fieldgroup.type == "placeholder") {
return {
overflow: null,
entries: JSON.parse(JSON.stringify(fieldgroup.default_entries)),
};
}
}
function parseExistingFieldgroupState(
fieldgroup: FieldGroupDefinition,
existing?: FieldGroupConfig,
): FieldGroupConfig {
if (!existing) {
return getDefaultFieldgroupState(fieldgroup);
}
if (fieldgroup.type == "predefined") {
return {
active: "active" in existing ? existing.active : fieldgroup.required,
};
} else if (fieldgroup.type == "placeholder") {
return {
overflow: "overflow" in existing ? existing.overflow : null,
// TODO: check that placeholders are possible
entries: structuredClone(
toRaw(
"entries" in existing ? existing.entries : fieldgroup.default_entries,
),
),
};
}
}
export function createWalletStore(config: {
platforms: Platforms;
variables: VariableConfig;
locales: Record<string, string>;
csrfToken: string;
layoutId: string;
}) {
return createStore({
state: () => ({
...config,
loaded: false,
activePlatform: config.platforms[0].identifier,
name: null as string | null,
platformLayouts: {} as Record<string, NewPlatformLayout>,
}),
getters: {
platform() {
return this.getPlatform(this.activePlatform);
},
layout() {
if (!this.loaded) {
return;
}
return this.platformLayouts[this.activePlatform] || null;
},
style(): Style {
if (this.layout) {
return this.platform.styles[this.layout.style];
} else {
return null;
}
},
styles() {
return this.platform.styles;
},
settings() {
const settings = {};
for (const setting of this.style.settings) {
if (setting.type == "text") {
settings[setting.identifier] =
this.layout.settings[setting.identifier];
} else if (setting.type == "image") {
settings[setting.identifier] =
this.layout.file_settings[setting.identifier];
}
}
return settings;
},
renderedFieldGroups() {
const content = {};
const group_defs = this.style.fieldgroups;
for (const fieldgroup of group_defs) {
if (fieldgroup.type == "placeholder") {
content[fieldgroup.identifier] = [];
const layout_group = this.layout.fieldgroups[
fieldgroup.identifier
] as any as PlaceholderFieldGroupConfig;
for (const entry of layout_group.entries) {
const placeholder =
entry.type === "placeholder"
? this.variables[fieldgroup.content_type][entry.content]
: null;
let label = i18nstringLocalize(entry.label);
if (placeholder && !label) {
label = i18nstringLocalize(placeholder.label);
}
let value = null;
if (entry.type == "custom") {
value = i18nstringLocalize(entry.content);
} else if (entry.type == "placeholder") {
value =
placeholder?.sample ||
`(unknown placeholder: ${entry.content})`;
}
content[fieldgroup.identifier].push({
entry,
label,
content: value,
});
}
}
}
for (const fieldgroup of group_defs) {
if (fieldgroup.type == "placeholder") {
const layout_group: PlaceholderFieldGroupConfig =
this.layout.fieldgroups[
fieldgroup.identifier
];
if (
fieldgroup.max_entries &&
content[fieldgroup.identifier].length > fieldgroup.max_entries
) {
const overflow = content[fieldgroup.identifier].slice(
fieldgroup.max_entries,
);
content[fieldgroup.identifier] = content[
fieldgroup.identifier
].slice(0, fieldgroup.max_entries);
if (layout_group.overflow) {
content[layout_group.overflow].splice(0, 0, ...overflow);
}
}
}
}
return content;
},
},
actions: {
getPlatform(identifier: string): Platform {
for (const platform of this.platforms) {
if (platform.identifier === identifier) {
return platform;
}
}
},
load() {
// TODO: error handling / proper api client
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
)
.then((x) => x.json())
.then((x) => {
this.parseServerLayout(x);
this.loaded = true;
})
.catch(alert);
},
parseServerLayout(serverLayout) {
this.name = serverLayout.name;
for (const layout of serverLayout.platform_layouts) {
const clientLayout = {
style: layout.style,
fieldgroups: {},
settings: layout.layout.settings,
file_settings: layout.file_settings,
};
const styleDefinition = this.getPlatform(layout.platform).styles[
layout.style
];
console.log(styleDefinition);
for (const fieldgroup of styleDefinition.fieldgroups) {
clientLayout.fieldgroups[fieldgroup.identifier] =
parseExistingFieldgroupState(
fieldgroup,
layout.layout.fieldgroups[fieldgroup.identifier],
);
}
this.platformLayouts[layout.platform] = clientLayout;
}
},
setPlatform(platform: string) {
this.activePlatform = platform;
},
setStyle(style: string | null) {
if (style === null) {
delete this.platformLayouts[this.activePlatform];
} else if (Object.keys(this.platform.styles).includes(style)) {
const newLayout = {
style,
fieldgroups: {},
settings: {},
file_settings: {},
};
for (const fieldgroup of this.platform.styles[style].fieldgroups) {
newLayout.fieldgroups[fieldgroup.identifier] =
getDefaultFieldgroupState(fieldgroup);
}
this.platformLayouts[this.activePlatform] = newLayout;
// TODO: keep old fieldgroups & settings if matching
}
},
getSetting(identifier: string): Setting {
for (const setting of this.style.settings) {
if (setting.identifier === identifier) {
return setting;
}
}
},
setSetting(identifier: string, value: string | File) {
const setting = this.getSetting(identifier);
if (!setting) return;
if (
setting.type === "image" &&
(value instanceof File || value === null)
) {
this.layout.file_settings[setting.identifier] = {
file: value as File | null,
};
} else if (setting.type == "text" && typeof value == "string") {
this.layout.settings[setting.identifier] = value;
}
},
async uploadFile(file: ClientSideFile) {
return await fetch("/api/v1/upload", {
method: "POST",
body: file.file,
headers: {
"content-disposition": `attachment; filename="${encodeURI(file.file.name)}"`,
"content-type": file.file.type || "application/octet-stream",
"X-CSRFToken": this.csrfToken,
},
})
.then((x) => x.json())
.then((x) => {
file.identifier = x.id;
return x.id;
})
.catch(alert);
},
async serializePlatformLayout(platform, layout: NewPlatformLayout) {
const uploadedFiles = Object.fromEntries(
(
await Promise.all(
Object.entries(layout.file_settings).map(async ([k, v]) => {
if ("url" in v) {
return;
} else if ("identifier" in v) {
return [k, v.identifier];
} else if ("file" in v && v.file instanceof File) {
return [k, await this.uploadFile(v)];
} else if ("file" in v && v.file == null) {
return [k, null];
}
}),
)
).filter((x) => !!x),
);
return {
platform,
style: layout.style,
file_settings: uploadedFiles,
layout: {
fieldgroups: layout.fieldgroups,
settings: layout.settings,
},
};
},
async serializeLayout() {
const layoutPromises = Object.entries(this.platformLayouts).map(
([platform, layout]) =>
this.serializePlatformLayout(platform, layout),
);
return {
name: this.name,
platform_layouts: await Promise.all(layoutPromises),
};
},
async save() {
const serializedLayout = await this.serializeLayout();
// TODO: error handling / proper api client
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
{
method: "PUT",
headers: {
"content-type": "application/json",
"X-CSRFToken": this.csrfToken,
},
body: JSON.stringify(serializedLayout),
},
)
.then((x) => x.json())
.catch((x) => alert(x))
.then((x) => {
this.parseServerLayout(x);
});
},
},
});
}
@@ -0,0 +1,252 @@
import { i18nstringLocalize } from "./helpers.js";
import { createStore } from "./lib/store.ts";
import { toRaw, type InjectionKey } from "vue";
export type WidgetStore = ReturnType<typeof createWalletStore>;
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
export function createWalletStore(config: {
platforms: Platforms;
variables: VariableConfig;
locales: Record<string, string>;
csrfToken: string;
layoutId: string;
}) {
return createStore({
state: () => ({
...config,
walletLayout: null as WalletLayout | null,
currentPlatform: config.platforms[0].identifier,
loaded: false,
files: {} as Record<string, File>
}),
getters: {
currentPlatformStyles() {
for (const platform of this.platforms) {
if (platform.identifier === this.currentPlatform) {
return platform.styles;
}
}
throw "Unknown platform";
},
currentPlatformLayout(): PlatformLayout {
if (!this.walletLayout) {
throw "currentPlatformLayout access before store was loaded";
}
for (const layout of this.walletLayout.platform_layouts) {
if (layout.platform === this.currentPlatform) {
if (!("fieldgroups" in layout.layout)) {
layout.layout.fieldgroups = {};
}
if (!("settings" in layout.layout)) {
layout.layout.settings = {};
}
return layout;
}
}
const newLayout = {
platform: this.currentPlatform,
style: null,
layout: { fieldgroups: {}, settings: {} },
};
this.walletLayout.platform_layouts.push(newLayout);
return newLayout;
},
currentLayoutFieldContent() {
const content = {};
const group_defs =
this.currentPlatformStyles[this.currentPlatformLayout.style]
.fieldgroups;
for (const fieldgroup of group_defs) {
if (fieldgroup.type == "placeholder") {
content[fieldgroup.identifier] = [];
const layout_group = this.currentPlatformLayout.layout.fieldgroups[
fieldgroup.identifier
] as any as PlaceholderFieldGroupConfig;
for (const entry of layout_group.entries) {
const placeholder =
entry.type === "placeholder"
? this.variables[fieldgroup.content_type][entry.content]
: null;
let label = i18nstringLocalize(entry.label);
if (placeholder && !label) {
label = i18nstringLocalize(placeholder.label);
}
let value = null;
if (entry.type == "custom") {
value = i18nstringLocalize(entry.content);
} else if (entry.type == "placeholder") {
value =
placeholder?.sample ||
`(unknown placeholder: ${entry.content})`;
}
content[fieldgroup.identifier].push({
entry,
label,
content: value,
});
}
}
}
for (const fieldgroup of group_defs) {
if (fieldgroup.type == "placeholder") {
const layout_group: PlaceholderFieldGroupConfig =
this.currentPlatformLayout.layout.fieldgroups[
fieldgroup.identifier
];
if (
fieldgroup.max_entries &&
content[fieldgroup.identifier].length > fieldgroup.max_entries
) {
const overflow = content[fieldgroup.identifier].slice(
fieldgroup.max_entries,
);
content[fieldgroup.identifier] = content[
fieldgroup.identifier
].slice(0, fieldgroup.max_entries);
if (layout_group.overflow) {
content[layout_group.overflow].splice(0, 0, ...overflow);
}
}
}
}
return content;
},
currentLayoutSettings() {
return {...this.currentPlatformLayout.file_settings, ...this.currentPlatformLayout.layout.settings}
},
},
actions: {
load() {
// TODO: error handling / proper api client
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
)
.then((x) => x.json())
.then((x) => {
this.walletLayout = x;
this.loaded = true;
});
},
async uploadFile(file: File) {
return await fetch("/api/v1/upload", {
method: "POST",
body: file,
headers: {
"content-disposition": `attachment; filename="${encodeURI(file.name)}"`,
"content-type": file.type || "application/octet-stream",
"X-CSRFToken": this.csrfToken,
},
})
.then((x) => x.json())
.then((x) => x.id);
},
async saveLayout() {
const layoutToSave = structuredClone(toRaw(this.walletLayout))
// TODO: error handling, parallelization
for (const platformLayout of layoutToSave.platform_layouts) {
const platformStyle = this.platforms.filter(
(x) => x.identifier == platformLayout.platform,
)[0].styles[platformLayout.style];
for (const setting of platformStyle.settings) {
console.log(setting, platformLayout.layout?.settings[setting.identifier])
if (
setting.type === "image" &&
platformLayout.layout?.settings[setting.identifier] instanceof
File
) {
platformLayout.layout.settings[setting.identifier] = await
this.uploadFile(
platformLayout.layout.settings[setting.identifier],
);
}
}
}
// TODO: error handling / proper api client
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
{
method: "PUT",
headers: {
"content-type": "application/json",
"X-CSRFToken": this.csrfToken,
},
body: JSON.stringify(layoutToSave),
},
)
.then((x) => x.json())
.catch((x) => alert(x))
.then((x) => {
this.walletLayout = x;
});
},
setCurrentPlatformStyle(style: string | null) {
if (style === null) {
this.currentPlatformLayout.style = null;
this.currentPlatformLayout.layout.fieldgroups = {};
} else if (Object.keys(this.currentPlatformStyles).includes(style)) {
const oldStyle =
this.currentPlatformLayout.style !== null
? this.currentPlatformStyles[this.currentPlatformLayout.style]
: { fieldgroups: [] };
const newStyle = this.currentPlatformStyles[style];
const oldFieldGroups = Object.fromEntries(
oldStyle.fieldgroups.map((x) => [x.identifier, x]),
);
const newFieldGroups = Object.fromEntries(
newStyle.fieldgroups.map((x) => [x.identifier, x]),
);
const keysToKeep = new Set(
Object.keys(this.currentPlatformLayout.layout.fieldgroups).filter(
(x) =>
oldFieldGroups[x]?.type === "placeholder" &&
newFieldGroups[x]?.type === "placeholder" &&
oldFieldGroups[x]?.content_type ===
newFieldGroups[x]?.content_type,
),
);
const keysToDefault = new Set(Object.keys(newFieldGroups)).difference(
keysToKeep,
);
const keysToRemove = new Set(
Object.keys(this.currentPlatformLayout.layout.fieldgroups),
)
.difference(keysToKeep)
.difference(keysToDefault);
for (const key of keysToRemove) {
delete this.currentPlatformLayout.layout.fieldgroups[key];
}
for (const key of keysToDefault) {
if (newFieldGroups[key].type == "placeholder") {
this.currentPlatformLayout.layout.fieldgroups[key] = {
overflow: null,
entries: JSON.parse(
JSON.stringify(newFieldGroups[key].default_entries),
),
active:
newFieldGroups[key].required ||
newFieldGroups[key].default_entries.length > 0,
};
} else {
this.currentPlatformLayout.layout.fieldgroups[key] = {
active: newFieldGroups[key].required,
};
}
}
this.currentPlatformLayout.style = style;
}
},
setSetting(identifier: string, value: string | File | null) {
this.currentPlatformLayout.layout.settings[identifier] = value;
},
},
});
}
@@ -0,0 +1,21 @@
from .apple import ApplePlatform, AppleWalletEventTicket
from .google import GooglePlatform, GoogleWalletEventTicket
from .base import PassStyle
AVAILABLE_PLATFORMS = [ApplePlatform, GooglePlatform]
AVAILABLE_STYLES: dict[str, list[type[PassStyle]]] = {
"apple": [AppleWalletEventTicket],
"google": [GoogleWalletEventTicket],
}
AVAILABLE_STYLES_DICT = {
plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items()
}
def get_style(platform: str, identifier: str) -> type[PassStyle] | None:
return AVAILABLE_STYLES_DICT.get(platform, {}).get(identifier)
__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassStyle"]
+369
View File
@@ -0,0 +1,369 @@
from collections import OrderedDict
from typing import Any
from .base import (
FieldEntryType,
FieldGroupDisplay,
ImageFieldGroup,
PlaceholderFieldGroup,
PredefinedFieldGroup,
TextFieldGroup,
WalletPlatform,
PassStyle,
PlaceholderFieldEntry,
SettingsField,
)
from django.utils.translation import gettext as _, gettext_lazy, override
from i18nfield.strings import LazyI18nString
import io
import hashlib
import zipfile
import cryptography
import cryptography.x509
import cryptography.hazmat.primitives.serialization.pkcs7
import json
from django.contrib.staticfiles import finders
from pretix.base.models import OrderPosition
from django.utils.encoding import force_bytes
from django import forms
class ApplePlatform(WalletPlatform):
identifier = "apple"
name = _("Apple")
class FormattedLazyI18nString:
def __init__(self, base_str: LazyI18nString, **format_args: str):
self.base_str = base_str
self.format_args = format_args
def localize(self, language):
return self.base_str.localize(language).format(**self.format_args)
def lazyi18nstring_from_gettext(text: str, locales: set[str]) -> LazyI18nString:
data = {}
for locale in locales:
with override(locale):
data[locale] = _(text)
return LazyI18nString(data)
class StringResource:
entries: dict[str, LazyI18nString | FormattedLazyI18nString]
locales: set[str]
def __init__(self, locales):
self.entries = {}
self.locales = set(locales)
def add_entry(self, key: str, value: LazyI18nString | FormattedLazyI18nString):
if key in self.entries:
raise ValueError(f"{key} already exists in this StringResource")
self.entries[key] = value
def escape(self, string):
return string.translate(
str.maketrans({'"': '\\"', "\r": "\\r", "\n": "\\n", "\\": "\\\\"})
)
def generate_resource(self, language):
output = ""
for key, entry in self.entries.items():
output += (
f'"{self.escape(key)}" = "{self.escape(entry.localize(language))}";\n'
)
return output.strip()
def generate(self):
return {language: self.generate_resource(language) for language in self.locales}
class SignedZipFile:
"""Generates a zip-file with manifest and signature as apple expects a pkpass file to be"""
def __init__(
self,
ca_certificate: str | bytes,
certificate: str | bytes,
key: str | bytes,
password,
):
self.ca_certificate = cryptography.x509.load_pem_x509_certificate(
force_bytes(ca_certificate)
)
self.certificate = cryptography.x509.load_pem_x509_certificate(
force_bytes(certificate)
)
self.key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
force_bytes(key), force_bytes(password) if password else None
)
self.password = password
self.file = io.BytesIO()
self.zip_file = zipfile.ZipFile(self.file, "w")
self.manifest = {}
def sign(self, data: bytes):
return (
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7SignatureBuilder()
.set_data(data)
.add_signer(
self.certificate,
self.key,
cryptography.hazmat.primitives.hashes.SHA256(),
)
.add_certificate(self.ca_certificate)
.sign(
cryptography.hazmat.primitives.serialization.Encoding.DER,
[
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.Binary,
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.DetachedSignature,
],
)
)
def finish(self):
manifest = json.dumps(self.manifest).encode()
signature = self.sign(manifest)
self.add_file("manifest.json", manifest)
self.add_file("signature", signature)
self.zip_file.close()
return self.file.getvalue()
def add_file(self, filename: str, content: str | bytes):
if isinstance(content, str):
content = content.encode()
with self.zip_file.open(filename, "w") as f:
f.write(content)
self.manifest[filename] = hashlib.sha1(content).hexdigest()
class AppleWalletStyle(PassStyle):
@property
def settings(self):
return [
SettingsField(
identifier="logo",
label=_("Logo"),
type="image",
required=False,
help_text="Will be displayed on the top left corner of the pass"
),
SettingsField(
identifier="icon",
label=_("Icon"),
type="image",
required=False,
help_text="Will be displayed as the file icon"
),
]
def pass_content(self, fields, strings):
raise NotImplementedError()
def generate_pass_json(self, fields, op, strings):
ticket = str(op.item.name)
if op.variation:
ticket += " - " + str(op.variation)
description = FormattedLazyI18nString(
LazyI18nString.from_gettext("Ticket for {event} ({product})"),
event=self.event.name,
product=ticket,
)
strings.add_entry("description", description)
serialNumber = "%s-%s-%s-%d" % (
self.event.organizer.slug,
self.event.slug,
op.order.code,
op.pk,
)
pass_json = {
"formatVersion": 1,
"description": "description",
"organizationName": self.event.organizer.name,
"passTypeIdentifier": self.event.settings.wallet_apple_pass_type_id,
"teamIdentifier": self.event.settings.wallet_apple_team_id,
"serialNumber": serialNumber,
**self.pass_content(fields, strings),
}
return pass_json
def generate(self, op: OrderPosition):
order = op.order
filename = "{}-{}.pkpass".format(order.event.slug, order.code)
fields = self.get_pass_fields(op)
pkpass = SignedZipFile(
self.event.settings.wallet_apple_ca_certificate.read(),
self.event.settings.wallet_apple_certificate.read(),
self.event.settings.wallet_apple_key.read(),
self.event.settings.wallet_apple_key_password,
)
strings = StringResource(locales=self.event.settings.locales)
pass_json = self.generate_pass_json(fields, op, strings)
print(pass_json)
breakpoint()
if fields["logo"]:
logo = fields["logo"][0]["value"]
else:
logo = open(finders.find("pretix_passbook/logo.png"), "rb")
if fields["icon"]:
icon = fields["icon"][0]["value"]
else:
icon = open(finders.find("pretix_passbook/icon.png"), "rb")
pkpass.add_file("icon.png", icon.read())
pkpass.add_file("logo.png", logo.read())
for lang, content in strings.generate().items():
pkpass.add_file(f"{lang}.lproj/pass.strings", content)
pkpass.add_file("pass.json", json.dumps(pass_json))
result = pkpass.finish()
return filename, "application/vnd.apple.pkpass", result
class AppleWalletEventTicket(AppleWalletStyle):
identifier = "event_1"
name = _("Event Ticket Layout 1")
fieldgroups = [
TextFieldGroup(
identifier="logo_text",
name=_("Logo text"),
max_entries=1,
display=FieldGroupDisplay.PLAIN,
default_entries=[],
context_args={"event", "order", "order_position"},
),
TextFieldGroup(
identifier="primary",
name=_("Primary"),
min_entries=1,
max_entries=1,
default_entries=[
PlaceholderFieldEntry(
label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}),
content="item",
)
], # TODO: support Lazyi18nproxy here by using lazyi18nstring_from_gettext
description=_("These fields appear prominently featured on the pass."),
required=True,
context_args={"event", "order", "order_position"},
),
TextFieldGroup(
identifier="secondary",
name=_("Secondary"),
max_entries=4,
context_args={"event", "order", "order_position"},
), # TODO: validation of max field count if combined "Coupons, store cards, and generic passes with a square barcode can have a total of up to four secondary and auxiliary fields, combined."
TextFieldGroup(
identifier="header",
name=_("Header"),
max_entries=3,
context_args={"event", "order", "order_position"},
),
TextFieldGroup(
identifier="auxiliary",
name=_("Auxiliary"),
max_entries=4,
context_args={"event", "order", "order_position"},
),
TextFieldGroup(
identifier="code",
name=_("QR-Code"),
max_entries=1,
display=FieldGroupDisplay.CODE,
default_entries=[
PlaceholderFieldEntry(
content="secret",
)
],
context_args={"event", "order", "order_position"},
),
TextFieldGroup(
identifier="back",
name=_("Back"),
context_args={"event", "order", "order_position"},
),
]
preview_layout = [
[
{
"children": [
{"setting": "logo"},
{
"fieldgroup": "logo_text",
"relSize": 3,
"display": ["bold", "large", "centered"],
},
{
"fieldgroup": "header",
"relSize": 2,
"display": ["large", "tight"],
},
]
},
{"fieldgroup": "primary", "display": "large"},
{"fieldgroup": "secondary"},
{"fieldgroup": "auxiliary"},
{"fieldgroup": "code"},
],
[{"fieldgroup": "back", "direction": "column"}],
]
def convert_fields(self, strings, fields, prefix):
converted = []
for i, f in enumerate(fields):
converted_field = {**f, "key": f"{prefix}-{i}"}
if "label" in converted_field and isinstance(
converted_field["label"], LazyI18nString
):
strings.add_entry(f"{prefix}-{i}-label", converted_field["label"])
converted_field["label"] = f"{prefix}-{i}-label"
if isinstance(converted_field["value"], LazyI18nString):
strings.add_entry(f"{prefix}-{i}-value", converted_field["value"])
converted_field["value"] = f"{prefix}-{i}-value"
converted.append(converted_field)
return converted
def pass_content(self, fields, strings):
content: dict[str, Any] = {
"eventTicket": {
"primaryFields": self.convert_fields(
strings, fields["primary"], "primary"
),
"secondaryFields": self.convert_fields(
strings, fields["secondary"], "secondary"
),
"auxiliaryFields": self.convert_fields(
strings, fields["auxiliary"], "auxiliary"
),
"backFields": self.convert_fields(strings, fields["back"], "back"),
"headerFields": self.convert_fields(
strings, fields["header"], "header"
),
},
}
if fields["logo_text"]:
content["logoText"] = self.convert_fields(
strings, fields["logo_text"], "logo_text"
)[0]["value"]
if fields["code"]:
content["barcodes"] = [
{
"format": "PKBarcodeFormatQR",
"message": str(fields["code"][0]["value"]),
"messageEncoding": "utf-8",
"altText": str(fields["code"][0]["value"]),
}
]
return content
+453
View File
@@ -0,0 +1,453 @@
import enum
from typing import Literal, OrderedDict, TypedDict
from i18nfield.strings import LazyI18nString
import jsonschema
from django.core.exceptions import ValidationError
from pretix.base.models import OrderPosition
from ..placeholders import WalletPlaceholderContext, get_wallet_placeholders
from django import forms
from pretix.api.helpers import handle_file_upload
from django.core.files import File
class WalletPlatform:
identifier: str
name: str
class LayoutContext(TypedDict):
placeholders: dict[str, dict]
class FieldGroupType(enum.Enum):
PLACEHOLDER = "placeholder"
PREDEFINED = "predefined"
class FieldGroupDisplay(enum.Enum):
PLAIN = "plain"
WITH_LABEL = "with_label"
CODE = "code"
class FieldGroup:
type: FieldGroupType
identifier: str
name: str
description: str
required: bool = False
def __init__(self, identifier: str, name: str, description=None, required=False):
self.identifier = identifier
self.name = name
self.required = required
self.description = description or ""
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: LayoutContext,
) -> dict:
raise NotImplementedError()
def asdict(self):
return {
"type": self.type.value,
"identifier": self.identifier,
"name": self.name,
"description": self.description,
"required": self.required,
}
class FieldContentType(enum.Enum):
IMAGE = "image"
TEXT = "text"
class FieldEntryType(enum.Enum):
CUSTOM = "custom"
PLACEHOLDER = "placeholder"
class FieldEntry[T]:
type: FieldEntryType
label: LazyI18nString | None
content: T
def __init__(
self, type: FieldEntryType, content: T, label: LazyI18nString | None = None
):
self.type = type
self.label = label
self.content = content
def asdict(self) -> dict:
return {
"type": self.type.value,
"content": self.content,
"label": self.label.data if self.label else None,
}
class PlaceholderFieldEntry(FieldEntry[str]):
type = FieldEntryType.PLACEHOLDER
label: LazyI18nString | None
content: str
def __init__(self, content: str, label: LazyI18nString | None = None):
self.label = label
self.content = content
class CustomFieldEntry(FieldEntry[LazyI18nString]):
type: FieldEntryType
label: LazyI18nString | None
content: LazyI18nString
def asdict(self) -> dict:
return {
"type": self.type.value,
"content": self.content.data,
"label": self.label.data if self.label else None,
}
class PredefinedFieldGroup(FieldGroup):
type = FieldGroupType.PREDEFINED
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: LayoutContext,
):
return {"type": "object", "properties": {"active": {"type": "boolean"}}}
class PlaceholderFieldGroup(FieldGroup):
type = FieldGroupType.PLACEHOLDER
content_type: FieldContentType
default_entries: list[FieldEntry]
display: FieldGroupDisplay
min_entries: int | None
max_entries: int | None
context_args: set[
str
] # what context arguments are available when rendering this fieldgroup
def __init__(
self,
identifier: str,
name: str,
content_type: FieldContentType,
description: str = "",
required=False,
default_entries=None,
min_entries=None,
max_entries=None,
display=FieldGroupDisplay.WITH_LABEL,
context_args: set[str] | None = None,
):
super().__init__(identifier, name, description, required)
self.content_type = content_type
self.default_entries = default_entries or []
self.min_entries = min_entries
self.max_entries = max_entries
self.display = display
self.context_args = context_args or set()
if self.required and (self.min_entries is None or self.min_entries < 1):
self.min_entries = 1
def asdict(self):
return {
**super().asdict(),
"content_type": self.content_type.value,
"default_entries": [x.asdict() for x in self.default_entries],
"display": self.display.value,
"min_entries": self.min_entries,
"max_entries": self.max_entries,
"context_args": list(sorted(self.context_args)),
}
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: LayoutContext,
):
content_type_placeholders = (
context["placeholders"].get(self.content_type.value, {}).values()
)
available_placeholders = [
x.identifier
for x in content_type_placeholders
if WalletPlaceholderContext.is_available(x, self.context_args)
]
return {
"type": "object",
"properties": {
"entries": self.entries_schema(placeholders=available_placeholders),
"overflow": {
"anyOf": [
{"type": "null"},
{
"type": "string",
"enum": [
f.identifier
for f in remaining_fields
if isinstance(f, PlaceholderFieldGroup)
and f.content_type == self.content_type
],
},
]
},
},
"required": ["entries"],
}
def entries_schema(self, placeholders: list[str]):
baseprops = {}
if self.display == FieldGroupDisplay.WITH_LABEL:
baseprops["label"] = {"$ref": "#/$defs/I18nString"}
schema = {
"type": "array",
"items": {
"type": "object",
"anyOf": [
{
"properties": {
**baseprops,
"type": {"const": "placeholder"},
"content": {"enum": placeholders},
}
},
{
"properties": {
**baseprops,
"type": {"const": "custom"},
"content": {"$ref": "#/$defs/I18nString"},
}
},
],
"required": ["type", "content"],
},
}
if self.display == FieldGroupDisplay.WITH_LABEL:
schema["items"]["required"].append("label")
if self.min_entries is not None:
schema["minItems"] = self.min_entries
# max_entries is not enforced here, as the layout can have more fields than that (null-fields are removed, rest is overspilled)
return schema
class TextFieldGroup(PlaceholderFieldGroup):
content_type = FieldContentType.TEXT
def __init__(self, **kwargs):
super().__init__(content_type=self.content_type, **kwargs)
class ImageFieldGroup(PlaceholderFieldGroup):
content_type = FieldContentType.IMAGE
display = FieldGroupDisplay.PLAIN
def __init__(self, **kwargs):
super().__init__(content_type=self.content_type, display=self.display, **kwargs)
class SettingsField:
identifier: str
label: str
type: Literal["image", "text"]
help_text: str|None
required: bool
def __init__(
self,
identifier: str,
label: str,
type: Literal["image", "text"] = "text",
help_text = None,
required: bool = False,
):
self.identifier = identifier
self.label = label
self.type = type
self.help_text = help_text
self.required = required
def asdict(self):
return {
"identifier": self.identifier,
"label": self.label,
"type": self.type,
"help_text": self.help_text,
"required": self.required,
}
class PassStyle:
identifier: str # unique within platform
name: str
# order here limits in what order users can configure field "overspilling" (if too many fields are defined, where should the rest go)
# -> can only go down in the list
# we evaluate the fields in this order, so they overspill in this order as well
fieldgroups: list[FieldGroup]
@property
def settings(self) -> list[SettingsField]:
return []
@property
def preview_layout(self) -> list | None:
return None
def asdict(self):
return {
"identifier": self.identifier,
"name": self.name,
"fieldgroups": [x.asdict() for x in self.fieldgroups],
"preview_layout": self.preview_layout,
"settings": [x.asdict() for x in self.settings],
}
def layout_schema(self):
context = LayoutContext(placeholders=self.placeholders)
print(f"schema {self.settings=}")
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
# TODO: $id
"title": self.name,
"type": "object",
"properties": {
"fieldgroups": {
"description": "Layout Field Groups",
"type": "object",
"properties": {
group.identifier: group.layout_schema(
context=context, remaining_fields=self.fieldgroups[i:]
)
for (i, group) in enumerate(self.fieldgroups)
},
"required": [
group.identifier for group in self.fieldgroups if group.required
],
},
"settings": {
"type": "object",
"properties": {
setting.identifier: {"type": ["string", "null"]}
for setting in self.settings
},
"required": [
setting.identifier for setting in self.settings if setting.required
],
}
},
"$defs": {
"I18nString": {
"oneOf": [
{"type": "string"},
{"type": "object", "additionalProperties": {"type": "string"}},
]
}
},
}
if any(group.required for group in self.fieldgroups):
schema.setdefault("required", [])
schema["required"].append("fieldgroups")
# if any(setting.required for setting in self.settings):
# schema.setdefault("required", [])
# schema["required"].append("settings")
return schema
def render_placeholder(self, context, content_type, content):
placeholder = self.placeholders.get(content_type, {}).get(content)
if placeholder:
placeholder_value = context.render_placeholder(placeholder)
if placeholder_value:
return placeholder.label, placeholder_value
return None, None
def __init__(self, event, layout = None, file_settings: dict[str, File] | None = None):
self.event = event
self.layout = layout
self.placeholders = get_wallet_placeholders(self.event)
self.file_settings = file_settings
def validate(self):
schema = self.layout_schema()
try:
jsonschema.validate(self.layout, schema)
except jsonschema.ValidationError as e:
raise ValidationError("Invalid layout: {}".format(str(e)))
def extract_file_settings(self, request, file_settings):
res = {}
for setting in self.settings:
if setting.type == "image":
if file_settings.get(setting.identifier) == "file:keep":
res[setting.identifier] = "keep"
elif data := file_settings.get(setting.identifier):
res[setting.identifier] = handle_file_upload(data, request.user, request.auth, {"image/png", "image/jpeg"})
elif setting.identifier in file_settings:
res[setting.identifier] = None
return res
def get_pass_fields(self, op: OrderPosition):
context = WalletPlaceholderContext(
event=self.event, order=op.order, order_position=op
)
fields = {}
for group in self.fieldgroups:
if isinstance(group, PredefinedFieldGroup):
pass
elif isinstance(group, PlaceholderFieldGroup):
group_fields = fields.get(group.identifier, [])
if group.identifier in self.layout["fieldgroups"]:
for field in self.layout["fieldgroups"][group.identifier][
"entries"
]:
field_entry = {}
if group.display == FieldGroupDisplay.WITH_LABEL:
field_entry["label"] = LazyI18nString(field["label"])
if field["type"] == FieldEntryType.PLACEHOLDER.value:
label, field_entry["value"] = self.render_placeholder(
context, group.content_type.value, field["content"]
)
if (
group.display == FieldGroupDisplay.WITH_LABEL
and not str(field_entry["label"])
and label
):
field_entry["label"] = LazyI18nString(label)
elif field["type"] == FieldEntryType.CUSTOM.value:
field_entry["value"] = LazyI18nString(field["content"])
if "value" in field_entry and field_entry["value"]:
group_fields.append(field_entry)
if group.min_entries and len(group_fields) < group.min_entries:
raise ValueError(
f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(group_fields)} were provided"
)
fields[group.identifier] = group_fields[: group.max_entries]
if overflow_group := self.layout["fieldgroups"][group.identifier][
"overflow"
]:
fields.setdefault(overflow_group, [])
fields[overflow_group] += group_fields[group.max_entries :]
else:
raise ValueError("Unknown field group")
return fields
def group_is_active(self, identifier: str):
return self.layout["fieldgroups"].get(identifier, {}).get("active", False)
def generate(self, op: OrderPosition):
raise NotImplementedError()
+361
View File
@@ -0,0 +1,361 @@
from pretix.base.models import Event, OrderPosition
from .base import (
FieldGroupDisplay,
ImageFieldGroup,
PassStyle,
PlaceholderFieldEntry,
PredefinedFieldGroup,
TextFieldGroup,
WalletPlatform,
)
from django.utils.translation import gettext as _
from walletobjects import ButtonJWT, EventTicketClass, EventTicketObject
from walletobjects.comms import Comms
from walletobjects.constants import (
Barcode,
ClassType,
ConfirmationCode,
DoorsOpen,
MultipleDevicesAndHoldersAllowedStatus,
ObjectState,
ObjectType,
ReviewStatus,
Seat,
)
from pretix.base.settings import GlobalSettingsObject
import uuid
from pretix.multidomain.urlreverse import eventreverse_absolute
from django.utils import translation
from django.conf import settings
def _get_instance_uuid():
gs = GlobalSettingsObject()
if not gs.settings.wallet_google_instance_uuid:
gs.settings.wallet_google_instance_uuid = str(uuid.uuid4())
return gs.settings.wallet_google_instance_uuid
def get_class_id(event: Event):
# TODO: add layout id somewhere
instance_uuid = _get_instance_uuid()
issuer_id = event.settings.get("wallet_google_issuer_id")
return "%s.pretix-%s-%s-%s" % (
issuer_id,
instance_uuid,
event.organizer.slug,
event.slug,
)
def get_object_id(op: OrderPosition):
instance_uuid = _get_instance_uuid()
issuer_id = op.order.event.settings.get("wallet_google_issuer_id")
return "%s.pretix-%s-%s-%s-%s-%s-1" % (
issuer_id,
instance_uuid,
op.order.event.organizer.slug,
op.order.event.slug,
op.order.code,
op.positionid,
)
def get_translated_dict(string, locales):
translated = {}
for locale in locales:
translation.activate(locale)
translated[locale] = _(string)
translation.deactivate()
return translated
def get_translated_string(string, locale):
translation.activate(locale)
translated = _(string)
translation.deactivate()
return translated
class GooglePlatform(WalletPlatform):
identifier = "google"
name = _("Google")
class GoogleWalletStyle(PassStyle):
platform = GooglePlatform
def _generate_class(self):
output_class = EventTicketClass(
self.event.organizer.name,
get_class_id(self.event),
MultipleDevicesAndHoldersAllowedStatus.multipleHolders, # TODO: Make configurable
self.event.name,
ReviewStatus.underReview,
self.event.settings.locale,
)
output_class.homepage_uri(
eventreverse_absolute(self.event, "presale:event.index"),
get_translated_string("Website", self.event.settings.locale),
get_translated_dict("Website", self.event.settings.locales),
)
# TODO: callback url
# output_class.callback_url(eventreverse_absolute(event.organizer,"plugins:wallet:google_webhook",))
# TODO: move to pass settings or set defaults
# if (event.settings.get('ticketoutput_googlepaypasses_latitude')
# and event.settings.get('ticketoutput_googlepaypasses_longitude')):
# output_class.locations(
# event.settings.get('ticketoutput_googlepaypasses_latitude'),
# event.settings.get('ticketoutput_googlepaypasses_longitude')
# )
# elif event.geo_lat and event.geo_lon:
# output_class.locations(
# event.geo_lat,
# event.geo_lon
# )
# output_class.country_code(event.settings.locale)
# if event.settings.get('ticketoutput_googlepaypasses_hero'):
# output_class.hero_image(
# urljoin(django_settings.SITE_URL, event.settings.get('ticketoutput_googlepaypasses_hero').url),
# str(event.name),
# event.name,
# )
# output_class.hex_background_color(event.settings.get('primary_color'))
# output_class.event_id('pretix-%s-%s-%s' % (gs.settings.get('update_check_id'), event.organizer.id, event.id))
# if event.settings.get('ticketoutput_googlepaypasses_logo'):
# output_class.logo(
# urljoin(django_settings.SITE_URL, event.settings.get('ticketoutput_googlepaypasses_logo').url),
# str(event.name),
# event.name,
# )
# if event.location:
# name = {}
# address = {}
# for key, value in event.location.data.items():
# lines = value.splitlines()
# name[key] = lines[0]
# # We must provide at least one address line each for the name and address - no way around it.
# if len(lines) > 1:
# address[key] = '\n'.join(value.splitlines()[1:])
# else:
# address[key] = lines[0]
# output_class.venue(name, address)
# if event.date_from and event.date_to and event.date_admission:
# output_class.date_time(
# DoorsOpen.doorsOpen,
# event.date_admission.isoformat(),
# event.date_from.isoformat(),
# event.date_to.isoformat(),
# )
# output_class.confirmation_code_label(ConfirmationCode.orderNumber)
# if event.seating_plan_id is not None:
# output_class.seat_label(Seat.seat)
# return self._comms().put_item(ClassType.eventTicketClass, class_name, output_class)
return output_class
def _generate_object(self, op: OrderPosition, class_id: str):
output_object = EventTicketObject(
get_object_id(op), class_id, ObjectState.active, self.event.settings.locale
)
return output_object
def generate(self, op):
self.op = op
comms = Comms(self.event.settings.get("wallet_google_credentials").read())
class_object = self._generate_class()
ticket_object = self._generate_object(op, class_id=class_object["id"])
# TODO: privacy screen
class_object = comms.put_item(
ClassType.eventTicketClass, class_object["id"], class_object
)
ticket_object = comms.put_item(
ObjectType.eventTicketObject, ticket_object["id"], ticket_object
)
generated_jwt = comms.sign_jwt(
ButtonJWT(
origins=[settings.SITE_URL],
issuer=comms.client_email,
event_ticket_objects=[ticket_object],
skinny=True,
)
)
return (
"googlepaypass",
"text/uri-list",
"https://pay.google.com/gp/v/save/%s" % generated_jwt,
)
class GoogleWalletEventTicket(GoogleWalletStyle):
identifier = "event"
name = "Event Ticket"
fieldgroups = [
ImageFieldGroup(
identifier="logo",
name=_("Logo"),
min_entries=0,
max_entries=1,
default_entries=[
PlaceholderFieldEntry(
content="poweredby",
)
],
),
PredefinedFieldGroup(identifier="venue", name=_("Venue")),
PredefinedFieldGroup(identifier="date", name=_("Date")),
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
TextFieldGroup(
identifier="code",
name=_("QR-Code"),
max_entries=1,
display=FieldGroupDisplay.CODE,
default_entries=[
PlaceholderFieldEntry(
content="secret",
)
],
context_args={"event", "order", "order_position"},
),
]
@property
def preview_layout(self):
return [
[
{
"children": [
{"fieldgroup": "logo", "relSize": 1},
{
"value": str(self.event.organizer.name),
"relSize": 3,
"display": ["large", "centered"],
},
]
},
{
"children": [
{
"fieldgroup": "venue",
"sample": [
{"content": self.venue()[0], "label": ""},
],
},
{"value": str(self.event.name), "display": "large"},
],
"direction": "column",
"display": ["tight"],
},
{
"fieldgroup": "date",
"sample": [
{"content": "01/01/1970", "label": "Date"},
{"content": "12:34", "label": "Time"},
],
},
{
"fieldgroup": "seating",
"sample": [
{"content": "5", "label": "Row"},
{"content": "2", "label": "Seat"},
],
},
{"fieldgroup": "code"},
],
[
{
"fieldgroup": "venue",
"sample": [
{"content": self.venue()[1], "label": self.venue()[0]},
],
},
],
]
def venue(self):
if self.event.location:
name = {}
address = {}
for key, value in self.event.location.data.items():
lines = value.splitlines()
name[key] = lines[0]
# We must provide at least one address line each for the name and address - no way around it.
if len(lines) > 1:
address[key] = "\n".join(value.splitlines()[1:])
else:
address[key] = lines[0]
return name, address
return "", ""
def _generate_class(self):
output_class = super()._generate_class()
if self.group_is_active("venue") and all(self.venue()):
output_class.venue(*self.venue())
return output_class
def _generate_object(self, op: OrderPosition, class_id: str):
output_object = super()._generate_object(op, class_id)
fields = self.get_pass_fields(op)
if fields["code"]:
output_object.barcode(
Barcode.qrCode, fields["code"][0]["value"], fields["code"][0]["value"]
)
# output_object.reservation_info("%s-%s" % (op.order.event.slug, op.order.code))
# output_object.ticket_holder_name(op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''))
output_object.ticket_holder_name("Some name")
output_object.ticket_number(fields["code"][0]["value"])
# output_object.ticket_type(
# get_translated_dict(
# str(op.item) + (" " + str(op.variation.value) if op.variation else ""),
# op.order.event.settings.get('locales')
# )
# )
# places = django_settings.CURRENCY_PLACES.get(op.order.event.currency, 2)
# output_object.face_value(int(op.price * 1000 ** places), op.order.event.currency)
# if op.order.event.seating_plan_id is not None:
# if op.seat:
# output_object.seat(
# get_translated_dict(
# _(str(op.seat)),
# op.order.event.settings.get('locales')
# )
# )
# else:
# output_object.seat(
# get_translated_dict(
# _('General admission'),
# op.order.event.settings.get('locales')
# )
# )
# return self._comms().put_item(ObjectType.eventTicketObject, object_name, output_object)
return output_object
@@ -0,0 +1,35 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% load bootstrap3 %}
{% load vite %}
{% load static %}
{% load compress %}
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %}
<h1>{% trans "New layout" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Ticket design" %}
</label>
<div class="col-md-9 form-control-static">
<p>
{% blocktrans trimmed %}
You can modify the design after you saved this page.
{% endblocktrans %}
</p>
</div>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,19 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Wallet layout" %}{% endblock %}
{% block content %}
<h1>{% trans "Wallet layout" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans with name=object.name %}Are you sure you want to delete <strong>"{{ name }}"</strong>?{% endblocktrans %}</p>
<div class="form-group submit-group">
<a href="{% url "plugins:wallet:index" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,22 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% load bootstrap3 %}
{% load vite %}
{% load static %}
{% load compress %}
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %}
<h1>{% trans "Edit layout" %}</h1>
{{ platforms|json_script:"platforms" }}
{{ variables|json_script:"variables" }}
{{ locales|json_script:"locales" }}
<div id="editor" data-layout-id="{{ object.pk }}"></div>
{% vite_hmr %}
{% vite_asset "src/pretix/plugins/wallet/static/pretixplugins/wallet/main.ts" %}
{% csrf_token %}
{% endblock %}
@@ -0,0 +1,74 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% load wallet %}
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %}
<h1>{% trans "Wallet layouts" %}</h1>
{% if object_list|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any layouts yet.
{% endblocktrans %}
</p>
{% if "event.settings.general:write" in request.eventpermset %}
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new layout" %}
</a>
{% endif %}
</div>
{% else %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Default" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for l in object_list %}
<tr>
<td>
{% if "can_change_event_settings" in request.eventpermset %}
<strong><a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
{{ l.name }}
</a></strong>
{% else %}
<strong>{{ l.name }}</strong>
{% endif %}
</td>
<td>
{% if l.default %}
<span class="text-success">
<span class="fa fa-check"></span>
{% trans "Default" %}
</span>
{% elif "can_change_event_settings" in request.eventpermset %}
<form class="form-inline" method="post"
action="{% url "plugins:wallet:default" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
{% csrf_token %}
<button class="btn btn-default btn-sm">
{% trans "Make default" %}
</button>
</form>
{% endif %}
</td>
<td class="text-right flip">
{% if "can_change_event_settings" in request.eventpermset %}
<a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ l.id }}"
class="btn btn-default btn-sm" title="{% trans "Clone" %}" data-toggle="tooltip"><i class="fa fa-copy"></i></a>
<a href="{% url "plugins:wallet:delete" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,8 @@
{% load i18n %}
<p>
<a class="btn btn-primary btn-lg" target="_blank"
href="{% url "plugins:wallet:index" organizer=request.organizer.slug event=request.event.slug %}">
<span class="fa fa-paint-brush"></span>
{% trans "Edit layouts" %}
</a>
</p>
@@ -0,0 +1,10 @@
from django import template
from ..models import WalletLayout
register = template.Library()
@register.filter
def platform_layouts(platform, event):
return WalletLayout.objects.filter(event=event, platform=platform.identifier)
+192
View File
@@ -0,0 +1,192 @@
#
# 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/>.
#
import logging
from django.utils.translation import gettext_lazy as _
from pretix.base.ticketoutput import BaseTicketOutput
from pretix.base.models import Event
from pretix.base.settings import SettingsSandbox
from django.template.loader import render_to_string
from django.shortcuts import get_object_or_404
from .styles.base import WalletPlatform
from .styles.apple import ApplePlatform
from .styles.google import GooglePlatform
from collections import OrderedDict
from django import forms
from .forms import CertificateFileField, validate_rsa_privkey
from pretix.control.forms import ClearableBasenameFileInput
logger = logging.getLogger("pretix.plugins.wallet")
class WalletSettingsHolder(BaseTicketOutput):
identifier = "wallet"
verbose_name = _("Wallet Output")
is_meta = True
is_enabled = False
preview_allowed = (
False # TODO: implement own preview view or hide button for meta-outputs
)
def settings_content_render(self, request) -> str:
return render_to_string(
"pretixplugins/wallet/settings_content.html", {"request": request}
)
class WalletOutput(BaseTicketOutput):
settings_form_fields = []
platform: WalletPlatform
def __init__(self, event: Event):
super().__init__(event)
self.settings = SettingsSandbox(
"ticketoutput", WalletSettingsHolder.identifier, event
)
def generate(self, op):
if hasattr(op.item, "walletlayout"):
wallet_layout = op.item.walletlayout
else:
wallet_layout = op.event.wallet_layouts.get(default=True)
platform_layout = get_object_or_404(
wallet_layout.platform_layouts, platform=self.platform.identifier
)
return self.platform.generate(platform_layout.pass_layout, op)
class GoogleWalletTicketOutput(WalletOutput):
identifier = "wallet_google"
verbose_name = _("Google")
download_button_text = "Add to Google Wallet"
platform = GooglePlatform
def get_global_settings(sender, **kwargs):
return OrderedDict(
[
(
"wallet_google_issuer_id",
forms.CharField(
label=_("Google Wallet Issuer/Merchant ID"),
help_text=_(
# TODO: update text
"After getting accepted by Google into the Google Pay API for Passes program, "
"your Issuer ID can be found in the Merchant center at "
"https://wallet.google.com/merchant/walletobjects/"
),
required=False,
),
),
(
"wallet_google_credentials",
forms.FileField(
label=_("Google Wallet Service Account Credentials"),
help_text=_(
"Please paste the contents of the JSON credentials file "
"of the service account you tied to your Google Pay API "
"for Passes Issuer ID"
),
required=False,
# TODO: add validator
# validators=[validate_json_credentials]
),
),
(
"wallet_google_instance_uuid",
forms.CharField(
label=_("Google Wallet Pass Instance UUID"),
help_text=_(
"Instance-specific part to be added to the wallet ids"
),
required=False,
),
),
]
)
class AppleWalletTicketOutput(WalletOutput):
identifier = "wallet_apple"
verbose_name = _("Apple")
download_button_text = "Add to Apple Wallet"
platform = ApplePlatform
def get_global_settings(sender, **kwargs):
return OrderedDict(
[
(
"wallet_apple_team_id",
forms.CharField(
label=_("Apple Wallet Pass team ID"),
required=False,
),
),
(
"wallet_apple_pass_type_id",
forms.CharField(
label=_("Apple Wallet Pass type"),
required=False,
),
),
(
"wallet_apple_certificate",
CertificateFileField(
label=_("Apple Wallet Pass certificate file"),
required=False,
),
),
(
"wallet_apple_ca_certificate",
CertificateFileField(
label=_("Apple Wallet Pass CA Certificate"),
help_text=_(
"You can download the current CA certificate from apple at "
"https://www.apple.com/certificateauthority/AppleWWDRCAG4.cer"
),
required=False,
),
),
(
"wallet_apple_key",
forms.FileField(
label=_("Apple Wallet Pass secret key"),
required=False,
validators=[validate_rsa_privkey],
widget=ClearableBasenameFileInput,
),
),
(
"wallet_apple_key_password",
forms.CharField(
label=_("Apple Wallet Pass key password"),
widget=forms.PasswordInput(render_value=True),
required=False,
help_text=_(
"Optional, only necessary if the key entered above requires a password to use."
),
),
),
]
)
OUTPUTS = [WalletSettingsHolder, GoogleWalletTicketOutput, AppleWalletTicketOutput]
+50
View File
@@ -0,0 +1,50 @@
#
# 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.urls import re_path
from pretix.api.urls import event_router
from .views import (
LayoutEditorView,
LayoutCreateView,
LayoutListView,
LayoutPreviewView,
LayoutSetDefault,
LayoutDelete
)
from .api import WalletLayoutViewSet
urlpatterns = [
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/$',
LayoutListView.as_view(), name='index'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/add/$',
LayoutCreateView.as_view(), name='add'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/edit/(?P<layout>[^/]+)/$',
LayoutEditorView.as_view(), name='edit'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/preview/$',
LayoutPreviewView.as_view(), name='preview'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/default/(?P<layout>[^/]+)/$', # TODO
LayoutSetDefault.as_view(), name='default'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/delete/(?P<layout>[^/]+)/$', # TODO
LayoutDelete.as_view(), name='delete'),
]
event_router.register('walletlayouts', WalletLayoutViewSet)
+242
View File
@@ -0,0 +1,242 @@
import copy
import json
from typing import Any
from django.db import transaction
from django import forms
from django.core.exceptions import BadRequest
from django.db.models.query import QuerySet
from django.urls import reverse
from django.http import HttpResponse, HttpResponseRedirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, ListView, DeleteView, View
from pretix_vrpayment_wero.payment import HttpRequest
from pretix.base.i18n import language
from pretix.base.pdf import get_images, get_variables
from pretix.base.services.tickets import get_preview_position
from pretix.control.permissions import EventPermissionRequiredMixin
from django.conf import settings
from django.shortcuts import redirect
from pretix.helpers.database import rolledback_transaction
from pretix.helpers.models import modelclone
from .models import WalletLayout
from .styles import (
AVAILABLE_STYLES,
AVAILABLE_PLATFORMS,
AVAILABLE_STYLES_DICT,
)
from django.contrib import messages
from django.contrib.staticfiles import finders
from django.utils.functional import cached_property
from django.templatetags.static import static
from .placeholders import get_wallet_placeholders, WalletPlaceholderContext
from pretix.base.middleware import add_to_response_csp
def get_editor_placeholders(event):
with (
rolledback_transaction(),
language(event.settings.locale, event.settings.region),
):
p = get_preview_position(event)
context = WalletPlaceholderContext(event=event, order=p.order, order_position=p)
placeholders = {
t: {
pid: {"label": str(p.label), "sample": str(context.render_sample(p)), "required_context": list(sorted(p.required_context))}
for pid, p in ps.items()
}
for t, ps in get_wallet_placeholders(event).items()
}
return placeholders
class WalletLayoutMixin:
model = WalletLayout
permission = "event.settings.general:write"
pk_url_kwarg = "layout"
context_object_name = "layouts"
def get_queryset(self):
return self.request.event.wallet_layouts.all()
class LayoutListView(WalletLayoutMixin, EventPermissionRequiredMixin, ListView):
template_name = "pretixplugins/wallet/layout_list.html"
class LayoutDetailView(WalletLayoutMixin, EventPermissionRequiredMixin, DetailView):
pass
class LayoutEditorView(LayoutDetailView):
template_name = "pretixplugins/wallet/edit.html"
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["platforms"] = [
{
"identifier": platform.identifier,
"name": platform.name,
"styles": {
style.identifier: style(self.request.event).asdict()
for style in AVAILABLE_STYLES.get(platform.identifier)
},
}
for platform in AVAILABLE_PLATFORMS
]
context["variables"] = get_editor_placeholders(self.request.event)
context["locales"] = {
l: dict(settings.LANGUAGES).get(l, l)
for l in self.request.event.settings.get("locales")
}
return context
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
response = super().dispatch(request, *args, **kwargs)
add_to_response_csp(response, {
'img-src': ['blob:'],
})
return response
class WalletLayoutCreateForm(forms.ModelForm):
class Meta:
model = WalletLayout
fields = ("name",)
def __init__(self, *args, event, **kwargs):
super().__init__(*args, **kwargs)
self.event = event
def save(self, *args, **kwargs) -> Any:
self.instance.event = self.event
return super().save(*args, **kwargs)
class LayoutCreateView(WalletLayoutMixin, EventPermissionRequiredMixin, CreateView):
template_name = "pretixplugins/wallet/create.html"
form_class = WalletLayoutCreateForm
permission = "event.settings.general:write"
def form_valid(self, form):
self.object = form.save()
if self.copy_from:
for pl in self.copy_from.platform_layouts.all():
modelclone(pl, parent=self.object).save()
return HttpResponseRedirect(self.get_success_url())
def get_form_kwargs(self) -> dict[str, Any]:
kwargs = super().get_form_kwargs()
kwargs["event"] = self.request.event
if self.copy_from:
kwargs["instance"] = modelclone(self.copy_from, default=False)
kwargs.setdefault("initial", {})
return kwargs
def get_success_url(self) -> str:
return reverse(
"plugins:wallet:edit",
kwargs={
"organizer": self.request.event.organizer.slug,
"event": self.request.event.slug,
"layout": self.object.pk,
},
)
@cached_property
def copy_from(self) -> WalletLayout | None:
if self.request.GET.get("copy_from"):
try:
return self.get_queryset().get(pk=self.request.GET.get("copy_from"))
except WalletLayout.DoesNotExist:
pass
class LayoutPreviewView(EventPermissionRequiredMixin, View):
permission = "event.settings.general:write"
def post(self, request, **kwargs):
event = request.event
platform_id = request.POST.get("platform")
style_id = request.POST.get("style")
layout = request.POST.get("layout")
platform = None
for p in AVAILABLE_PLATFORMS:
if p.identifier == platform_id:
platform = p
if not platform:
raise BadRequest("Unknown platform")
if style_id not in AVAILABLE_STYLES_DICT[platform_id]:
raise BadRequest("Unknown style")
style = AVAILABLE_STYLES_DICT[platform_id][style_id]
layout = json.loads(layout)
with (
rolledback_transaction(),
language(request.event.settings.locale, request.event.settings.region),
):
p = get_preview_position(request.event)
l = style(event=event, layout=layout) # TODO
file_settings = l.extract_file_settings(request)
layout = style(event, layout, file_settings)
layout.validate()
fname, mimet, data = layout.generate(p)
resp = HttpResponse(data, content_type=mimet)
ftype = fname.split(".")[-1]
if not mimet.startswith("text/"):
resp["Content-Disposition"] = (
'attachment; filename="ticket-preview.{}"'.format(ftype)
)
return resp
class LayoutSetDefault(LayoutDetailView):
@transaction.atomic
def post(self, request, *args, **kwargs):
obj = self.get_object()
request.event.wallet_layouts.exclude(pk=obj.pk).update(default=False)
obj.default = True
obj.save(update_fields=["default"])
messages.success(self.request, _("Your changes have been saved."))
return redirect(self.get_success_url())
def get_success_url(self) -> str:
return reverse(
"plugins:wallet:index",
kwargs={
"organizer": self.request.event.organizer.slug,
"event": self.request.event.slug,
},
)
class LayoutDelete(WalletLayoutMixin, DeleteView):
template_name = "pretixplugins/wallet/delete.html"
def get_success_url(self) -> str:
return reverse(
"plugins:wallet:index",
kwargs={
"organizer": self.request.event.organizer.slug,
"event": self.request.event.slug,
},
)
@transaction.atomic
def form_valid(self, form):
self.object = self.get_object()
self.object.log_action(
action="pretix.plugins.wallet.layout.deleted", user=self.request.user
)
self.object.delete()
if not self.request.event.wallet_layouts.filter(default=True).exists():
f = self.request.event.wallet_layouts.first()
if f:
f.default = True
f.save(update_fields=["default"])
messages.success(self.request, _("The selected layout been deleted."))
return redirect(self.get_success_url())
+1 -1
View File
@@ -147,7 +147,7 @@ def get_font_stylesheet(font_name, organizer: Organizer = None, event: Event = N
stylesheet = []
font = get_fonts(event)[font_name]
for sty, formats in font.items():
if sty == 'sample':
if sty in ['sample', 'pdf_only']:
continue
stylesheet.append('@font-face { ')
stylesheet.append('font-family: "{}";'.format(font_name))
+1
View File
@@ -795,6 +795,7 @@ class WidgetAPIProductList(EventListMixin, View):
'target_url': eventreverse_absolute(request.event, 'presale:event.index'),
'subevent': self.subevent.pk if self.subevent else None,
'currency': request.event.currency,
'currency_places': settings.CURRENCY_PLACES.get(request.event.currency, 2),
'display_net_prices': request.event.settings.display_net_prices,
'use_native_spinners': request.event.settings.widget_use_native_spinners,
'show_variations_expanded': request.event.settings.show_variations_expanded,
@@ -380,16 +380,16 @@ Vue.component('pricebox', {
},
display_price: function () {
if (this.$root.display_net_prices) {
return floatformat(parseFloat(this.price.net), 2);
return floatformat(this.price.net, this.$root.currency_places);
} else {
return floatformat(parseFloat(this.price.gross), 2);
return floatformat(this.price.gross, this.$root.currency_places);
}
},
display_price_nonlocalized: function () {
if (this.$root.display_net_prices) {
return parseFloat(this.price.net).toFixed(2);
return parseFloat(this.price.net).toFixed(this.$root.currency_places);
} else {
return parseFloat(this.price.gross).toFixed(2);
return parseFloat(this.price.gross).toFixed(this.$root.currency_places);
}
},
suggested_price_nonlocalized: function () {
@@ -398,9 +398,9 @@ Vue.component('pricebox', {
price = this.price;
}
if (this.$root.display_net_prices) {
return parseFloat(price.net).toFixed(2);
return parseFloat(price.net).toFixed(this.$root.currency_places);
} else {
return parseFloat(price.gross).toFixed(2);
return parseFloat(price.gross).toFixed(this.$root.currency_places);
}
},
original_price_aria_label: function () {
@@ -410,7 +410,7 @@ Vue.component('pricebox', {
return django.interpolate(strings.new_price, [this.stripHTML(this.priceline)]);
},
original_line: function () {
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> " + floatformat(parseFloat(this.original_price), 2);
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> " + floatformat(this.original_price, this.$root.currency_places);
},
priceline: function () {
if (this.price.gross === "0.00") {
@@ -645,19 +645,19 @@ Vue.component('item', {
if (this.item.free_price) {
return django.interpolate(strings.price_from, {
'currency': this.$root.currency,
'price': floatformat(this.item.min_price, 2)
'price': floatformat(this.item.min_price, this.$root.currency_places)
}, true).replace(this.$root.currency, '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + '</span>');
} else if (this.item.min_price !== this.item.max_price) {
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> "
+ floatformat(this.item.min_price, 2) + " "
+ floatformat(this.item.max_price, 2);
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> "
+ floatformat(this.item.min_price, this.$root.currency_places) + " "
+ floatformat(this.item.max_price, this.$root.currency_places);
} else if (this.item.min_price === "0.00" && this.item.max_price === "0.00") {
if (this.item.mandatory_priced_addons) {
return "\xA0"; // nbsp, because an empty string would cause the HTML element to collapse
}
return strings.free;
} else {
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> " + floatformat(this.item.min_price, 2);
return '<span class="pretix-widget-pricebox-currency">' + this.$root.currency + "</span> " + floatformat(this.item.min_price, this.$root.currency_places);
}
},
variationsToggleLabel: function () {
@@ -1949,6 +1949,7 @@ var shared_root_methods = {
root.location = data.location;
root.categories = data.items_by_category;
root.currency = data.currency;
root.currency_places = data.currency_places;
root.display_net_prices = data.display_net_prices;
root.voucher_explanation_text = data.voucher_explanation_text;
root.error = data.error;
@@ -1983,6 +1984,7 @@ var shared_root_methods = {
}, function (error) {
root.categories = [];
root.currency = '';
root.currency_places = 2;
if (error.status === 429) {
root.error = strings['loading_error_429'];
root.connection_error = true;
@@ -2339,6 +2341,7 @@ var create_widget = function (element, html_id=null) {
is_button: false,
categories: null,
currency: null,
currency_places: 2,
name: null,
date_range: null,
location: null,
@@ -22,6 +22,7 @@ export interface ProductListResponse {
location?: string
items_by_category?: Category[]
currency?: string
currency_places?: number
display_net_prices?: boolean
voucher_explanation_text?: string
error?: string
@@ -72,7 +72,7 @@ const pricerange = computed(() => {
STRINGS.price_from,
{
currency: store.currency,
price: floatformat(props.item.min_price || '0', 2),
price: floatformat(props.item.min_price || '0', store.currency_places),
},
true
).replace(
@@ -80,14 +80,14 @@ const pricerange = computed(() => {
`<span class="pretix-widget-pricebox-currency">${store.currency}</span>`
)
} else if (props.item.min_price !== props.item.max_price) {
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', 2)} ${floatformat(props.item.max_price || '0', 2)}`
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', store.currency_places)} ${floatformat(props.item.max_price || '0', store.currency_places)}`
} else if (props.item.min_price === '0.00' && props.item.max_price === '0.00') {
if (props.item.mandatory_priced_addons) {
return '\xA0' // nbsp, because an empty string would cause the HTML element to collapse
}
return STRINGS.free
} else {
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', 2)}`
return `<span class="pretix-widget-pricebox-currency">${store.currency}</span> ${floatformat(props.item.min_price || '0', store.currency_places)}`
}
})

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