Compare commits

...

17 Commits

Author SHA1 Message Date
Raphael Michel
8baacda154 Rebase migration 2025-08-18 11:01:46 +02:00
Raphael Michel
bbc54a3852 Fix duplicate setting of timezone 2025-08-18 11:01:18 +02:00
Raphael Michel
e177bc7475 Upgrade to hierarkey 2.0 2025-08-18 11:01:18 +02:00
Raphael Michel
650b4b461f Voucher: Add creation date (Z#23202621) (#5359)
* Voucher: Add creation date (Z#23202621)

* Migration fix

* Update doc/api/resources/vouchers.rst

Co-authored-by: luelista <weller@rami.io>

* Update src/pretix/base/migrations/0285_voucher_created.py

Co-authored-by: luelista <weller@rami.io>

---------

Co-authored-by: luelista <weller@rami.io>
2025-08-18 10:56:53 +02:00
Luca Sorace "Stranck
d14f7fb108 Orders API: Add check_quotas to orders/change and PATCH/POST orderpositions query params (#5323)
* Orders API: Add check_quotas to orders/change and PATCH/POST orderpositions query params

* Refs #5323: Checkstyle fix

Forgot tu run fkale8 after implementing unit tests oops

* Refs #5323: Fix unit tests and fix of the previous ones

* Refs #5323: PR review
2025-08-13 16:15:05 +02:00
Richard Schreiber
160f1c2e62 [A11y] Remove skip-to-main fallback container creation (#5372) 2025-08-13 12:24:49 +02:00
Raphael Michel
b9e627a86c PDF: Add font fallback on a pragraph level (Z#23203886) (#5367)
* PDF: Add font fallback on a pragraph level (Z#23203886)

* Fix empty texts
2025-08-13 10:51:23 +02:00
dependabot[bot]
328867c089 Update redis requirement from ==6.3.* to ==6.4.* (#5353)
Updates the requirements on [redis](https://github.com/redis/redis-py) to permit the latest version.
- [Release notes](https://github.com/redis/redis-py/releases)
- [Changelog](https://github.com/redis/redis-py/blob/master/CHANGES)
- [Commits](https://github.com/redis/redis-py/compare/v6.3.0...v6.4.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 6.4.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>
2025-08-13 09:13:41 +02:00
dependabot[bot]
3e45274343 Update fakeredis requirement from ==2.30.* to ==2.31.* (#5370)
Updates the requirements on [fakeredis](https://github.com/cunla/fakeredis-py) to permit the latest version.
- [Release notes](https://github.com/cunla/fakeredis-py/releases)
- [Commits](https://github.com/cunla/fakeredis-py/compare/v2.30.0...v2.31.0)

---
updated-dependencies:
- dependency-name: fakeredis
  dependency-version: 2.31.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>
2025-08-13 08:31:31 +02:00
dependabot[bot]
538ca9f0c2 Update pypdf requirement from ==5.9.* to ==6.0.* (#5371)
Updates the requirements on [pypdf](https://github.com/py-pdf/pypdf) to permit the latest version.
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/5.9.0...6.0.0)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.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>
2025-08-13 08:31:11 +02:00
Raphael Michel
99e10adad4 Revert "PDF: Add font fallback on a pragraph level (Z#23203886)"
This reverts commit 10b5f76356.
2025-08-12 15:51:43 +02:00
Raphael Michel
10b5f76356 PDF: Add font fallback on a pragraph level (Z#23203886) 2025-08-12 15:51:13 +02:00
Raphael Michel
39a0093c6b Fix subtotal rendering on mobile (#5365) 2025-08-12 09:39:21 +02:00
Richard Schreiber
d8bf3d0b07 Fix select2 config typo (#5363) 2025-08-11 14:30:25 +02:00
Yasunobu YesNo Kawaguchi
4e56ce8927 Translations: Update Japanese
Currently translated at 99.1% (5891 of 5941 strings)

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

powered by weblate
2025-08-08 16:12:05 +02:00
Raphael Michel
807df01f5d Checkout: Delete invoice address if no longer required (Z#23203488) (#5358) 2025-08-08 15:56:35 +02:00
Raphael Michel
067e11c265 Allow to annul a check-in (#5303)
* Allow to annul a check-in

* Fix locking

* Update doc/api/resources/checkin.rst

Co-authored-by: Phin Wolkwitz <wolkwitz@rami.io>

---------

Co-authored-by: Phin Wolkwitz <wolkwitz@rami.io>
2025-08-08 09:22:19 +02:00
34 changed files with 1135 additions and 132 deletions

View File

@@ -359,3 +359,65 @@ Performing a ticket search
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or check-in list does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested check-in list does not exist.
.. _`rest-checkin-annul`:
Annulment of a check-in
-----------------------
.. http:post:: /api/v1/organizers/(organizer)/checkinrpc/annul/
If a check-in was made in error and the person was not let in, it can be annulled. We do not recommend this to be used
in case of manual check-ins or user interfaces because it is too prone for human errors. It is mostly intended for
automated entry systems like a turnstile or automated door, where the check-in is first created, then the door is
opened, and then the check-in may be annulled if the system knows that the turnstile did not turn or was out of
order.
This endpoint supports passing multiple check-in lists for the context of a multi-event scan. However, each
check-in list passed needs to be from a distinct event.
Check-ins created by a device can only be annulled by the same device. The datetime of annulment may not be more than
15 minutes after the datetime of check-in (value subject to change).
A status code of 404 is returned if no check-in was found for the given nonce. A status code of 400 is returned when
multiple check-ins match the nonce, the input is invalid in another way, the annulment is made from the wrong device,
the check-in is already in an annulled or failed state, or the datetime constraint is not valid.
:<json string nonce: ``nonce`` value of the original check-in.
:<json array lists: List of check-in list IDs to search on. No two check-in lists may be from the same event.
:<json datetime datetime: Specifies the client-side datetime of the annulment. If not supplied, the current time will be used.
:<json string error_explanation: A human-readable description of why the check-in was annulled (optional).
:>json string status: ``"ok"``
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/checkinrpc/annul/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
{
"lists": [1],
"nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
"error_explanation": "Turnstile did not turn"
}
**Example successful response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"status": "ok",
}
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 400: Invalid or incomplete request, see above
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested nonce does not exist.

View File

@@ -1926,6 +1926,7 @@ Manipulating individual positions
(Full order position resource, see above.)
:query boolean check_quotas: Whether to check quotas before committing item changes, default is ``true``
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param id: The ``id`` field of the order position to update
@@ -2005,6 +2006,7 @@ Manipulating individual positions
(Full order position resource, see above.)
:query boolean check_quotas: Whether to check quotas before creating the new position, default is ``true``
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
@@ -2291,6 +2293,7 @@ otherwise, such as splitting an order or changing fees.
(Full order position resource, see above.)
:query boolean check_quotas: Whether to check quotas before patching or creating positions, default is ``true``
:param organizer: The ``slug`` field of the organizer of the event
:param event: The ``slug`` field of the event
:param code: The ``code`` field of the order to update

View File

@@ -14,6 +14,7 @@ The voucher resource contains the following public fields:
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the voucher
created datetime The creation date of the voucher. For vouchers created before pretix 2025.7.0, this is guessed retroactively and might not be accurate.
code string The voucher code that is required to redeem the voucher
max_usages integer The maximum number of times this voucher can be
redeemed (default: 1).
@@ -84,6 +85,7 @@ Endpoints
"results": [
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
@@ -156,6 +158,7 @@ Endpoints
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
@@ -228,6 +231,7 @@ Endpoints
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,
@@ -321,6 +325,7 @@ Endpoints
[
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
}, …
@@ -367,6 +372,7 @@ Endpoints
{
"id": 1,
"created": "2020-09-18T14:17:40.971519Z",
"code": "43K6LKM37FBVR2YG",
"max_usages": 1,
"redeemed": 0,

View File

@@ -30,7 +30,7 @@ Check-ins
.. automodule:: pretix.base.signals
:no-index:
:members: checkin_created
:members: checkin_created, checkin_annulled
Frontend

View File

@@ -42,7 +42,7 @@ dependencies = [
"django-filter==25.1",
"django-formset-js-improved==0.5.0.3",
"django-formtools==2.5.1",
"django-hierarkey==1.2.*",
"django-hierarkey==2.0.*",
"django-hijack==3.7.*",
"django-i18nfield==1.10.*",
"django-libsass==0.9",
@@ -81,14 +81,14 @@ dependencies = [
"pycountry",
"pycparser==2.22",
"pycryptodome==3.23.*",
"pypdf==5.9.*",
"pypdf==6.0.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
"pytz",
"pytz-deprecation-shim==0.1.*",
"pyuca",
"qrcode==8.2",
"redis==6.3.*",
"redis==6.4.*",
"reportlab==4.4.*",
"requests==2.31.*",
"sentry-sdk==2.34.*",
@@ -110,7 +110,7 @@ dev = [
"aiohttp==3.12.*",
"coverage",
"coveralls",
"fakeredis==2.30.*",
"fakeredis==2.31.*",
"flake8==7.3.*",
"freezegun",
"isort==6.0.*",

View File

@@ -104,3 +104,14 @@ class MiniCheckinListSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class CheckinRPCAnnulInputSerializer(serializers.Serializer):
lists = serializers.PrimaryKeyRelatedField(required=True, many=True, queryset=CheckinList.objects.none())
nonce = serializers.CharField(required=True, allow_null=False)
datetime = serializers.DateTimeField(required=False, allow_null=True)
error_explanation = serializers.CharField(required=False, allow_null=True)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event')

View File

@@ -83,6 +83,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
def create(self, validated_data):
ocm = self.context['ocm']
check_quotas = self.context.get('check_quotas', True)
try:
ocm.add_position(
@@ -96,7 +97,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
valid_until=validated_data.get('valid_until'),
)
if self.context.get('commit', True):
ocm.commit()
ocm.commit(check_quotas=check_quotas)
return validated_data['order'].positions.order_by('-positionid').first()
else:
return OrderPosition() # fake to appease DRF
@@ -310,6 +311,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data):
ocm = self.context['ocm']
check_quotas = self.context.get('check_quotas', True)
current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None
item = validated_data.get('item', instance.item)
variation = validated_data.get('variation', instance.variation)
@@ -356,7 +358,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
ocm.change_ticket_secret(instance, secret)
if self.context.get('commit', True):
ocm.commit()
ocm.commit(check_quotas=check_quotas)
instance.refresh_from_db()
except OrderError as e:
raise ValidationError(str(e))

View File

@@ -70,7 +70,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
class Meta:
model = Voucher
fields = ('id', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota',
fields = ('id', 'created', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota',
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
'tag', 'comment', 'subevent', 'show_hidden_items', 'seat', 'all_addons_included',
'all_bundles_included', 'budget', 'budget_used')

View File

@@ -132,6 +132,8 @@ urlpatterns = [
name="checkinrpc.redeem"),
re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/search/$', checkin.CheckinRPCSearchView.as_view(),
name="checkinrpc.search"),
re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/annul/$', checkin.CheckinRPCAnnulView.as_view(),
name="checkinrpc.annul"),
re_path(r'^organizers/(?P<organizer>[^/]+)/settings/$', organizer.OrganizerSettingsView.as_view(),
name="organizer.settings"),
re_path(r'^organizers/(?P<organizer>[^/]+)/giftcards/(?P<giftcard>[^/]+)/', include(giftcard_router.urls)),

View File

@@ -20,12 +20,13 @@
# <https://www.gnu.org/licenses/>.
#
import operator
from datetime import timedelta
from functools import reduce
import django_filters
from django.conf import settings
from django.core.exceptions import ValidationError as BaseValidationError
from django.db import transaction
from django.db import connection, transaction
from django.db.models import (
Count, Exists, F, Max, OrderBy, OuterRef, Prefetch, Q, Subquery,
prefetch_related_objects,
@@ -39,17 +40,19 @@ from django.utils.translation import gettext
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from packaging.version import parse
from rest_framework import views, viewsets
from rest_framework import status, views, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.exceptions import (
NotFound, PermissionDenied, ValidationError,
)
from rest_framework.fields import DateTimeField
from rest_framework.generics import ListAPIView
from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response
from pretix.api.serializers.checkin import (
CheckinListSerializer, CheckinRPCRedeemInputSerializer,
MiniCheckinListSerializer,
CheckinListSerializer, CheckinRPCAnnulInputSerializer,
CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
)
from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import (
@@ -66,6 +69,8 @@ from pretix.base.models.orders import PrintLog
from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
)
from pretix.base.signals import checkin_annulled
from pretix.helpers import OF_SELF
with scopes_disabled():
class CheckinListFilter(FilterSet):
@@ -999,3 +1004,79 @@ class CheckinRPCSearchView(ListAPIView):
qs = qs.none()
return qs
class CheckinRPCAnnulView(views.APIView):
def post(self, request, *args, **kwargs):
if isinstance(self.request.auth, (TeamAPIToken, Device)):
events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders'))
elif self.request.user.is_authenticated:
events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter(
organizer=self.request.organizer
)
else:
raise ValueError("unknown authentication method")
s = CheckinRPCAnnulInputSerializer(data=request.data, context={'events': events})
s.is_valid(raise_exception=True)
with transaction.atomic():
try:
qs = Checkin.all.all()
if isinstance(request.auth, Device):
qs = qs.filter(device=request.auth)
ci = qs.select_for_update(
of=OF_SELF,
).select_related("position", "position__order", "position__order__event").get(
list__in=s.validated_data['lists'],
nonce=s.validated_data['nonce'],
)
if connection.features.has_select_for_update_of and ci.position_id:
# Lock position as well, can't do it with of= above because relation is nullable
OrderPosition.objects.select_for_update(of=OF_SELF).get(pk=ci.position_id)
if not ci.successful or not ci.position:
raise ValidationError("Cannot annul an unsuccessful checkin")
except Checkin.DoesNotExist:
raise NotFound("No check-in found based on nonce")
except Checkin.MultipleObjectsReturned:
raise ValidationError("Multiple check-ins found based on nonce")
annulment_time = s.validated_data.get("datetime") or now()
if annulment_time - ci.datetime > timedelta(minutes=15):
# Compare to sent datetime, which makes this cheatable, but allows offline annulment of checkins
ci.position.order.log_action('pretix.event.checkin.annulment.ignored', data={
'checkin': ci.pk,
'position': ci.position.id,
'positionid': ci.position.positionid,
'datetime': annulment_time,
'error_explanation': s.validated_data.get("error_explanation"),
'type': ci.type,
'list': ci.list_id,
}, user=request.user, auth=request.auth)
return Response({
"non_field_errors": ["Annulment is not allowed more than 15 minutes after check-in"]
}, status=status.HTTP_400_BAD_REQUEST)
if ci.device and ci.device != request.auth:
return Response({
"non_field_errors": ["Annulment is only allowed from the same device"]
}, status=status.HTTP_400_BAD_REQUEST)
ci.successful = False
ci.error_reason = Checkin.REASON_ANNULLED
ci.error_explanation = s.validated_data.get("error_explanation")
ci.save(update_fields=["successful", "error_reason", "error_explanation"])
ci.position.order.log_action('pretix.event.checkin.annulled', data={
'checkin': ci.pk,
'position': ci.position.id,
'positionid': ci.position.positionid,
'datetime': annulment_time,
'error_explanation': s.validated_data.get("error_explanation"),
'type': ci.type,
'list': ci.list_id,
}, user=request.user, auth=request.auth)
checkin_annulled.send(ci.position.order.event, checkin=ci)
return Response({"status": "ok"}, status=status.HTTP_200_OK)

View File

@@ -943,6 +943,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
@action(detail=True, methods=['POST'])
def change(self, request, **kwargs):
order = self.get_object()
check_quotas = self.request.query_params.get('check_quotas', 'true') == 'true'
serializer = OrderChangeOperationSerializer(
context={'order': order, **self.get_serializer_context()},
@@ -1008,7 +1009,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
elif serializer.validated_data.get('recalculate_taxes') == 'keep_gross':
ocm.recalculate_taxes(keep='gross')
ocm.commit()
ocm.commit(check_quotas=check_quotas)
except OrderError as e:
raise ValidationError(str(e))
@@ -1087,6 +1088,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false') == 'true'
ctx['check_quotas'] = self.request.query_params.get('check_quotas', 'true').lower() == 'true'
return ctx
def get_queryset(self):

View File

@@ -48,7 +48,7 @@ from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import (
BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate,
PageTemplate, Paragraph, Spacer, Table, TableStyle,
PageTemplate, Spacer, Table, TableStyle,
)
from pretix.base.decimal import round_decimal
@@ -56,7 +56,9 @@ from pretix.base.models import Event, Invoice, Order, OrderPayment
from pretix.base.services.currencies import SOURCE_NAMES
from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper
from pretix.helpers.reportlab import (
FontFallbackParagraph, ThumbnailingImageReader, reshaper,
)
from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__)
@@ -235,16 +237,17 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
italic='OpenSansIt', boldItalic='OpenSansBI')
for family, styles in get_fonts(event=self.event, pdf_support_required=True).items():
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
if family == self.event.settings.invoice_renderer_font:
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
self.font_regular = family
if 'italic' in styles:
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
if 'bold' in styles:
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
self.font_bold = family + ' B'
if 'bolditalic' in styles:
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
if 'italic' in styles:
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
if 'bold' in styles:
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
if 'bolditalic' in styles:
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
def _normalize(self, text):
# reportlab does not support unicode combination characters
@@ -393,8 +396,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_to_top = 52 * mm
def _draw_invoice_to(self, canvas):
p = Paragraph(self._clean_text(self.invoice.address_invoice_to),
style=self.stylesheet['Normal'])
p = FontFallbackParagraph(self._clean_text(self.invoice.address_invoice_to),
style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - p_size[1] - self.invoice_to_top)
@@ -405,7 +408,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_from_top = 17 * mm
def _draw_invoice_from(self, canvas):
p = Paragraph(
p = FontFallbackParagraph(
self._clean_text(self.invoice.full_invoice_from),
style=self.stylesheet['InvoiceFrom']
)
@@ -523,12 +526,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def shorten(txt):
txt = str(txt)
txt = bleach.clean(txt, tags=set()).strip()
p = Paragraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height)
while p_size[1] > 2 * self.stylesheet['Normal'].leading:
txt = ' '.join(txt.replace('', '').split()[:-1]) + ''
p = Paragraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p = FontFallbackParagraph(self._normalize(txt.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p_size = p.wrap(self.event_width, self.event_height)
return txt
@@ -554,7 +557,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
else:
p_str = shorten(self.invoice.event.name)
p = Paragraph(self._normalize(p_str.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p = FontFallbackParagraph(self._normalize(p_str.strip().replace('\n', '<br />\n')), style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.event_width, self.event_height)
p_size = p.wrap(self.event_width, self.event_height)
p.drawOn(canvas, self.event_left, self.pagesize[1] - self.event_top - p_size[1])
@@ -608,7 +611,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _get_intro(self):
story = []
if self.invoice.custom_field:
story.append(Paragraph(
story.append(FontFallbackParagraph(
'{}: {}'.format(
self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)),
self._clean_text(self.invoice.custom_field),
@@ -617,7 +620,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
))
if self.invoice.internal_reference:
story.append(Paragraph(
story.append(FontFallbackParagraph(
self._normalize(pgettext('invoice', 'Customer reference: {reference}').format(
reference=self._clean_text(self.invoice.internal_reference),
)),
@@ -625,14 +628,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
))
if self.invoice.invoice_to_vat_id:
story.append(Paragraph(
story.append(FontFallbackParagraph(
self._normalize(pgettext('invoice', 'Customer VAT ID')) + ': ' +
self._clean_text(self.invoice.invoice_to_vat_id),
self.stylesheet['Normal']
))
if self.invoice.invoice_to_beneficiary:
story.append(Paragraph(
story.append(FontFallbackParagraph(
self._normalize(pgettext('invoice', 'Beneficiary')) + ':<br />' +
self._clean_text(self.invoice.invoice_to_beneficiary),
self.stylesheet['Normal']
@@ -644,7 +647,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if story:
story.append(Spacer(1, 5 * mm))
story.append(Paragraph(
story.append(FontFallbackParagraph(
self._clean_text(self.invoice.introductory_text, tags=['br']),
self.stylesheet['Normal']
))
@@ -657,7 +660,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story = [
NextPageTemplate('FirstPage'),
Paragraph(
FontFallbackParagraph(
self._normalize(
pgettext('invoice', 'Tax Invoice') if str(self.invoice.invoice_from_country) == 'AU'
else pgettext('invoice', 'Invoice')
@@ -683,17 +686,17 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
]
if has_taxes:
tdata = [(
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
Paragraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']),
Paragraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']),
Paragraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross')), self.stylesheet['BoldRightNoSplit']),
)]
else:
tdata = [(
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
Paragraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
)]
def _group_key(line):
@@ -715,14 +718,20 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
)
description = description + "\n" + single_price_line
tdata.append((
Paragraph(
FontFallbackParagraph(
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
str(len(lines)),
localize(tax_rate) + " %",
Paragraph(money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']),
Paragraph(money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']),
FontFallbackParagraph(
money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
FontFallbackParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
))
else:
if len(lines) > 1:
@@ -731,12 +740,15 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
)
description = description + "\n" + single_price_line
tdata.append((
Paragraph(
FontFallbackParagraph(
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
str(len(lines)),
Paragraph(money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']),
FontFallbackParagraph(
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
self.stylesheet['NormalRight']
),
))
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
@@ -744,13 +756,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if has_taxes:
tdata.append([
Paragraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
money_filter(total, self.invoice.event.currency)
])
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
else:
tdata.append([
Paragraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
money_filter(total, self.invoice.event.currency)
])
colwidths = [a * doc.width for a in (.65, .20, .15)]
@@ -760,12 +772,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
pending_sum = self.invoice.order.pending_sum
if pending_sum != total:
tdata.append(
[Paragraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] +
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Received payments')), self.stylesheet['Normal'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum - total, self.invoice.event.currency)]
)
tdata.append(
[Paragraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] +
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Outstanding payments')), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(pending_sum, self.invoice.event.currency)]
)
@@ -782,12 +794,12 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
s=Sum('amount')
)['s'] or Decimal('0.00')
tdata.append(
[Paragraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] +
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Paid by gift card')), self.stylesheet['Normal'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(giftcard_sum, self.invoice.event.currency)]
)
tdata.append(
[Paragraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] +
[FontFallbackParagraph(self._normalize(pgettext('invoice', 'Remaining amount')), self.stylesheet['Bold'])] +
(['', '', ''] if has_taxes else ['']) +
[money_filter(total - giftcard_sum, self.invoice.event.currency)]
)
@@ -810,7 +822,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Spacer(1, 10 * mm))
if self.invoice.payment_provider_text:
story.append(Paragraph(
story.append(FontFallbackParagraph(
self._normalize(self.invoice.payment_provider_text),
self.stylesheet['Normal']
))
@@ -819,7 +831,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(Spacer(1, 3 * mm))
if self.invoice.additional_text:
story.append(Paragraph(
story.append(FontFallbackParagraph(
self._clean_text(self.invoice.additional_text, tags=['br']),
self.stylesheet['Normal']
))
@@ -835,10 +847,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
('FONTNAME', (0, 0), (-1, -1), self.font_regular),
]
thead = [
Paragraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']),
Paragraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']),
Paragraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']),
Paragraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax rate')), self.stylesheet['Fineprint']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Net value')), self.stylesheet['FineprintRight']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Gross value')), self.stylesheet['FineprintRight']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Tax')), self.stylesheet['FineprintRight']),
''
]
tdata = [thead]
@@ -849,7 +861,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
continue
tax = taxvalue_map[idx]
tdata.append([
Paragraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
money_filter(gross - tax, self.invoice.event.currency),
money_filter(gross, self.invoice.event.currency),
money_filter(tax, self.invoice.event.currency),
@@ -868,7 +880,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
table.setStyle(TableStyle(tstyledata))
story.append(Spacer(5 * mm, 5 * mm))
story.append(KeepTogether([
Paragraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']),
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Included taxes')), self.stylesheet['FineprintHeading']),
table
]))
@@ -885,7 +897,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
net = gross - tax
tdata.append([
Paragraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
FontFallbackParagraph(self._normalize(localize(rate) + " % " + name), self.stylesheet['Fineprint']),
fmt(net), fmt(gross), fmt(tax), ''
])
@@ -894,7 +906,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
story.append(KeepTogether([
Spacer(1, height=2 * mm),
Paragraph(
FontFallbackParagraph(
self._normalize(pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, this corresponds to:'
@@ -909,7 +921,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
elif self.invoice.foreign_currency_display and self.invoice.foreign_currency_rate:
foreign_total = round_decimal(total * self.invoice.foreign_currency_rate)
story.append(Spacer(1, 5 * mm))
story.append(Paragraph(self._normalize(
story.append(FontFallbackParagraph(self._normalize(
pgettext(
'invoice', 'Using the conversion rate of 1:{rate} as published by the {authority} on '
'{date}, the invoice total corresponds to {total}.'
@@ -962,7 +974,7 @@ class Modern1Renderer(ClassicInvoiceRenderer):
self._clean_text(l)
for l in self.invoice.address_invoice_from.strip().split('\n')
]
p = Paragraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender'])
p = FontFallbackParagraph(self._normalize(' · '.join(c)), style=self.stylesheet['Sender'])
p.wrapOn(canvas, self.invoice_to_width, 15.7 * mm)
p.drawOn(canvas, self.invoice_to_left, self.pagesize[1] - self.invoice_to_top + 2 * mm)
super()._draw_invoice_from(canvas)
@@ -1021,7 +1033,7 @@ class Modern1Renderer(ClassicInvoiceRenderer):
_draw(pgettext('invoice', 'Order code'), self.invoice.order.full_code, value_size, self.left_margin, 45 * mm, **kwargs)
]
p = Paragraph(
p = FontFallbackParagraph(
self._normalize(date_format(self.invoice.date, "DATE_FORMAT")),
style=ParagraphStyle(name=f'Normal{value_size}', fontName=self.font_regular, fontSize=value_size, leading=value_size * 1.2)
)
@@ -1079,7 +1091,7 @@ class Modern1SimplifiedRenderer(Modern1Renderer):
i = []
if not self.invoice.event.has_subevents and self.invoice.event.settings.show_dates_on_frontpage:
i.append(Paragraph(
i.append(FontFallbackParagraph(
pgettext('invoice', 'Event date: {date_range}').format(
date_range=self.invoice.event.get_date_range_display(),
),

View File

@@ -0,0 +1,46 @@
# Generated by Django 4.2.16 on 2025-08-08 09:13
from django.db import migrations, models
from django.db.models import Min
from django.utils.timezone import now
def backfill_voucher_created(apps, schema_editor):
Voucher = apps.get_model("pretixbase", "Voucher")
LogEntry = apps.get_model("pretixbase", "LogEntry")
ContentType = apps.get_model("contenttypes", "ContentType")
ct = None
for v in Voucher.objects.filter(created__isnull=True).iterator():
if not ct:
# "Lazy-loading" to prevent this to be executed on new DBs where the content type does not yet
# exist -- but also no vouchers do
ct = ContentType.objects.get(app_label='pretixbase', model='voucher')
v.created = LogEntry.objects.filter(
content_type=ct,
object_id=v.pk,
).aggregate(m=Min("datetime"))["m"] or now()
v.save(update_fields=["created"])
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0284_ordersyncresult_ordersyncqueue"),
]
operations = [
migrations.AddField(
model_name="voucher",
name="created",
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.RunPython(
backfill_voucher_created,
migrations.RunPython.noop,
),
migrations.AlterField(
model_name="voucher",
name="created",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.16 on 2025-08-14 09:40
from django.db import migrations
from hierarkey.utils import CleanHierarkeyDuplicates
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0285_voucher_created"),
]
operations = [
CleanHierarkeyDuplicates("GlobalSettingsObject_SettingsStore"),
CleanHierarkeyDuplicates("Organizer_SettingsStore"),
CleanHierarkeyDuplicates("Event_SettingsStore"),
migrations.AlterUniqueTogether(
name="event_settingsstore",
unique_together={("object", "key")},
),
migrations.AlterUniqueTogether(
name="globalsettingsobject_settingsstore",
unique_together={("key",)},
),
migrations.AlterUniqueTogether(
name="organizer_settingsstore",
unique_together={("object", "key")},
),
]

View File

@@ -350,6 +350,7 @@ class Checkin(models.Model):
REASON_BLOCKED = 'blocked'
REASON_UNAPPROVED = 'unapproved'
REASON_INVALID_TIME = 'invalid_time'
REASON_ANNULLED = 'annulled'
REASONS = (
(REASON_CANCELED, _('Order canceled')),
(REASON_INVALID, _('Unknown ticket')),
@@ -364,6 +365,7 @@ class Checkin(models.Model):
(REASON_BLOCKED, _('Ticket blocked')),
(REASON_UNAPPROVED, _('Order not approved')),
(REASON_INVALID_TIME, _('Ticket not valid at this time')),
(REASON_ANNULLED, _('Check-in annulled')),
)
successful = models.BooleanField(

View File

@@ -1085,7 +1085,7 @@ class Event(EventMixin, LoggedModel):
s.save(force_insert=True)
valid_sales_channel_identifers = set(self.organizer.sales_channels.values_list("identifier", flat=True))
skip_settings = (
skip_settings = {
'ticket_secrets_pretix_sig1_pubkey',
'ticket_secrets_pretix_sig1_privkey',
# no longer used, but we still don't need to copy them
@@ -1093,7 +1093,10 @@ class Event(EventMixin, LoggedModel):
'presale_css_checksum',
'presale_widget_css_file',
'presale_widget_css_checksum',
)
} | {
# Some settings might already exist due to e.g. the timezone being special in the API
s.key for s in self.settings._objects.all()
}
settings_to_save = []
for s in other.settings._objects.all():
if s.key in skip_settings:

View File

@@ -3314,6 +3314,24 @@ class InvoiceAddress(models.Model):
kwargs['update_fields'] = {'name_cached', 'name_parts'}.union(kwargs['update_fields'])
super().save(**kwargs)
def clear(self, except_name=False):
self.is_business = False
if not except_name:
self.name_cached = ""
self.name_parts = {}
self.company = ""
self.street = ""
self.zipcode = ""
self.city = ""
self.country_old = ""
self.country = ""
self.state = ""
self.vat_id = ""
self.vat_id_validated = False
self.custom_field = None
self.internal_reference = ""
self.beneficiary = ""
def describe(self):
parts = [
self.company,

View File

@@ -174,6 +174,9 @@ class Voucher(LoggedModel):
('percent', _('Reduce product price by (%)')),
)
created = models.DateTimeField(
auto_now_add=True,
)
event = models.ForeignKey(
Event,
on_delete=models.CASCADE,

View File

@@ -668,6 +668,16 @@ For backwards compatibility reasons, this signal is only sent when a **successfu
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
checkin_annulled = EventPluginSignal()
"""
Arguments: ``checkin``
This signal is sent out every time a check-in is annulled (i.e. changed to unsuccessful after it
already was successful).
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
logentry_display = EventPluginSignal()
"""
Arguments: ``logentry``

View File

@@ -321,6 +321,14 @@ class OrderChangedSplitFrom(OrderLogEntryType):
_('Denied scan of position #{posid} at {datetime} for list "{list}", type "{type}", error code "{errorcode}".'),
_('Denied scan of position #{posid} for list "{list}", type "{type}", error code "{errorcode}".'),
),
'pretix.event.checkin.annulled': (
_('Annulled scan of position #{posid} at {datetime} for list "{list}", type "{type}".'),
_('Annulled scan of position #{posid} for list "{list}", type "{type}".'),
),
'pretix.event.checkin.annulment.ignored': (
_('Ignored annulment of position #{posid} at {datetime} for list "{list}", type "{type}".'),
_('Ignored annulment of position #{posid} for list "{list}", type "{type}".'),
),
'pretix.control.views.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'),
'pretix.event.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'),
})

View File

@@ -19,11 +19,20 @@
# 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 arabic_reshaper import ArabicReshaper
from django.conf import settings
from django.utils.functional import SimpleLazyObject
from PIL import Image
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.platypus import Paragraph
from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__)
class ThumbnailingImageReader(ImageReader):
@@ -59,3 +68,35 @@ reshaper = SimpleLazyObject(lambda: ArabicReshaper(configuration={
'delete_harakat': True,
'support_ligatures': False,
}))
class FontFallbackParagraph(Paragraph):
def __init__(self, text, style=None, *args, **kwargs):
if style is None:
style = ParagraphStyle(name='paragraphImplicitDefaultStyle')
if not self._font_supports_text(text, style.fontName):
newFont = self._find_font(text, style.fontName)
if newFont:
logger.debug(f"replacing {style.fontName} with {newFont} for {text!r}")
style = style.clone(name=style.name + '_' + newFont, fontName=newFont)
super().__init__(text, style, *args, **kwargs)
def _font_supports_text(self, text, font_name):
if not text:
return True
font = pdfmetrics.getFont(font_name)
return all(
ord(c) in font.face.charToGlyph or not c.isprintable()
for c in text
)
def _find_font(self, text, original_font):
for family, styles in get_fonts(pdf_support_required=True).items():
if self._font_supports_text(text, family):
if (original_font.endswith("It") or original_font.endswith(" I")) and "italic" in styles:
return family + " I"
if (original_font.endswith("Bd") or original_font.endswith(" B")) and "bold" in styles:
return family + " B"
return family

View File

@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-08-05 07:29+0000\n"
"PO-Revision-Date: 2025-08-05 20:00+0000\n"
"Last-Translator: Ryo Tagami <rtagami@airstrip.jp>\n"
"PO-Revision-Date: 2025-08-08 06:00+0000\n"
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/"
"ja/>\n"
"Language: ja\n"
@@ -16949,7 +16949,7 @@ msgstr "税金のルール"
#: pretix/control/navigation.py:97
msgid "Invoicing"
msgstr "請求書作成します"
msgstr "請求書作成"
#: pretix/control/navigation.py:105
msgctxt "action"

View File

@@ -49,7 +49,7 @@ from django.utils.translation import (
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
)
from reportlab.lib.units import mm
from reportlab.platypus import Flowable, Paragraph, Spacer, Table, TableStyle
from reportlab.platypus import Flowable, Spacer, Table, TableStyle
from pretix.base.exporter import BaseExporter, ListExporter
from pretix.base.models import (
@@ -64,6 +64,7 @@ from pretix.base.timeframes import (
from pretix.control.forms.widgets import Select2
from pretix.helpers.filenames import safe_for_filename
from pretix.helpers.iter import chunked_iterable
from pretix.helpers.reportlab import FontFallbackParagraph
from pretix.helpers.templatetags.jsonfield import JSONExtract
from pretix.plugins.reports.exporters import ReportlabExportMixin
@@ -343,7 +344,7 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
]
story = [
Paragraph(
FontFallbackParagraph(
cl.name,
headlinestyle
),
@@ -351,7 +352,7 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
if cl.subevent:
story += [
Spacer(1, 3 * mm),
Paragraph(
FontFallbackParagraph(
'{} ({} {})'.format(
cl.subevent.name,
cl.subevent.get_date_range_display(),
@@ -381,10 +382,10 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
headrowstyle.fontName = 'OpenSansBd'
for q in questions:
txt = str(q.question)
p = Paragraph(txt, headrowstyle)
p = FontFallbackParagraph(txt, headrowstyle)
while p.wrap(colwidths[len(tdata[0])], 5000)[1] > 30 * mm:
txt = txt[:len(txt) - 50] + "..."
p = Paragraph(txt, headrowstyle)
p = FontFallbackParagraph(txt, headrowstyle)
tdata[0].append(p)
qs = self._get_queryset(cl, form_data)
@@ -431,8 +432,8 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
CBFlowable(bool(op.last_checked_in)) if not op.blocked else '',
'' if op.order.status != Order.STATUS_PAID else '',
op.order.code,
Paragraph(name, self.get_style()),
Paragraph(bleach.clean(str(item), tags={'br'}).strip().replace('<br>', '<br/>'), self.get_style()),
FontFallbackParagraph(name, self.get_style()),
FontFallbackParagraph(bleach.clean(str(item), tags={'br'}).strip().replace('<br>', '<br/>'), self.get_style()),
]
acache = {}
if op.addon_to:
@@ -443,10 +444,10 @@ class PDFCheckinList(ReportlabExportMixin, CheckInListMixin, BaseExporter):
for q in questions:
txt = acache.get(q.pk, '')
txt = bleach.clean(txt, tags={'br'}).strip().replace('<br>', '<br/>')
p = Paragraph(txt, self.get_style())
p = FontFallbackParagraph(txt, self.get_style())
while p.wrap(colwidths[len(row)], 5000)[1] > 50 * mm:
txt = txt[:len(txt) - 50] + "..."
p = Paragraph(txt, self.get_style())
p = FontFallbackParagraph(txt, self.get_style())
row.append(p)
if op.order.status != Order.STATUS_PAID:
tstyledata += [

View File

@@ -49,6 +49,7 @@ from pretix.base.timeframes import (
resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
)
from pretix.control.forms.filter import get_all_payment_providers
from pretix.helpers.reportlab import FontFallbackParagraph
from pretix.plugins.reports.exporters import ReportlabExportMixin
@@ -310,13 +311,13 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
tdata = [
[
Paragraph(self._transaction_group_header_label(), tstyle_bold),
Paragraph(_("Price"), tstyle_bold_right),
Paragraph(_("Tax rate"), tstyle_bold_right),
Paragraph("#", tstyle_bold_right),
Paragraph(_("Net total"), tstyle_bold_right),
Paragraph(_("Tax total"), tstyle_bold_right),
Paragraph(_("Gross total"), tstyle_bold_right),
FontFallbackParagraph(self._transaction_group_header_label(), tstyle_bold),
FontFallbackParagraph(_("Price"), tstyle_bold_right),
FontFallbackParagraph(_("Tax rate"), tstyle_bold_right),
FontFallbackParagraph("#", tstyle_bold_right),
FontFallbackParagraph(_("Net total"), tstyle_bold_right),
FontFallbackParagraph(_("Tax total"), tstyle_bold_right),
FontFallbackParagraph(_("Gross total"), tstyle_bold_right),
]
]
@@ -351,7 +352,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
tdata[last_group_head_idx][6] = Paragraph(money_filter(sum_price_by_group, currency), tstyle_bold_right),
tdata.append(
[
Paragraph(
FontFallbackParagraph(
e,
tstyle_bold,
),
@@ -374,7 +375,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
text = self._transaction_row_label(r)
tdata.append(
[
Paragraph(text, tstyle),
FontFallbackParagraph(text, tstyle),
Paragraph(
money_filter(r["price"], currency)
if "price" in r and r["price"] is not None
@@ -405,7 +406,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
for tax_rate in sorted(sum_tax_by_tax_rate.keys(), reverse=True):
tdata.append(
[
Paragraph(_("Sum"), tstyle),
FontFallbackParagraph(_("Sum"), tstyle),
Paragraph("", tstyle_right),
Paragraph(localize(tax_rate.normalize()) + " %", tstyle_right),
Paragraph("", tstyle_right),
@@ -438,7 +439,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
tdata.append(
[
Paragraph(_("Sum"), tstyle_bold),
FontFallbackParagraph(_("Sum"), tstyle_bold),
Paragraph("", tstyle_right),
Paragraph("", tstyle_right),
Paragraph("", tstyle_bold_right),
@@ -492,10 +493,10 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
tdata = [
[
Paragraph(_("Payment method"), tstyle_bold),
Paragraph(_("Payments"), tstyle_bold_right),
Paragraph(_("Refunds"), tstyle_bold_right),
Paragraph(_("Total"), tstyle_bold_right),
FontFallbackParagraph(_("Payment method"), tstyle_bold),
FontFallbackParagraph(_("Payments"), tstyle_bold_right),
FontFallbackParagraph(_("Refunds"), tstyle_bold_right),
FontFallbackParagraph(_("Total"), tstyle_bold_right),
]
]
@@ -537,7 +538,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
tdata.append(
[
Paragraph(provider_names.get(p, p), tstyle),
Paragraph(
FontFallbackParagraph(
money_filter(payments_by_provider[p], currency)
if p in payments_by_provider
else "",
@@ -562,7 +563,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
tdata.append(
[
Paragraph(_("Sum"), tstyle_bold),
FontFallbackParagraph(_("Sum"), tstyle_bold),
Paragraph(
money_filter(
sum(payments_by_provider.values(), Decimal("0.00")), currency
@@ -640,7 +641,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
open_before = tx_before - p_before + r_before
tdata.append(
[
Paragraph(
FontFallbackParagraph(
_("Pending payments at {datetime}").format(
datetime=date_format(
df_start - datetime.timedelta.resolution,
@@ -667,21 +668,21 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
] or Decimal("0.00")
tdata.append(
[
Paragraph(_("Orders"), tstyle),
FontFallbackParagraph(_("Orders"), tstyle),
Paragraph("+", tstyle_center),
Paragraph(money_filter(tx_during, currency), tstyle_right),
]
)
tdata.append(
[
Paragraph(_("Payments"), tstyle),
FontFallbackParagraph(_("Payments"), tstyle),
Paragraph("-", tstyle_center),
Paragraph(money_filter(p_during, currency), tstyle_right),
]
)
tdata.append(
[
Paragraph(_("Refunds"), tstyle),
FontFallbackParagraph(_("Refunds"), tstyle),
Paragraph("+", tstyle_center),
Paragraph(money_filter(r_during, currency), tstyle_right),
]
@@ -767,7 +768,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
] or Decimal("0.00")
tdata.append(
[
Paragraph(_("Gift card transactions (credit)"), tstyle),
FontFallbackParagraph(_("Gift card transactions (credit)"), tstyle),
Paragraph(money_filter(tx_during_pos, currency), tstyle_right),
]
)
@@ -777,7 +778,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
] or Decimal("0.00")
tdata.append(
[
Paragraph(_("Gift card transactions (debit)"), tstyle),
FontFallbackParagraph(_("Gift card transactions (debit)"), tstyle),
Paragraph(money_filter(tx_during_neg, currency), tstyle_right),
]
)
@@ -845,9 +846,9 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
style_small.leading = 10
story = [
Paragraph(self.verbose_name, style_h1),
FontFallbackParagraph(self.verbose_name, style_h1),
Spacer(0, 3 * mm),
Paragraph(
FontFallbackParagraph(
"<br />".join(escape(f) for f in self.describe_filters(form_data)),
style_small,
),
@@ -859,7 +860,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
c_head = f" [{c}]" if len(currencies) > 1 else ""
story += [
Spacer(0, 3 * mm),
Paragraph(_("Orders") + c_head, style_h2),
FontFallbackParagraph(_("Orders") + c_head, style_h2),
Spacer(0, 3 * mm),
*self._table_transactions(form_data, c),
]
@@ -868,7 +869,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
c_head = f" [{c}]" if len(currencies) > 1 else ""
story += [
Spacer(0, 8 * mm),
Paragraph(_("Payments") + c_head, style_h2),
FontFallbackParagraph(_("Payments") + c_head, style_h2),
Spacer(0, 3 * mm),
*self._table_payments(form_data, c),
]
@@ -879,7 +880,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
Spacer(0, 8 * mm),
KeepTogether(
[
Paragraph(_("Open items") + c_head, style_h2),
FontFallbackParagraph(_("Open items") + c_head, style_h2),
Spacer(0, 3 * mm),
*self._table_open_items(form_data, c),
]
@@ -895,7 +896,7 @@ class ReportExporter(ReportlabExportMixin, BaseExporter):
Spacer(0, 8 * mm),
KeepTogether(
[
Paragraph(_("Gift cards") + c_head, style_h2),
FontFallbackParagraph(_("Gift cards") + c_head, style_h2),
Spacer(0, 3 * mm),
*self._table_gift_cards(form_data, c),
]

View File

@@ -56,7 +56,7 @@ from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.units import mm
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import PageBreak, Paragraph, Spacer, Table, TableStyle
from reportlab.platypus import PageBreak, Spacer, Table, TableStyle
from pretix.base.decimal import round_decimal
from pretix.base.exporter import BaseExporter, MultiSheetListExporter
@@ -69,6 +69,8 @@ from pretix.base.timeframes import (
resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
)
from pretix.control.forms.filter import OverviewFilterForm
from pretix.helpers.reportlab import FontFallbackParagraph
from pretix.presale.style import get_fonts
class NumberedCanvas(Canvas):
@@ -135,6 +137,15 @@ class ReportlabExportMixin:
pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf')))
pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf')))
for family, styles in get_fonts(None, pdf_support_required=True).items():
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
if 'italic' in styles:
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
if 'bold' in styles:
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
if 'bolditalic' in styles:
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
def get_doc_template(self):
from reportlab.platypus import BaseDocTemplate
@@ -272,7 +283,7 @@ class OverviewReport(Report):
headlinestyle.fontSize = 15
headlinestyle.fontName = 'OpenSansBd'
story = [
Paragraph(_('Orders by product') + ' ' + (_('(excl. taxes)') if net else _('(incl. taxes)')), headlinestyle),
FontFallbackParagraph(_('Orders by product') + ' ' + (_('(excl. taxes)') if net else _('(incl. taxes)')), headlinestyle),
Spacer(1, 5 * mm)
]
return story
@@ -282,7 +293,7 @@ class OverviewReport(Report):
if form_data.get('date_axis') and form_data.get('date_range'):
d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
story += [
Paragraph(_('{axis} between {start} and {end}').format(
FontFallbackParagraph(_('{axis} between {start} and {end}').format(
axis=dict(OverviewFilterForm(event=self.event).fields['date_axis'].choices)[form_data.get('date_axis')],
start=date_format(d_start, 'SHORT_DATE_FORMAT') if d_start else '',
end=date_format(d_end, 'SHORT_DATE_FORMAT') if d_end else '',
@@ -295,13 +306,13 @@ class OverviewReport(Report):
subevent = self.event.subevents.get(pk=self.form_data.get('subevent'))
except SubEvent.DoesNotExist:
subevent = self.form_data.get('subevent')
story.append(Paragraph(pgettext('subevent', 'Date: {}').format(subevent), self.get_style()))
story.append(FontFallbackParagraph(pgettext('subevent', 'Date: {}').format(subevent), self.get_style()))
story.append(Spacer(1, 5 * mm))
if form_data.get('subevent_date_range'):
d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['subevent_date_range'], self.timezone)
story += [
Paragraph(_('{axis} between {start} and {end}').format(
FontFallbackParagraph(_('{axis} between {start} and {end}').format(
axis=_('Event date'),
start=date_format(d_start, 'SHORT_DATE_FORMAT') if d_start else '',
end=date_format(d_end - timedelta(hours=1), 'SHORT_DATE_FORMAT') if d_end else '',
@@ -373,13 +384,13 @@ class OverviewReport(Report):
tdata = [
[
_('Product'),
Paragraph(_('Canceled'), tstyle_th),
FontFallbackParagraph(_('Canceled'), tstyle_th),
'',
Paragraph(_('Expired'), tstyle_th),
FontFallbackParagraph(_('Expired'), tstyle_th),
'',
Paragraph(_('Approval pending'), tstyle_th),
FontFallbackParagraph(_('Approval pending'), tstyle_th),
'',
Paragraph(_('Purchased'), tstyle_th),
FontFallbackParagraph(_('Purchased'), tstyle_th),
'', '', '', '', ''
],
[
@@ -410,14 +421,14 @@ class OverviewReport(Report):
for tup in items_by_category:
if tup[0]:
tdata.append([
Paragraph(str(tup[0]), tstyle_bold)
FontFallbackParagraph(str(tup[0]), tstyle_bold)
])
for l, s in states:
tdata[-1].append(str(tup[0].num[l][0]))
tdata[-1].append(floatformat(tup[0].num[l][2 if net else 1], places))
for item in tup[1]:
tdata.append([
Paragraph(str(item), tstyle)
FontFallbackParagraph(str(item), tstyle)
])
for l, s in states:
tdata[-1].append(str(item.num[l][0]))
@@ -425,7 +436,7 @@ class OverviewReport(Report):
if item.has_variations:
for var in item.all_variations:
tdata.append([
Paragraph(" " + str(var), tstyle)
FontFallbackParagraph(" " + str(var), tstyle)
])
for l, s in states:
tdata[-1].append(str(var.num[l][0]))
@@ -512,7 +523,7 @@ class OrderTaxListReportPDF(Report):
def get_story(self, doc, form_data):
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, Spacer, Table, TableStyle
from reportlab.platypus import Spacer, Table, TableStyle
headlinestyle = self.get_style()
headlinestyle.fontSize = 15
@@ -553,7 +564,7 @@ class OrderTaxListReportPDF(Report):
tstyledata.append(('SPAN', (5 + 2 * i, 0), (6 + 2 * i, 0)))
story = [
Paragraph(_('Orders by tax rate ({currency})').format(currency=self.event.currency), headlinestyle),
FontFallbackParagraph(_('Orders by tax rate ({currency})').format(currency=self.event.currency), headlinestyle),
Spacer(1, 5 * mm)
]
tdata = [

View File

@@ -959,6 +959,10 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
d['phone'] = str(d['phone'])
self.cart_session['contact_form_data'] = d
if self.address_asked or self.request.event.settings.invoice_name_required:
if not self.address_asked:
# Invoice address was there, but is no longer asked for, however, name is still required
self.invoice_form.instance.clear(except_name=True)
addr = self.invoice_form.save()
if self.cart_customer and self.invoice_form.cleaned_data.get('save'):
@@ -997,6 +1001,10 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
'rate to your purchase and the price of the products in your cart has '
'changed accordingly.'))
return redirect_to_url(self.get_next_url(request) + '?open_cart=true')
elif 'invoice_address' in self.cart_session:
# Invoice address was there, but is no longer asked for
self.invoice_address.delete()
del self.cart_session['invoice_address']
try:
validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False,

View File

@@ -437,7 +437,7 @@
{% if not hide_prices %}
<div role="rowgroup" class="cart-rowgroup-total">
{% if event.settings.display_net_prices and cart.tax_total %}
<div role="row" class="row cart-row">
<div role="row" class="row cart-row subtotal">
<div role="cell" class="product">
<strong>{% trans "Net total" %}</strong>
</div>
@@ -448,7 +448,7 @@
</div>
<div class="clearfix"></div>
</div>
<div role="row" class="row cart-row">
<div role="row" class="row cart-row subtotal">
<div role="cell" class="product">
<strong>{% trans "Taxes" %}</strong>
</div>

View File

@@ -594,7 +594,7 @@ var form_handlers = function (el) {
}
}
},
placeholder: $(this).attr("data-placeholder") | "",
placeholder: $(this).attr("data-placeholder") || "",
templateResult: function (res) {
if (!res.id) {
return res.text;

View File

@@ -241,10 +241,6 @@ function setup_basics(el) {
el.find(".js-only").removeClass("js-only");
el.find(".js-hidden").hide();
// make sure to always have a #content for skip-link to work
if (!document.querySelector("#content")) {
(document.querySelector('main') || document.querySelector('.page-header + *')).id = "content"
}
el.find("div.collapsed").removeClass("collapsed").addClass("collapse");
el.find(".has-error, .alert-danger").each(function () {

View File

@@ -221,7 +221,7 @@
&.has-downloads.hide-prices .download-desktop {
margin-left: 50%;
}
&.total .product {
&.subtotal .product, &.total .product {
width: 50%;
}
.count {

View File

@@ -1077,3 +1077,97 @@ def test_reason_explanation_localization(token_client, organizer, clist, other_i
assert resp.data["status"] == "error"
assert resp.data["reason"] == "invalid_time"
assert resp.data["reason_explanation"] == "Erst ab 01.01.2020 12:00 gültig."
@pytest.mark.django_db
def test_annul_simple(token_client, organizer, clist, event, order):
with scopes_disabled():
p = order.positions.first()
resp = _redeem(token_client, organizer, clist, p.secret, {
'nonce': 'nooooonce'
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
resp = token_client.post('/api/v1/organizers/{}/checkinrpc/annul/'.format(organizer.slug), {
'lists': [clist.pk],
'nonce': 'nooooonce',
'error_explanation': 'Turnstile did not turn',
}, format='json', headers={})
assert resp.status_code == 200
with scopes_disabled():
ci = p.all_checkins.get()
assert not ci.successful
assert ci.error_reason == Checkin.REASON_ANNULLED
assert ci.error_explanation == "Turnstile did not turn"
@pytest.mark.django_db
def test_annul_failures(device_client, team, organizer, clist, clist_event2, event, order):
with scopes_disabled():
p = order.positions.first()
resp = _redeem(device_client, organizer, clist, p.secret, {
'nonce': 'nooooonce',
'datetime': '2025-04-01T12:23:45Z',
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
resp = device_client.post('/api/v1/organizers/{}/checkinrpc/annul/'.format(organizer.slug), {
'lists': [clist.pk],
}, format='json', headers={})
assert resp.status_code == 400
assert resp.data == {"nonce": ["This field is required."]}
resp = device_client.post('/api/v1/organizers/{}/checkinrpc/annul/'.format(organizer.slug), {
'lists': [clist_event2.pk],
'nonce': 'nooooonce',
'error_explanation': 'Turnstile did not turn',
}, format='json', headers={})
assert resp.status_code == 404
assert resp.data == {"detail": "No check-in found based on nonce"}
resp = device_client.post('/api/v1/organizers/{}/checkinrpc/annul/'.format(organizer.slug), {
'lists': [clist.pk],
'nonce': 'notfound',
'error_explanation': 'Turnstile did not turn',
}, format='json', headers={})
assert resp.status_code == 404
assert resp.data == {"detail": "No check-in found based on nonce"}
with scopes_disabled():
lcnt = order.all_logentries().count()
resp = device_client.post('/api/v1/organizers/{}/checkinrpc/annul/'.format(organizer.slug), {
'lists': [clist.pk],
'nonce': 'nooooonce',
'error_explanation': 'Turnstile did not turn',
}, format='json', headers={})
assert resp.status_code == 400
assert resp.data == {
'non_field_errors': ['Annulment is not allowed more than 15 minutes after check-in']
}
with scopes_disabled():
assert order.all_logentries().count() == lcnt + 1
t = team.tokens.create(name='Foo')
team.all_events = True
team.save()
device_client.credentials(HTTP_AUTHORIZATION='Token ' + t.token)
resp = device_client.post('/api/v1/organizers/{}/checkinrpc/annul/'.format(organizer.slug), {
'lists': [clist.pk],
'nonce': 'nooooonce',
'error_explanation': 'Turnstile did not turn',
'datetime': '2025-04-01T12:24:45Z',
}, format='json', headers={})
assert resp.status_code == 400
assert resp.data == {
'non_field_errors': ['Annulment is only allowed from the same device']
}
with scopes_disabled():
ci = p.all_checkins.get()
assert ci.successful

View File

@@ -1204,6 +1204,106 @@ def test_position_update_change_item_no_quota(token_client, organizer, event, or
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_position_update_change_item_empty_quota(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
q = event.quotas.create(name="No Quota", size=0)
q.items.add(item2)
q.save()
op = order.positions.first()
payload = {
'item': item2.pk,
}
assert op.item != item2
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/'.format(
organizer.slug, event.slug, op.pk
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_position_update_change_item_no_quota_check_quota_false(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
op = order.positions.first()
payload = {
'item': item2.pk,
}
assert op.item != item2
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/?check_quotas=false'.format(
organizer.slug, event.slug, op.pk
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_position_update_change_item_empty_quota_check_quota_false(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
q = event.quotas.create(name="No Quota", size=0)
q.items.add(item2)
q.save()
op = order.positions.first()
payload = {
'item': item2.pk,
}
assert op.item != item2
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/?check_quotas=false'.format(
organizer.slug, event.slug, op.pk
), format='json', data=payload
)
assert resp.status_code == 200
op.refresh_from_db()
assert op.item == item2
@pytest.mark.django_db
def test_position_update_change_item_no_quota_check_quota_true(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
op = order.positions.first()
payload = {
'item': item2.pk,
}
assert op.item != item2
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/?check_quotas=true'.format(
organizer.slug, event.slug, op.pk
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_position_update_change_item_empty_quota_check_quota_true(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
q = event.quotas.create(name="No Quota", size=0)
q.items.add(item2)
q.save()
op = order.positions.first()
payload = {
'item': item2.pk,
}
assert op.item != item2
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/?check_quotas=true'.format(
organizer.slug, event.slug, op.pk
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_position_update_change_item_variation(token_client, organizer, event, order, quota):
with scopes_disabled():
@@ -1318,6 +1418,49 @@ def test_position_update_change_subevent_quota_empty(token_client, organizer, ev
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_position_update_change_subevent_quota_empty_check_quota_false(token_client, organizer, event, order, quota, item, subevent):
with scopes_disabled():
se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=datetime.timezone.utc))
q2 = se2.quotas.create(name="foo", size=0, event=event)
q2.items.add(item)
op = order.positions.first()
op.subevent = subevent
op.save()
payload = {
'subevent': se2.pk,
}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/?check_quotas=false'.format(
organizer.slug, event.slug, op.pk
), format='json', data=payload
)
assert resp.status_code == 200
op.refresh_from_db()
assert op.subevent == se2
@pytest.mark.django_db
def test_position_update_change_subevent_quota_empty_check_quota_true(token_client, organizer, event, order, quota, item, subevent):
with scopes_disabled():
se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=datetime.timezone.utc))
q2 = se2.quotas.create(name="foo", size=0, event=event)
q2.items.add(item)
op = order.positions.first()
op.subevent = subevent
op.save()
payload = {
'subevent': se2.pk,
}
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/orderpositions/{}/?check_quotas=true'.format(
organizer.slug, event.slug, op.pk
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_position_update_change_seat(token_client, organizer, event, order, quota, item, seat):
with scopes_disabled():
@@ -1600,6 +1743,85 @@ def test_position_add_quota_empty(token_client, organizer, event, order, quota,
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_position_add_no_quota(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
assert order.positions.count() == 1
payload = {
'order': order.code,
'item': item2.pk,
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/'.format(
organizer.slug, event.slug,
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_position_add_no_quota_check_quota_false(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
assert order.positions.count() == 1
payload = {
'order': order.code,
'item': item2.pk,
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/?check_quotas=false'.format(
organizer.slug, event.slug,
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_position_add_quota_empty_check_quota_false(token_client, organizer, event, order, quota, item):
with scopes_disabled():
assert order.positions.count() == 1
quota.size = 1
quota.save()
payload = {
'order': order.code,
'item': item.pk,
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/?check_quotas=false'.format(
organizer.slug, event.slug,
), format='json', data=payload
)
assert resp.status_code == 201
with scopes_disabled():
assert order.positions.count() == 2
op = order.positions.last()
assert op.item == item
assert op.price == item.default_price
assert op.positionid == 3
@pytest.mark.django_db
def test_position_add_quota_empty_check_quota_true(token_client, organizer, event, order, quota, item):
with scopes_disabled():
assert order.positions.count() == 1
quota.size = 1
quota.save()
payload = {
'order': order.code,
'item': item.pk,
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orderpositions/?check_quotas=true'.format(
organizer.slug, event.slug,
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_position_add_seat(token_client, organizer, event, order, quota, item, seat):
with scopes_disabled():
@@ -1803,6 +2025,283 @@ def test_order_change_patch(token_client, organizer, event, order, quota):
assert order.total == Decimal('109.44')
@pytest.mark.django_db
def test_order_change_patch_no_quota(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
p = order.positions.first()
payload = {
'patch_positions': [
{
'position': p.pk,
'body': {
'item': item2.pk,
'price': '99.44',
},
},
]
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/change/'.format(
organizer.slug, event.slug, order.code,
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_order_change_patch_no_quota_check_quota_false(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
p = order.positions.first()
payload = {
'patch_positions': [
{
'position': p.pk,
'body': {
'item': item2.pk,
'price': '99.44',
},
},
]
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/change/?check_quotas=false'.format(
organizer.slug, event.slug, order.code,
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_order_change_patch_quota_empty(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
q = event.quotas.create(name="No Quota", size=0)
q.items.add(item2)
q.save()
p = order.positions.first()
payload = {
'patch_positions': [
{
'position': p.pk,
'body': {
'item': item2.pk,
'price': '99.44',
},
},
]
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/change/'.format(
organizer.slug, event.slug, order.code,
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_order_change_patch_quota_empty_check_quota_false(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
q = event.quotas.create(name="No Quota", size=0)
q.items.add(item2)
q.save()
p = order.positions.first()
payload = {
'patch_positions': [
{
'position': p.pk,
'body': {
'item': item2.pk,
'price': '99.44',
},
},
]
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/change/?check_quotas=false'.format(
organizer.slug, event.slug, order.code,
), format='json', data=payload
)
assert resp.status_code == 200
with scopes_disabled():
p.refresh_from_db()
assert p.price == Decimal('99.44')
assert p.item == item2
@pytest.mark.django_db
def test_order_change_patch_quota_empty_check_quota_true(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
q = event.quotas.create(name="No Quota", size=0)
q.items.add(item2)
q.save()
p = order.positions.first()
payload = {
'patch_positions': [
{
'position': p.pk,
'body': {
'item': item2.pk,
'price': '99.44',
},
},
]
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/change/?check_quotas=true'.format(
organizer.slug, event.slug, order.code,
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_order_change_create_position(token_client, organizer, event, order, quota, item):
with scopes_disabled():
assert order.positions.count() == 1
payload = {
'create_positions': [
{
'item': item.pk,
},
]
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/change/'.format(
organizer.slug, event.slug, order.code,
), format='json', data=payload
)
assert resp.status_code == 200
with scopes_disabled():
assert order.positions.count() == 2
op = order.positions.last()
assert op.item == item
assert op.price == item.default_price
assert op.positionid == 3
@pytest.mark.django_db
def test_order_change_create_position_no_quota(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
payload = {
'create_positions': [
{
'item': item2.pk,
},
]
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/change/'.format(
organizer.slug, event.slug, order.code,
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_order_change_create_position_no_quota_check_quota_false(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
payload = {
'create_positions': [
{
'item': item2.pk,
},
]
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/change/?check_quotas=false'.format(
organizer.slug, event.slug, order.code,
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_order_change_create_position_quota_empty(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
q = event.quotas.create(name="No Quota", size=0)
q.items.add(item2)
q.save()
payload = {
'create_positions': [
{
'item': item2.pk,
},
]
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/change/'.format(
organizer.slug, event.slug, order.code,
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_order_change_create_position_quota_empty_check_quota_false(token_client, organizer, event, order):
with scopes_disabled():
assert order.positions.count() == 1
item2 = event.items.create(name="Budget Ticket", default_price=23)
q = event.quotas.create(name="No Quota", size=0)
q.items.add(item2)
q.save()
payload = {
'create_positions': [
{
'item': item2.pk,
},
]
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/change/?check_quotas=false'.format(
organizer.slug, event.slug, order.code,
), format='json', data=payload
)
assert resp.status_code == 200
with scopes_disabled():
assert order.positions.count() == 2
op = order.positions.last()
assert op.item == item2
assert op.price == item2.default_price
assert op.positionid == 3
@pytest.mark.django_db
def test_order_change_create_position_quota_empty_check_quota_true(token_client, organizer, event, order):
with scopes_disabled():
item2 = event.items.create(name="Budget Ticket", default_price=23)
q = event.quotas.create(name="No Quota", size=0)
q.items.add(item2)
q.save()
payload = {
'create_positions': [
{
'item': item2.pk,
},
]
}
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/orders/{}/change/?check_quotas=true'.format(
organizer.slug, event.slug, order.code,
), format='json', data=payload
)
assert resp.status_code == 400
assert 'quota' in str(resp.data)
@pytest.mark.django_db
def test_order_change_cancel_and_create(token_client, organizer, event, order, quota, item):
with scopes_disabled():

View File

@@ -91,6 +91,7 @@ def test_voucher_list(token_client, organizer, event, voucher, item, quota, sube
res = dict(TEST_VOUCHER_RES)
res['item'] = item.pk
res['id'] = voucher.pk
res['created'] = voucher.created.isoformat().replace('+00:00', 'Z')
res['code'] = voucher.code
q2 = copy.copy(quota)
q2.pk = None
@@ -264,6 +265,7 @@ def test_voucher_detail(token_client, organizer, event, voucher, item):
res['item'] = item.pk
res['id'] = voucher.pk
res['code'] = voucher.code
res['created'] = voucher.created.isoformat().replace('+00:00', 'Z')
resp = token_client.get('/api/v1/organizers/{}/events/{}/vouchers/{}/'.format(organizer.slug, event.slug,
voucher.pk))

View File

@@ -1337,6 +1337,57 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
def test_invoice_address_discarded_for_free(self):
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = True
self.event.settings.invoice_address_not_asked_free = True
self.event.settings.set('name_scheme', 'title_given_middle_family')
with scopes_disabled():
cp = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('input[name="city"]')), 1)
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business',
'company': 'Foo',
'name_parts_0': 'Mr',
'name_parts_1': 'John',
'name_parts_2': '',
'name_parts_3': 'Kennedy',
'street': 'Baz',
'zipcode': '12345',
'city': 'Here',
'country': 'DE',
'vat_id': 'DE123456',
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
with scopes_disabled():
assert InvoiceAddress.objects.exists()
cp.price = Decimal("0.00")
cp.save()
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('input[name="city"]')), 0)
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
with scopes_disabled():
assert not InvoiceAddress.objects.exists()
def test_invoice_address_optional(self):
self.event.settings.invoice_address_asked = True
self.event.settings.invoice_address_required = False