mirror of
https://github.com/pretix/pretix.git
synced 2026-06-22 03:06:16 +00:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72e3438632 | |||
| 771f4f5d1e | |||
| 496591bb3b | |||
| 88165c098e | |||
| 82a14a4f83 | |||
| ff77a2125a | |||
| 97904d8567 | |||
| a6a9eb6a6a | |||
| b000dff134 | |||
| ba75de7e7d | |||
| 35e1df28d9 | |||
| 7e457f7430 | |||
| 5faa85ed40 | |||
| 1b88a84a83 | |||
| 447cffa7a8 | |||
| 6d255bb9cc | |||
| 4fe405886e | |||
| b7d3e8a80a | |||
| d0d76ffddc | |||
| c04be5c0d9 | |||
| ee1a8420a5 | |||
| d9000c2a66 | |||
| 4530d864d3 | |||
| b968266611 | |||
| 640518c1b3 | |||
| 0715144a31 | |||
| 58ea7c8656 | |||
| a8fe6f505e | |||
| baeec92203 | |||
| 2f9ac05184 | |||
| 4beea63b49 | |||
| 5e49df0ef6 | |||
| b3bb9fccb5 | |||
| e3ffd66691 | |||
| 0f2ebb8687 | |||
| efd887b439 | |||
| 8690d65e99 | |||
| 5682d3ed56 | |||
| 059ff6c99b | |||
| f46fc7fa69 | |||
| 3473fa738d | |||
| 6c7163406e | |||
| 49729d2c87 | |||
| e80b4b560b | |||
| 0bb04ca8f0 | |||
| f50548cd02 | |||
| bb450e1be9 | |||
| 6d07530d2b | |||
| 5d7ee584d9 | |||
| 58cce4b85e | |||
| aa420d4353 | |||
| d2ca217cd8 | |||
| cb6d3967a0 | |||
| 221cbd15ab | |||
| 5c7104634e | |||
| c037fd865b | |||
| 12171e0665 | |||
| 444963e952 | |||
| a57810cf41 | |||
| 2e2e57d231 | |||
| fc7e8ea67a | |||
| 23d1673403 | |||
| 92d1830f3b | |||
| d411c36414 | |||
| 84e12fea32 | |||
| b6518449d6 | |||
| 50c99e1239 | |||
| e70452ee47 | |||
| 666b496ab4 | |||
| 8bd0665f37 | |||
| ed1459b1dd | |||
| 8c251029b9 | |||
| 531f697b9a | |||
| 719ad7104d | |||
| dcb0eb765f | |||
| 86b5191e8b | |||
| b0714886bc | |||
| 438f70c730 | |||
| 608b150bf8 | |||
| c0df7c6142 | |||
| b2ea172a60 |
+10
-5
@@ -1,11 +1,16 @@
|
||||
Contributing to pretix
|
||||
======================
|
||||
|
||||
Hey there and welcome to pretix!
|
||||
Welcome to pretix, we are happy that you would like to contribute.
|
||||
Before you do so, please make sure to read the following documents:
|
||||
|
||||
* We've got a contributors guide in [our documentation](https://docs.pretix.eu/dev/development/contribution/) together with notes on the [development setup](https://docs.pretix.eu/dev/development/setup.html).
|
||||
- [Contribution workflow](https://docs.pretix.eu/dev/development/contribution/general.html)
|
||||
- [AI-assisted contribution policy](https://docs.pretix.eu/dev/development/contribution/ai.html)
|
||||
- [Coding style and quality](https://docs.pretix.eu/dev/development/contribution/style.html)
|
||||
- [Development setup](https://docs.pretix.eu/dev/development/setup.html)
|
||||
- [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html)
|
||||
|
||||
* Please note that we have a [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html) in place that applies to all project contributions, including issues, pull requests, etc.
|
||||
|
||||
* Before we can accept a PR from you we'll need you to sign [our CLA](https://pretix.eu/about/en/cla). You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/).
|
||||
Before we can accept your first PR we'll need you to sign [our **Contributor License Agreement** (CLA)](https://pretix.eu/about/en/cla).
|
||||
You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/).
|
||||
|
||||
**Before contributing new functionality, always open a discussion first.**
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11-bookworm
|
||||
FROM python:3.13-trixie
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
.. _`aipolicy`:
|
||||
|
||||
AI-assisted contribution policy
|
||||
===============================
|
||||
|
||||
pretix is maintained by humans.
|
||||
Every discussion, issue, and pull request is read and reviewed by humans (and sometimes machines, too).
|
||||
We ask you to respect the time and effort put in by these humans by not sending low-effort, unqualified work, since it puts the burden of validation on the maintainer.
|
||||
|
||||
Therefore, the pretix project has strict rules for AI usage:
|
||||
|
||||
- **All AI usage in any form must be disclosed.** You must state the tool you used (e.g. Claude Code, Cursor, Amp) along with the extent that the work was AI-assisted.
|
||||
|
||||
- **The human-in-the-loop must fully understand all code.** If you can't explain what your changes do and how they interact with the greater system without the aid of AI tools, do not contribute to this project.
|
||||
|
||||
- **Issues and discussions can use AI assistance but must have a full human-in-the-loop.** This means that any content generated with AI must have been reviewed and edited by a human before submission. AI is very good at being overly verbose and including noise that distracts from the main point. Humans must do their research and trim this down.
|
||||
|
||||
- **No AI-generated media is allowed (art, images, videos, audio, etc.).** Text and code are the only acceptable AI-generated content, per the other rules in this policy.
|
||||
|
||||
- **Bad AI drivers will be excluded from the project.** People who produce bad contributions that are clearly AI (slop) will be blocked from our organization without warning.
|
||||
|
||||
This policy was inspired by the `ghostty project`_.
|
||||
|
||||
.. _ghostty project: https://github.com/ghostty-org/ghostty/blob/main/AI_POLICY.md
|
||||
@@ -1,23 +1,39 @@
|
||||
General remarks
|
||||
===============
|
||||
Contribution workflow
|
||||
=====================
|
||||
|
||||
You are interested in contributing to pretix? That is awesome!
|
||||
|
||||
If you’re new to contributing to open source software, don’t be afraid. We’ll happily review your code and give you
|
||||
constructive and friendly feedback on your changes.
|
||||
constructive and friendly feedback on your changes. Every contribution should go through the following steps.
|
||||
|
||||
First of all, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
|
||||
Discussion & Design
|
||||
-------------------
|
||||
|
||||
pretix is a large and mature project with more of a decade of history and hopefully many more decades to come.
|
||||
Keeping pretix in good shape over long timeframes is first and foremost a fight against complexity.
|
||||
With every additional feature, complexity grows, and both features and complexity are hard to remove.
|
||||
|
||||
Even if you are doing the initial work of the contribution, accepting the contribution is not free for us.
|
||||
Not only will we need to maintain the feature, but every feature adds cost to the maintenance of every other feature it interacts with, and every feature adds effort for users to understand how pretix works.
|
||||
Therefore, we must carefully select what features we add, based on how well they fit the system in general and of how much use they will be to our larger user base.
|
||||
|
||||
We strongly ask you to **create a discussion on GitHub for every new feature idea** outlining the use case and the proposed implementation design.
|
||||
Pull requests without prior discussion will likely just be closed.
|
||||
|
||||
For bug fixes and very minor changes, you can skip this step and open a PR right away.
|
||||
|
||||
Development
|
||||
-----------
|
||||
|
||||
To develop your contribution, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
|
||||
If you run into any problems on your way, please do not hesitate to ask us anytime!
|
||||
|
||||
Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be
|
||||
assured that we will not tolerate any form of harassment.
|
||||
While developing, please have a look at our :ref:`aipolicy` and our guidelines on :ref:`codestyle`.
|
||||
|
||||
Sending a patch
|
||||
---------------
|
||||
|
||||
If you improved pretix in any way, we'd be very happy if you contribute it
|
||||
back to the main code base! The easiest way to do so is to `create a pull request`_
|
||||
on our `GitHub repository`_.
|
||||
Once you have a first draft of your changes, please `create a pull request`_ on our `GitHub repository`_.
|
||||
|
||||
We recommend that you create a feature branch for every issue you work on so the changes can
|
||||
be reviewed individually.
|
||||
@@ -25,14 +41,17 @@ Please use the test suite to check whether your changes break any existing featu
|
||||
the code style checks to confirm you are consistent with pretix's coding style. You'll
|
||||
find instructions on this in the :ref:`checksandtests` section of the development setup guide.
|
||||
|
||||
We automatically run the tests and the code style check on every pull request on Travis CI and we won’t
|
||||
We automatically run the tests and the code style check on every pull request through GitHub Actions and we won’t
|
||||
accept any pull requests without all tests passing. However, if you don't find out *why* they are not passing,
|
||||
just send the pull request and tell us – we'll be glad to help.
|
||||
|
||||
If you add a new feature, please include appropriate documentation into your patch. If you fix a bug,
|
||||
please include a regression test, i.e. a test that fails without your changes and passes after applying your changes.
|
||||
|
||||
Again: If you get stuck, do not hesitate to contact any of us, or Raphael personally at mail@raphaelmichel.de.
|
||||
Again: If you get stuck, do not hesitate to contact us through GitHub discussions.
|
||||
|
||||
Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be
|
||||
assured that we will not tolerate any form of harassment.
|
||||
|
||||
.. _create a pull request: https://help.github.com/articles/creating-a-pull-request/
|
||||
.. _GitHub repository: https://github.com/pretix/pretix
|
||||
|
||||
@@ -6,4 +6,5 @@ Contributing to pretix
|
||||
|
||||
general
|
||||
style
|
||||
ai
|
||||
codeofconduct
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.. spelling:word-list:: Rebase rebasing
|
||||
|
||||
.. _`codestyle`:
|
||||
|
||||
Coding style and quality
|
||||
========================
|
||||
|
||||
@@ -28,8 +30,6 @@ Code
|
||||
Commits and Pull Requests
|
||||
-------------------------
|
||||
|
||||
|
||||
|
||||
Most commits should start as pull requests, therefore this applies to the titles of pull requests as well since
|
||||
the pull request title will become the commit message on merge. We prefer merging with GitHub's "Squash and merge"
|
||||
feature if the PR contains multiple commits that do not carry value to keep. If there is value in keeping the
|
||||
|
||||
+7
-7
@@ -33,9 +33,9 @@ dependencies = [
|
||||
"bleach==6.3.*",
|
||||
"celery==5.6.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=44.0.0",
|
||||
"cryptography>=46.0.7",
|
||||
"css-inline==0.20.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"defusedcsv>=3.0.0",
|
||||
"dnspython==2.*",
|
||||
"Django[argon2]==5.2.*",
|
||||
"django-bootstrap3==26.1",
|
||||
@@ -76,7 +76,7 @@ dependencies = [
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.12.*",
|
||||
"phonenumberslite==9.0.*",
|
||||
"Pillow==12.1.*",
|
||||
"Pillow==12.2.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==7.34.*",
|
||||
"psycopg2-binary",
|
||||
@@ -90,14 +90,14 @@ dependencies = [
|
||||
"pytz-deprecation-shim==0.1.*",
|
||||
"pyuca",
|
||||
"qrcode==8.2",
|
||||
"redis==7.1.*",
|
||||
"redis==7.4.*",
|
||||
"reportlab==4.4.*",
|
||||
"requests==2.32.*",
|
||||
"sentry-sdk==2.56.*",
|
||||
"sentry-sdk==2.58.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
"tlds>=2020041600",
|
||||
"tlds>=2026041800",
|
||||
"tqdm==4.*",
|
||||
"ua-parser==1.0.*",
|
||||
"vobject==0.9.*",
|
||||
@@ -117,7 +117,7 @@ dev = [
|
||||
"isort==8.0.*",
|
||||
"pep8-naming==0.15.*",
|
||||
"potypo",
|
||||
"pytest-asyncio>=0.24",
|
||||
"pytest-asyncio>=1.3.0",
|
||||
"pytest-cache",
|
||||
"pytest-cov",
|
||||
"pytest-django==4.*",
|
||||
|
||||
@@ -31,7 +31,9 @@ from pretix.api.serializers.order import OrderPositionSerializer
|
||||
from pretix.api.serializers.organizer import (
|
||||
CustomerSerializer, GiftCardSerializer,
|
||||
)
|
||||
from pretix.base.models import Order, OrderPosition, ReusableMedium
|
||||
from pretix.base.models import (
|
||||
Device, Order, OrderPosition, ReusableMedium, TeamAPIToken,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -80,8 +82,7 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
|
||||
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
|
||||
# No additional permission check performed, documented limitation of the permission system
|
||||
# Would get to complex/unusable otherwise since the permission depends on the event
|
||||
# Permission Check performed in to_representation
|
||||
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
|
||||
else:
|
||||
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
|
||||
@@ -117,6 +118,27 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
return data
|
||||
|
||||
def to_representation(self, instance):
|
||||
r = super().to_representation(instance)
|
||||
request = self.context.get('request')
|
||||
# late permission evaluations for checks that depend on the actual linked events
|
||||
expand_nested = self.context['request'].query_params.getlist('expand')
|
||||
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
|
||||
if 'linked_orderposition' in expand_nested:
|
||||
if instance.linked_orderposition is not None:
|
||||
event = instance.linked_orderposition.order.event
|
||||
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
|
||||
r['linked_orderposition'] = {'id': instance.linked_orderposition.id}
|
||||
|
||||
if 'linked_giftcard.owner_ticket' in expand_nested:
|
||||
gc = instance.linked_giftcard
|
||||
if gc is not None and gc.owner_ticket is not None:
|
||||
event = gc.owner_ticket.order.event
|
||||
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
|
||||
r['linked_giftcard']['owner_ticket'] = {'id': instance.linked_giftcard.owner_ticket.id}
|
||||
|
||||
return r
|
||||
|
||||
class Meta:
|
||||
model = ReusableMedium
|
||||
fields = (
|
||||
|
||||
@@ -769,7 +769,11 @@ class PaymentDetailsField(serializers.Field):
|
||||
pp = value.payment_provider
|
||||
if not pp:
|
||||
return {}
|
||||
return pp.api_payment_details(value)
|
||||
try:
|
||||
return pp.api_payment_details(value)
|
||||
except Exception:
|
||||
logger.exception("Failed to retrieve payment_details")
|
||||
return {}
|
||||
|
||||
|
||||
class OrderPaymentSerializer(I18nAwareModelSerializer):
|
||||
|
||||
@@ -286,6 +286,19 @@ class GiftCardSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
return data
|
||||
|
||||
def to_representation(self, instance):
|
||||
r = super().to_representation(instance)
|
||||
request = self.context.get('request')
|
||||
# late permission evaluations for checks that depend on the actual linked events
|
||||
if 'owner_ticket' in self.context['request'].query_params.getlist('expand'):
|
||||
owner_ticket = instance.owner_ticket
|
||||
if owner_ticket:
|
||||
event = owner_ticket.order.event
|
||||
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
|
||||
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
|
||||
r['owner_ticket'] = {'id': instance.owner_ticket.id}
|
||||
return r
|
||||
|
||||
class Meta:
|
||||
model = GiftCard
|
||||
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket',
|
||||
|
||||
@@ -1122,7 +1122,7 @@ class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission = 'event.orders:read'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Checkin.all.filter().select_related(
|
||||
qs = Checkin.all.filter(list__event=self.request.event).select_related(
|
||||
"position",
|
||||
"device",
|
||||
)
|
||||
|
||||
@@ -381,12 +381,15 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
|
||||
return resp
|
||||
else:
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), order.code,
|
||||
provider.identifier, ct.extension
|
||||
return FileResponse(
|
||||
ct.file.file,
|
||||
filename='{}-{}-{}{}'.format(
|
||||
self.request.event.slug.upper(), order.code,
|
||||
provider.identifier, ct.extension
|
||||
),
|
||||
as_attachment=True,
|
||||
content_type=ct.type
|
||||
)
|
||||
return resp
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def mark_paid(self, request, **kwargs):
|
||||
@@ -1303,14 +1306,17 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
|
||||
raise NotFound()
|
||||
|
||||
ftype, ignored = mimetypes.guess_type(answer.file.name)
|
||||
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format(
|
||||
self.request.event.slug.upper(),
|
||||
pos.order.code,
|
||||
pos.positionid,
|
||||
os.path.basename(answer.file.name).split('.', 1)[1]
|
||||
return FileResponse(
|
||||
answer.file,
|
||||
filename='{}-{}-{}-{}'.format(
|
||||
self.request.event.slug.upper(),
|
||||
pos.order.code,
|
||||
pos.positionid,
|
||||
os.path.basename(answer.file.name).split('.', 1)[1]
|
||||
),
|
||||
as_attachment=True,
|
||||
content_type=ftype or 'application/binary'
|
||||
)
|
||||
return resp
|
||||
|
||||
@action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"])
|
||||
def printlog(self, request, **kwargs):
|
||||
@@ -1365,15 +1371,18 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
|
||||
if hasattr(image_file, 'seek'):
|
||||
image_file.seek(0)
|
||||
|
||||
resp = FileResponse(image_file, content_type=ftype or 'application/binary')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format(
|
||||
self.request.event.slug.upper(),
|
||||
pos.order.code,
|
||||
pos.positionid,
|
||||
key,
|
||||
extension,
|
||||
return FileResponse(
|
||||
image_file,
|
||||
filename='{}-{}-{}-{}.{}'.format(
|
||||
self.request.event.slug.upper(),
|
||||
pos.order.code,
|
||||
pos.positionid,
|
||||
key,
|
||||
extension,
|
||||
),
|
||||
as_attachment=True,
|
||||
content_type=ftype or 'application/binary'
|
||||
)
|
||||
return resp
|
||||
|
||||
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
|
||||
def download(self, request, output, **kwargs):
|
||||
@@ -1399,12 +1408,15 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
|
||||
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
|
||||
return resp
|
||||
else:
|
||||
resp = FileResponse(ct.file.file, content_type=ct.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), pos.order.code, pos.positionid,
|
||||
provider.identifier, ct.extension
|
||||
return FileResponse(
|
||||
ct.file.file,
|
||||
filename='{}-{}-{}-{}{}'.format(
|
||||
self.request.event.slug.upper(), pos.order.code, pos.positionid,
|
||||
provider.identifier, ct.extension
|
||||
),
|
||||
as_attachment=True,
|
||||
content_type=ct.type
|
||||
)
|
||||
return resp
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def regenerate_secrets(self, request, **kwargs):
|
||||
@@ -1646,6 +1658,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
count_waitinglist=False,
|
||||
force=request.data.get('force', False),
|
||||
send_mail=send_mail,
|
||||
ignore_date=True,
|
||||
)
|
||||
except Quota.QuotaExceededException:
|
||||
pass
|
||||
@@ -1681,7 +1694,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
auth=self.request.auth,
|
||||
count_waitinglist=False,
|
||||
send_mail=send_mail,
|
||||
force=force)
|
||||
force=force,
|
||||
ignore_date=True)
|
||||
except Quota.QuotaExceededException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except PaymentException as e:
|
||||
@@ -1986,9 +2000,12 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
if not invoice.file:
|
||||
raise RetryException()
|
||||
|
||||
resp = FileResponse(invoice.file.file, content_type='application/pdf')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
|
||||
return resp
|
||||
return FileResponse(
|
||||
invoice.file.file,
|
||||
filename='{}.pdf'.format(invoice.number),
|
||||
as_attachment=True,
|
||||
content_type='application/pdf'
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
def transmit(self, request, **kwargs):
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
# 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 ipaddress
|
||||
import logging
|
||||
import smtplib
|
||||
import socket
|
||||
from itertools import groupby
|
||||
from smtplib import SMTPResponseException
|
||||
from typing import TypeVar
|
||||
@@ -237,3 +240,80 @@ def base_renderers(sender, **kwargs):
|
||||
|
||||
def get_email_context(**kwargs):
|
||||
return PlaceholderContext(**kwargs).render_all()
|
||||
|
||||
|
||||
def create_connection(address, timeout=socket.getdefaulttimeout(),
|
||||
source_address=None, *, all_errors=False):
|
||||
# Taken from the python stdlib, extended with a check for local ips
|
||||
|
||||
host, port = address
|
||||
exceptions = []
|
||||
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
|
||||
af, socktype, proto, canonname, sa = res
|
||||
|
||||
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
|
||||
ip_addr = ipaddress.ip_address(sa[0])
|
||||
if ip_addr.is_multicast:
|
||||
raise socket.error(f"Request to multicast address {sa[0]} blocked")
|
||||
if ip_addr.is_loopback or ip_addr.is_link_local:
|
||||
raise socket.error(f"Request to local address {sa[0]} blocked")
|
||||
if ip_addr.is_private:
|
||||
raise socket.error(f"Request to private address {sa[0]} blocked")
|
||||
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(af, socktype, proto)
|
||||
if timeout is not socket.getdefaulttimeout():
|
||||
sock.settimeout(timeout)
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sa)
|
||||
# Break explicitly a reference cycle
|
||||
exceptions.clear()
|
||||
return sock
|
||||
|
||||
except socket.error as exc:
|
||||
if not all_errors:
|
||||
exceptions.clear() # raise only the last error
|
||||
exceptions.append(exc)
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
|
||||
if len(exceptions):
|
||||
try:
|
||||
if not all_errors:
|
||||
raise exceptions[0]
|
||||
raise ExceptionGroup("create_connection failed", exceptions)
|
||||
finally:
|
||||
# Break explicitly a reference cycle
|
||||
exceptions.clear()
|
||||
else:
|
||||
raise socket.error("getaddrinfo returns an empty list")
|
||||
|
||||
|
||||
class CheckPrivateNetworkMixin:
|
||||
# _get_socket taken 1:1 from smtplib, just with a call to our own create_connection
|
||||
def _get_socket(self, host, port, timeout):
|
||||
# This makes it simpler for SMTP_SSL to use the SMTP connect code
|
||||
# and just alter the socket connection bit.
|
||||
if timeout is not None and not timeout:
|
||||
raise ValueError('Non-blocking socket (timeout=0) is not supported')
|
||||
if self.debuglevel > 0:
|
||||
self._print_debug('connect: to', (host, port), self.source_address)
|
||||
return create_connection((host, port), timeout, self.source_address)
|
||||
|
||||
|
||||
class SMTP(CheckPrivateNetworkMixin, smtplib.SMTP):
|
||||
pass
|
||||
|
||||
|
||||
# SMTP used here instead of mixin, because smtp.SMTP_SSL._get_socket calls super()._get_socket and then wraps this socket
|
||||
# super()._get_socket needs to be our version from the mixin
|
||||
class SMTP_SSL(smtplib.SMTP_SSL, SMTP): # noqa: N801
|
||||
pass
|
||||
|
||||
|
||||
class CheckPrivateNetworkSmtpBackend(EmailBackend):
|
||||
@property
|
||||
def connection_class(self):
|
||||
return SMTP_SSL if self.use_ssl else SMTP
|
||||
|
||||
@@ -47,6 +47,7 @@ from django.utils.formats import localize
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.models.auth import PermissionHolder
|
||||
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
|
||||
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
|
||||
)
|
||||
@@ -59,11 +60,20 @@ class BaseExporter:
|
||||
This is the base class for all data exporters
|
||||
"""
|
||||
|
||||
def __init__(self, event, organizer, progress_callback=lambda v: None):
|
||||
def __init__(self, event, organizer, permission_holder: PermissionHolder=None, progress_callback=lambda v: None):
|
||||
"""
|
||||
:param event: Event context, can also be a queryset of events for multi-event exports
|
||||
:param organizer: Organizer context
|
||||
:param user: The user who triggered the export (or None).
|
||||
:param token: The API token that triggered the export (or None).
|
||||
:param device: The device that triggered the export (or None)
|
||||
:param progress_callback: Callback function with progress
|
||||
"""
|
||||
self.event = event
|
||||
self.organizer = organizer
|
||||
self.progress_callback = progress_callback
|
||||
self.is_multievent = isinstance(event, QuerySet)
|
||||
self.permission_holder = permission_holder
|
||||
if isinstance(event, QuerySet):
|
||||
self.events = event
|
||||
self.event = None
|
||||
@@ -180,7 +190,7 @@ class BaseExporter:
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_required_event_permission(cls) -> str:
|
||||
def get_required_event_permission(cls) -> Optional[str]:
|
||||
"""
|
||||
The permission level required to use this exporter for events. For multi-event-exports, this will be used
|
||||
to limit the selection of events. Will be ignored if the ``OrganizerLevelExportMixin`` mixin is used.
|
||||
@@ -195,7 +205,7 @@ class OrganizerLevelExportMixin:
|
||||
raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin")
|
||||
|
||||
@classmethod
|
||||
def get_required_organizer_permission(cls) -> str:
|
||||
def get_required_organizer_permission(cls) -> Optional[str]:
|
||||
"""
|
||||
The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to
|
||||
allow everyone with any access to the organizer.
|
||||
|
||||
@@ -1103,13 +1103,25 @@ class PaymentListExporter(ListExporter):
|
||||
def iterate_list(self, form_data):
|
||||
provider_names = dict(get_all_payment_providers())
|
||||
|
||||
i_numbers = Invoice.objects.filter(
|
||||
order=OuterRef('order_id'),
|
||||
).values('order').annotate(
|
||||
m=GroupConcat('full_invoice_no', delimiter=', ')
|
||||
).values(
|
||||
'm'
|
||||
).order_by()
|
||||
|
||||
payments = OrderPayment.objects.filter(
|
||||
order__event__in=self.events,
|
||||
state__in=form_data.get('payment_states', [])
|
||||
).annotate(
|
||||
order_invoice_numbers=Subquery(i_numbers, output_field=CharField()),
|
||||
).select_related('order').prefetch_related('order__event').order_by('created')
|
||||
refunds = OrderRefund.objects.filter(
|
||||
order__event__in=self.events,
|
||||
state__in=form_data.get('refund_states', [])
|
||||
).annotate(
|
||||
order_invoice_numbers=Subquery(i_numbers, output_field=CharField()),
|
||||
).select_related('order').prefetch_related('order__event').order_by('created')
|
||||
|
||||
if form_data.get('end_date_range'):
|
||||
@@ -1135,6 +1147,7 @@ class PaymentListExporter(ListExporter):
|
||||
headers = [
|
||||
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
|
||||
_('Status code'), _('Amount'), _('Payment method'), _('Comment'), _('Matching ID'), _('Payment details'),
|
||||
_('Invoice numbers'),
|
||||
]
|
||||
yield headers
|
||||
|
||||
@@ -1172,6 +1185,7 @@ class PaymentListExporter(ListExporter):
|
||||
obj.comment if isinstance(obj, OrderRefund) else "",
|
||||
matching_id,
|
||||
payment_details,
|
||||
obj.order_invoice_numbers,
|
||||
]
|
||||
yield row
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ from pretix.base.settings import (
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
|
||||
PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
|
||||
)
|
||||
from pretix.base.templatetags.rich_text import rich_text
|
||||
from pretix.base.templatetags.rich_text import URL_RE, rich_text
|
||||
from pretix.base.timemachine import time_machine_now
|
||||
from pretix.control.forms import (
|
||||
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
|
||||
@@ -227,9 +227,15 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
# bots.
|
||||
r'^[^$€/%§{}<>~]*$',
|
||||
message=_('Please do not use special characters in names.')
|
||||
),
|
||||
RegexValidator(
|
||||
URL_RE,
|
||||
inverse_match=True,
|
||||
message=_('Please do not use special characters in names.')
|
||||
)
|
||||
]
|
||||
}
|
||||
self.max_length = defaults['max_length']
|
||||
self.scheme_name = kwargs.pop('scheme')
|
||||
self.titles = kwargs.pop('titles')
|
||||
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
|
||||
@@ -287,7 +293,7 @@ class NamePartsFormField(forms.MultiValueField):
|
||||
if self.require_all_fields and not all(v for v in value):
|
||||
raise forms.ValidationError(self.error_messages['incomplete'], code='required')
|
||||
|
||||
if sum(len(v) for v in value.values() if v) > 250:
|
||||
if sum(len(v) for v in value.values() if v) > (self.max_length or 250):
|
||||
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
|
||||
|
||||
if value.get("salutation") == "empty":
|
||||
|
||||
@@ -70,6 +70,10 @@ def parse_csv(file, length=None, mode="strict", charset=None):
|
||||
except ImportError:
|
||||
charset = file.charset
|
||||
data = data.decode(charset or "utf-8", mode)
|
||||
|
||||
# remove stray linebreaks from the end of the file
|
||||
data = data.rstrip("\n")
|
||||
|
||||
# If the file was modified on a Mac, it only contains \r as line breaks
|
||||
if '\r' in data and '\n' not in data:
|
||||
data = data.replace('\r', '\n')
|
||||
|
||||
@@ -29,7 +29,9 @@ import inspect
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
|
||||
@@ -74,10 +76,14 @@ def _transactions_mark_order_dirty(order_id, using=None):
|
||||
if "PYTEST_CURRENT_TEST" in os.environ:
|
||||
# We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code
|
||||
# or not.
|
||||
for frame in inspect.stack():
|
||||
if 'pretix/base/models/orders' in frame.filename:
|
||||
for frame in inspect.stack()[1:]:
|
||||
if (
|
||||
'pretix/base/models/orders' in frame.filename
|
||||
or Path(frame.filename).is_relative_to(Path(django.__file__).parent)
|
||||
):
|
||||
# Ignore model- and django-internal code
|
||||
continue
|
||||
elif 'test_' in frame.filename or 'conftest.py in frame.filename':
|
||||
elif 'test_' in frame.filename or 'conftest.py' in frame.filename:
|
||||
return
|
||||
elif 'pretix/' in frame.filename or 'pretix_' in frame.filename:
|
||||
# This went through non-test code, let's consider it non-test
|
||||
|
||||
@@ -38,6 +38,7 @@ import operator
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from functools import reduce
|
||||
from typing import Protocol
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import (
|
||||
@@ -67,6 +68,14 @@ class EmailAddressTakenError(IntegrityError):
|
||||
pass
|
||||
|
||||
|
||||
class PermissionHolder(Protocol):
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||
...
|
||||
|
||||
def has_organizer_permission(self, organizer, perm_name=None, request=None):
|
||||
...
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
"""
|
||||
This is the user manager for our custom user model. See the User
|
||||
@@ -696,6 +705,18 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
return self.teams.exists()
|
||||
|
||||
|
||||
class UserWithStaffSession:
|
||||
# Wrapper around a User object with a staff session, implementing the PermissionHolder Protocol
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||
return True
|
||||
|
||||
def has_organizer_permission(self, organizer, perm_name=None, request=None):
|
||||
return True
|
||||
|
||||
|
||||
class UserKnownLoginSource(models.Model):
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
|
||||
agent_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
@@ -229,7 +229,7 @@ class Device(LoggedModel):
|
||||
"""
|
||||
return self._organizer_permission_set() if self.organizer == organizer else set()
|
||||
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||
"""
|
||||
Checks if this token is part of a team that grants access of type ``perm_name``
|
||||
to the event ``event``.
|
||||
@@ -238,6 +238,7 @@ class Device(LoggedModel):
|
||||
:param event: The event to check
|
||||
:param perm_name: The permission, e.g. ``event.orders:read``
|
||||
:param request: This parameter is ignored and only defined for compatibility reasons.
|
||||
:param session_key: This parameter is ignored and only defined for compatibility reasons.
|
||||
:return: bool
|
||||
"""
|
||||
has_event_access = (self.all_events and organizer == self.organizer) or (
|
||||
|
||||
@@ -715,6 +715,12 @@ class Event(EventMixin, LoggedModel):
|
||||
self.settings.name_scheme = 'given_family'
|
||||
self.settings.payment_banktransfer_invoice_immediately = True
|
||||
self.settings.low_availability_percentage = 10
|
||||
self.settings.mail_send_order_free_attendee = True
|
||||
self.settings.mail_send_order_placed_attendee = True
|
||||
self.settings.mail_send_order_paid_attendee = True
|
||||
self.settings.mail_send_order_approved_attendee = True
|
||||
self.settings.mail_send_order_approved_free_attendee = True
|
||||
self.settings.mail_text_download_reminder_attendee = True
|
||||
|
||||
@property
|
||||
def social_image(self):
|
||||
|
||||
@@ -590,7 +590,7 @@ class Order(LockModel, LoggedModel):
|
||||
not kwargs.get('force_save_with_deferred_fields', None) and
|
||||
(not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields))
|
||||
):
|
||||
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
|
||||
_fail("It is unsafe to call save() on an Order with deferred fields since we can't check if you missed "
|
||||
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
|
||||
"this.")
|
||||
|
||||
@@ -2841,7 +2841,7 @@ class OrderPosition(AbstractPosition):
|
||||
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
|
||||
_transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
|
||||
elif not kwargs.get('force_save_with_deferred_fields', None):
|
||||
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
|
||||
_fail("It is unsafe to call save() on an OrderPosition with deferred fields since we can't check if you missed "
|
||||
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
|
||||
"this.")
|
||||
|
||||
|
||||
@@ -319,6 +319,9 @@ class TeamQuerySet(models.QuerySet):
|
||||
def event_permission_q(cls, perm_name):
|
||||
from ..permissions import assert_valid_event_permission
|
||||
|
||||
if perm_name is None:
|
||||
return Q()
|
||||
|
||||
if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_EVENT_COMPAT: # legacy
|
||||
return reduce(operator.and_, [cls.event_permission_q(p) for p in OLD_TO_NEW_EVENT_COMPAT[perm_name]])
|
||||
assert_valid_event_permission(perm_name, allow_legacy=False)
|
||||
@@ -331,6 +334,9 @@ class TeamQuerySet(models.QuerySet):
|
||||
def organizer_permission_q(cls, perm_name):
|
||||
from ..permissions import assert_valid_organizer_permission
|
||||
|
||||
if perm_name is None:
|
||||
return Q()
|
||||
|
||||
if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_ORGANIZER_COMPAT: # legacy
|
||||
return reduce(operator.and_, [cls.organizer_permission_q(p) for p in OLD_TO_NEW_ORGANIZER_COMPAT[perm_name]])
|
||||
assert_valid_organizer_permission(perm_name, allow_legacy=False)
|
||||
@@ -550,7 +556,7 @@ class TeamAPIToken(models.Model):
|
||||
"""
|
||||
return self.team.organizer_permission_set() if self.team.organizer == organizer else set()
|
||||
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||
"""
|
||||
Checks if this token is part of a team that grants access of type ``perm_name``
|
||||
to the event ``event``.
|
||||
@@ -559,6 +565,7 @@ class TeamAPIToken(models.Model):
|
||||
:param event: The event to check
|
||||
:param perm_name: The permission, e.g. ``event.orders:read``
|
||||
:param request: This parameter is ignored and only defined for compatibility reasons.
|
||||
:param session_key: This parameter is ignored and only defined for compatibility reasons.
|
||||
:return: bool
|
||||
"""
|
||||
has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
|
||||
|
||||
+16
-5
@@ -54,7 +54,7 @@ from bidi import get_display
|
||||
from django.conf import settings
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Max, Min
|
||||
from django.db.models import Exists, Max, Min, OuterRef
|
||||
from django.db.models.fields.files import FieldFile
|
||||
from django.dispatch import receiver
|
||||
from django.utils.deconstruct import deconstructible
|
||||
@@ -76,7 +76,7 @@ from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.platypus import Paragraph
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Event, Order, OrderPosition, Question
|
||||
from pretix.base.models import Checkin, Event, Order, OrderPosition, Question
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import layout_image_variables, layout_text_variables
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
@@ -379,6 +379,13 @@ DEFAULT_VARIABLES = OrderedDict((
|
||||
str(p) for p in generate_compressed_addon_list(op, order, ev)
|
||||
])
|
||||
}),
|
||||
("checked_in_addons", {
|
||||
"label": _("List of Checked-In Add-Ons"),
|
||||
"editor_sample": _("Add-on 1\n2x Add-on 2"),
|
||||
"evaluate": lambda op, order, ev: "\n".join([
|
||||
str(p) for p in generate_compressed_addon_list(op, order, ev, only_checked_in=True)
|
||||
])
|
||||
}),
|
||||
("organizer", {
|
||||
"label": _("Organizer name"),
|
||||
"editor_sample": _("Event organizer company"),
|
||||
@@ -750,12 +757,16 @@ def get_program_times(op: OrderPosition, ev: Event):
|
||||
])
|
||||
|
||||
|
||||
def generate_compressed_addon_list(op, order, event):
|
||||
def generate_compressed_addon_list(op, order, event, only_checked_in=False):
|
||||
itemcount = defaultdict(int)
|
||||
addons = [p for p in (
|
||||
addon_qs = (
|
||||
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
|
||||
else op.addons.select_related('item', 'variation')
|
||||
) if not p.canceled]
|
||||
)
|
||||
if only_checked_in:
|
||||
addon_qs = addon_qs.filter(Exists(Checkin.objects.filter(position=OuterRef('pk'))), canceled=False)
|
||||
addons = [p for p in addon_qs if not p.canceled]
|
||||
|
||||
for pos in addons:
|
||||
itemcount[pos.item, pos.variation] += 1
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ SOURCE_NAMES = {
|
||||
None: _('European Central Bank'), # backwards-compatibility
|
||||
'eu:ecb:eurofxref-daily': _('European Central Bank'),
|
||||
'cz:cnb:rate-fixing-daily': _('Czech National Bank'),
|
||||
'pl:nbp:table-a': _('National Bank of Poland'),
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +50,7 @@ def fetch_rates(sender, **kwargs):
|
||||
source_tasks = {
|
||||
'eu:ecb:eurofxref-daily': fetch_ecb_rates,
|
||||
'cz:cnb:rate-fixing-daily': fetch_cnb_cz_rates,
|
||||
'pl:nbp:table-a': fetch_nbp_pl_rates,
|
||||
}
|
||||
|
||||
for source_name, task in source_tasks.items():
|
||||
@@ -144,3 +146,29 @@ def fetch_cnb_cz_rates():
|
||||
rate=rate,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.task()
|
||||
def fetch_nbp_pl_rates():
|
||||
"""
|
||||
Fetches currency rates from the Polish National Bank.
|
||||
"""
|
||||
r = requests.get("https://api.nbp.pl/api/exchangerates/tables/A/", headers={
|
||||
"Accept": "application/json",
|
||||
})
|
||||
r.raise_for_status()
|
||||
data = r.json()[0]
|
||||
|
||||
source_date = datetime.strptime(data["effectiveDate"], "%Y-%m-%d").date()
|
||||
|
||||
for r in data["rates"]:
|
||||
rate = Decimal(r["mid"]).quantize(Decimal('0.000001'))
|
||||
ExchangeRate.objects.update_or_create(
|
||||
source='pl:nbp:table-a',
|
||||
source_currency=r["code"],
|
||||
other_currency='PLN',
|
||||
defaults=dict(
|
||||
source_date=source_date,
|
||||
rate=rate,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -40,6 +40,7 @@ from pretix.base.models import (
|
||||
CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken,
|
||||
User, cachedfile_name,
|
||||
)
|
||||
from pretix.base.models.auth import UserWithStaffSession
|
||||
from pretix.base.models.exports import ScheduledOrganizerExport
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.services.tasks import (
|
||||
@@ -211,7 +212,12 @@ def init_event_exporters(event, user=None, token=None, device=None, request=None
|
||||
if not perm_holder.has_event_permission(event.organizer, event, permission_name, request) and not staff_session:
|
||||
continue
|
||||
|
||||
exporter: BaseExporter = response(event=event, organizer=event.organizer, **kwargs)
|
||||
exporter: BaseExporter = response(
|
||||
event=event,
|
||||
organizer=event.organizer,
|
||||
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if not exporter.available_for_user(user if user and user.is_authenticated else None):
|
||||
continue
|
||||
@@ -243,7 +249,12 @@ def init_organizer_exporters(
|
||||
continue
|
||||
|
||||
if issubclass(response, OrganizerLevelExportMixin):
|
||||
exporter: BaseExporter = response(event=Event.objects.none(), organizer=organizer, **kwargs)
|
||||
exporter: BaseExporter = response(
|
||||
event=Event.objects.none(),
|
||||
organizer=organizer,
|
||||
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
try:
|
||||
if not perm_holder.has_organizer_permission(organizer, response.get_required_organizer_permission(), request) and not staff_session:
|
||||
@@ -295,7 +306,12 @@ def init_organizer_exporters(
|
||||
if not _has_permission_on_any_team_cache[permission_name] and not staff_session:
|
||||
continue
|
||||
|
||||
exporter: BaseExporter = response(event=_event_list_cache[permission_name], organizer=organizer, **kwargs)
|
||||
exporter: BaseExporter = response(
|
||||
event=_event_list_cache[permission_name],
|
||||
organizer=organizer,
|
||||
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if not exporter.available_for_user(user if user and user.is_authenticated else None):
|
||||
continue
|
||||
|
||||
@@ -58,6 +58,7 @@ from pretix.base.invoicing.transmission import (
|
||||
from pretix.base.models import (
|
||||
ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
|
||||
)
|
||||
from pretix.base.models.orders import OrderPayment
|
||||
from pretix.base.models.tax import EU_CURRENCIES
|
||||
from pretix.base.services.tasks import (
|
||||
TransactionAwareProfiledEventTask, TransactionAwareTask,
|
||||
@@ -102,7 +103,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
|
||||
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
|
||||
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
|
||||
if lp and lp.payment_provider:
|
||||
if lp and lp.payment_provider and lp.state not in (OrderPayment.PAYMENT_STATE_FAILED, OrderPayment.PAYMENT_STATE_CANCELED):
|
||||
if 'payment' in inspect.signature(lp.payment_provider.render_invoice_text).parameters:
|
||||
payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp))
|
||||
else:
|
||||
@@ -204,6 +205,19 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
||||
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
|
||||
invoice.foreign_currency_rate_date = rate.source_date
|
||||
invoice.foreign_currency_source = 'cz:cnb:rate-fixing-daily'
|
||||
elif invoice.event.settings.invoice_eu_currencies == 'PLN' and invoice.event.currency != 'PLN':
|
||||
invoice.foreign_currency_display = 'PLN'
|
||||
if settings.FETCH_ECB_RATES:
|
||||
rate = ExchangeRate.objects.filter(
|
||||
source='pl:nbp:table-a',
|
||||
source_currency=invoice.event.currency,
|
||||
other_currency=invoice.foreign_currency_display,
|
||||
source_date__gt=now().date() - timedelta(days=7)
|
||||
).first()
|
||||
if rate:
|
||||
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
|
||||
invoice.foreign_currency_rate_date = rate.source_date
|
||||
invoice.foreign_currency_source = 'pl:nbp:table-a'
|
||||
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
|
||||
@@ -67,9 +67,9 @@ from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import get_language_without_region, language
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
from pretix.base.models import (
|
||||
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
|
||||
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
|
||||
Voucher,
|
||||
CartPosition, Device, Event, GiftCard, Item, ItemVariation, LogEntry,
|
||||
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
|
||||
SeatCategoryMapping, User, Voucher,
|
||||
)
|
||||
from pretix.base.models.event import SubEvent
|
||||
from pretix.base.models.orders import (
|
||||
@@ -1618,7 +1618,7 @@ class OrderChangeManager:
|
||||
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
|
||||
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
|
||||
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
|
||||
'valid_from', 'valid_until', 'is_bundled', 'result'))
|
||||
'valid_from', 'valid_until', 'is_bundled', 'result', 'count'))
|
||||
SplitOperation = namedtuple('SplitOperation', ('position',))
|
||||
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
|
||||
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
|
||||
@@ -1632,16 +1632,24 @@ class OrderChangeManager:
|
||||
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
|
||||
|
||||
class AddPositionResult:
|
||||
_position: Optional[OrderPosition]
|
||||
_positions: Optional[List[OrderPosition]]
|
||||
|
||||
def __init__(self):
|
||||
self._position = None
|
||||
self._positions = None
|
||||
|
||||
@property
|
||||
def position(self) -> OrderPosition:
|
||||
if self._position is None:
|
||||
if self._positions is None:
|
||||
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
|
||||
return self._position
|
||||
if len(self._positions) != 1:
|
||||
raise RuntimeError("More than one position created.")
|
||||
return self._positions[0]
|
||||
|
||||
@property
|
||||
def positions(self) -> List[OrderPosition]:
|
||||
if self._positions is None:
|
||||
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
|
||||
return self._positions
|
||||
|
||||
def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
|
||||
self.order = order
|
||||
@@ -1848,8 +1856,12 @@ class OrderChangeManager:
|
||||
|
||||
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
|
||||
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
|
||||
valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult':
|
||||
valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult':
|
||||
if count < 1:
|
||||
raise ValueError("Count must be positive")
|
||||
if isinstance(seat, str):
|
||||
if count > 1:
|
||||
raise ValueError("Cannot combine count > 1 with seat")
|
||||
if not seat:
|
||||
seat = None
|
||||
else:
|
||||
@@ -1903,14 +1915,14 @@ class OrderChangeManager:
|
||||
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
|
||||
self._invoice_dirty = True
|
||||
|
||||
self._totaldiff_guesstimate += price.gross
|
||||
self._quotadiff.update(new_quotas)
|
||||
self._totaldiff_guesstimate += price.gross * count
|
||||
self._quotadiff.update({q: count for q in new_quotas})
|
||||
if seat:
|
||||
self._seatdiff.update([seat])
|
||||
|
||||
result = self.AddPositionResult()
|
||||
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
|
||||
valid_from, valid_until, is_bundled, result))
|
||||
valid_from, valid_until, is_bundled, result, count))
|
||||
return result
|
||||
|
||||
def split(self, position: OrderPosition):
|
||||
@@ -2530,29 +2542,35 @@ class OrderChangeManager:
|
||||
secret_dirty.remove(position)
|
||||
position.save(update_fields=['canceled', 'secret'])
|
||||
elif isinstance(op, self.AddOperation):
|
||||
pos = OrderPosition.objects.create(
|
||||
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
||||
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
|
||||
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
|
||||
positionid=nextposid, subevent=op.subevent, seat=op.seat,
|
||||
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
|
||||
is_bundled=op.is_bundled,
|
||||
)
|
||||
nextposid += 1
|
||||
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
|
||||
'position': pos.pk,
|
||||
'item': op.item.pk,
|
||||
'variation': op.variation.pk if op.variation else None,
|
||||
'addon_to': op.addon_to.pk if op.addon_to else None,
|
||||
'price': op.price.gross,
|
||||
'positionid': pos.positionid,
|
||||
'membership': pos.used_membership_id,
|
||||
'subevent': op.subevent.pk if op.subevent else None,
|
||||
'seat': op.seat.pk if op.seat else None,
|
||||
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
|
||||
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
|
||||
})
|
||||
op.result._position = pos
|
||||
new_pos = []
|
||||
new_logs = []
|
||||
for i in range(op.count):
|
||||
pos = OrderPosition.objects.create(
|
||||
item=op.item, variation=op.variation, addon_to=op.addon_to,
|
||||
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
|
||||
tax_value=op.price.tax, tax_rule=op.item.tax_rule,
|
||||
positionid=nextposid, subevent=op.subevent, seat=op.seat,
|
||||
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
|
||||
is_bundled=op.is_bundled,
|
||||
)
|
||||
nextposid += 1
|
||||
new_pos.append(pos)
|
||||
new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
|
||||
'position': pos.pk,
|
||||
'item': op.item.pk,
|
||||
'variation': op.variation.pk if op.variation else None,
|
||||
'addon_to': op.addon_to.pk if op.addon_to else None,
|
||||
'price': op.price.gross,
|
||||
'positionid': pos.positionid,
|
||||
'membership': pos.used_membership_id,
|
||||
'subevent': op.subevent.pk if op.subevent else None,
|
||||
'seat': op.seat.pk if op.seat else None,
|
||||
'valid_from': op.valid_from.isoformat() if op.valid_from else None,
|
||||
'valid_until': op.valid_until.isoformat() if op.valid_until else None,
|
||||
}, save=False))
|
||||
|
||||
op.result._positions = new_pos
|
||||
LogEntry.bulk_create_and_postprocess(new_logs)
|
||||
elif isinstance(op, self.SplitOperation):
|
||||
position = position_cache.setdefault(op.position.pk, op.position)
|
||||
split_positions.append(position)
|
||||
@@ -2877,7 +2895,7 @@ class OrderChangeManager:
|
||||
return total
|
||||
|
||||
def _check_order_size(self):
|
||||
if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
|
||||
if (len(self.order.positions.all()) + sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
|
||||
raise OrderError(
|
||||
self.error_messages['max_order_size'] % {
|
||||
'max': settings.PRETIX_MAX_ORDER_SIZE,
|
||||
@@ -2938,7 +2956,7 @@ class OrderChangeManager:
|
||||
]) + len([
|
||||
o for o in self._operations if isinstance(o, self.SplitOperation)
|
||||
])
|
||||
adds = len([o for o in self._operations if isinstance(o, self.AddOperation)])
|
||||
adds = sum([o.count for o in self._operations if isinstance(o, self.AddOperation)])
|
||||
if current > 0 and current - cancels + adds < 1:
|
||||
raise OrderError(self.error_messages['complete_cancel'])
|
||||
|
||||
@@ -2985,17 +3003,18 @@ class OrderChangeManager:
|
||||
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
|
||||
fake_cart.remove(positions_to_fake_cart[op.position])
|
||||
elif isinstance(op, self.AddOperation):
|
||||
cp = CartPosition(
|
||||
event=self.event,
|
||||
item=op.item,
|
||||
variation=op.variation,
|
||||
used_membership=op.membership,
|
||||
subevent=op.subevent,
|
||||
seat=op.seat,
|
||||
)
|
||||
cp.override_valid_from = op.valid_from
|
||||
cp.override_valid_until = op.valid_until
|
||||
fake_cart.append(cp)
|
||||
for i in range(op.count):
|
||||
cp = CartPosition(
|
||||
event=self.event,
|
||||
item=op.item,
|
||||
variation=op.variation,
|
||||
used_membership=op.membership,
|
||||
subevent=op.subevent,
|
||||
seat=op.seat,
|
||||
)
|
||||
cp.override_valid_from = op.valid_from
|
||||
cp.override_valid_until = op.valid_until
|
||||
fake_cart.append(cp)
|
||||
try:
|
||||
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)
|
||||
except ValidationError as e:
|
||||
|
||||
@@ -574,6 +574,7 @@ DEFAULTS = {
|
||||
('True', _('Based on European Central Bank daily rates, whenever the invoice recipient is in an EU '
|
||||
'country that uses a different currency.')),
|
||||
('CZK', _('Based on Czech National Bank daily rates, whenever the invoice amount is not in CZK.')),
|
||||
('PLN', _('Based on National Bank of Poland daily rates, whenever the invoice amount is not in PLN.')),
|
||||
),
|
||||
),
|
||||
'serializer_kwargs': dict(
|
||||
@@ -582,6 +583,7 @@ DEFAULTS = {
|
||||
('True', _('Based on European Central Bank daily rates, whenever the invoice recipient is in an EU '
|
||||
'country that uses a different currency.')),
|
||||
('CZK', _('Based on Czech National Bank daily rates, whenever the invoice amount is not in CZK.')),
|
||||
('PLN', _('Based on National Bank of Poland daily rates, whenever the invoice amount is not in PLN.')),
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -331,6 +331,10 @@ class OtherOperationsForm(forms.Form):
|
||||
|
||||
|
||||
class OrderPositionAddForm(forms.Form):
|
||||
count = forms.IntegerField(
|
||||
label=_('Number of products to add'),
|
||||
initial=1,
|
||||
)
|
||||
itemvar = forms.ChoiceField(
|
||||
label=_('Product')
|
||||
)
|
||||
@@ -432,6 +436,10 @@ class OrderPositionAddForm(forms.Form):
|
||||
d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0]
|
||||
else:
|
||||
d['used_membership'] = None
|
||||
if d.get("count", 1) > 1 and d.get("seat"):
|
||||
raise ValidationError({
|
||||
"seat": _("You can not choose a seat when adding multiple products at once.")
|
||||
})
|
||||
return d
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ from django.forms import formset_factory
|
||||
from django.forms.utils import ErrorDict
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _, pgettext_lazy
|
||||
from i18nfield.forms import I18nInlineFormSet
|
||||
|
||||
from pretix.base.forms import I18nModelForm
|
||||
@@ -102,6 +102,16 @@ class SubEventBulkForm(SubEventForm):
|
||||
required=False,
|
||||
limit_choices=('date_from', 'date_to'),
|
||||
)
|
||||
skip_if_overlap = forms.BooleanField(
|
||||
label=pgettext_lazy('subevent', 'Skip dates that overlap with any existing date'),
|
||||
help_text=pgettext_lazy(
|
||||
'subevent',
|
||||
'This can be useful if all your dates happen in the same location and no repeated dates should '
|
||||
'be created in conflict with existing special events. This respects even inactive dates and works best if '
|
||||
'all dates have both a start and end time.'
|
||||
),
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs['event']
|
||||
|
||||
@@ -329,6 +329,7 @@
|
||||
{{ add_form.custom_error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% bootstrap_field add_form.count layout="control" %}
|
||||
{% bootstrap_field add_form.itemvar layout="control" %}
|
||||
{% bootstrap_field add_form.price addon_after=request.event.currency layout="control" %}
|
||||
{% if add_form.addon_to %}
|
||||
@@ -364,6 +365,7 @@
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="form-horizontal">
|
||||
{% bootstrap_field add_position_formset.empty_form.count layout="control" %}
|
||||
{% bootstrap_field add_position_formset.empty_form.itemvar layout="control" %}
|
||||
{% bootstrap_field add_position_formset.empty_form.price addon_after=request.event.currency layout="control" %}
|
||||
{% if add_position_formset.empty_form.addon_to %}
|
||||
|
||||
@@ -379,6 +379,8 @@
|
||||
<i class="fa fa-calendar"></i> {% trans "Add many time slots" %}</button>
|
||||
</p>
|
||||
</div>
|
||||
<hr />
|
||||
{% bootstrap_field form.skip_if_overlap layout="control" horizontal_label_class='sr-only' horizontal_field_class='col-md-12' %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
|
||||
@@ -763,12 +763,7 @@ class InvoicePreview(EventPermissionRequiredMixin, View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
fname, ftype, fcontent = build_preview_invoice_pdf(request.event)
|
||||
resp = HttpResponse(fcontent, content_type=ftype)
|
||||
if settings.DEBUG:
|
||||
# attachment is more secure as we're dealing with user-generated stuff here, but inline is much more convenient during debugging
|
||||
resp['Content-Disposition'] = 'inline; filename="{}"'.format(fname)
|
||||
resp._csp_ignore = True
|
||||
else:
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(fname)
|
||||
resp['Content-Disposition'] = 'inline; filename="{}"'.format(fname)
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
@@ -300,5 +300,4 @@ class SysReportView(AdministratorPermissionRequiredMixin, TemplateView):
|
||||
resp = HttpResponse(data)
|
||||
resp['Content-Type'] = mime
|
||||
resp['Content-Disposition'] = 'inline; filename="{}"'.format(name)
|
||||
resp._csp_ignore = True
|
||||
return resp
|
||||
|
||||
@@ -710,22 +710,26 @@ class OrderDownload(AsyncAction, OrderView):
|
||||
resp = HttpResponseRedirect(value.file.file.read())
|
||||
return resp
|
||||
else:
|
||||
resp = FileResponse(value.file.file, content_type=value.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
|
||||
self.output.identifier, value.extension
|
||||
return FileResponse(
|
||||
value.file.file,
|
||||
filename='{}-{}-{}-{}{}'.format(
|
||||
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
|
||||
self.output.identifier, value.extension
|
||||
),
|
||||
content_type=value.type
|
||||
)
|
||||
return resp
|
||||
elif isinstance(value, CachedCombinedTicket):
|
||||
if value.type == 'text/uri-list':
|
||||
resp = HttpResponseRedirect(value.file.file.read())
|
||||
return resp
|
||||
else:
|
||||
resp = FileResponse(value.file.file, content_type=value.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension
|
||||
return FileResponse(
|
||||
value.file.file,
|
||||
filename='{}-{}-{}{}'.format(
|
||||
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension
|
||||
),
|
||||
content_type=value.type
|
||||
)
|
||||
return resp
|
||||
else:
|
||||
return redirect(self.get_self_url())
|
||||
|
||||
@@ -1831,15 +1835,15 @@ class InvoiceDownload(EventPermissionRequiredMixin, View):
|
||||
return redirect(self.get_order_url())
|
||||
|
||||
try:
|
||||
resp = FileResponse(self.invoice.file.file, content_type='application/pdf')
|
||||
return FileResponse(
|
||||
self.invoice.file.file,
|
||||
filename='{}.pdf'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", self.invoice.number)),
|
||||
content_type='application/pdf'
|
||||
)
|
||||
except FileNotFoundError:
|
||||
invoice_pdf_task.apply(args=(self.invoice.pk,))
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
resp['Content-Disposition'] = 'inline; filename="{}.pdf"'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", self.invoice.number))
|
||||
resp._csp_ignore = True # Some browser's PDF readers do not work with CSP
|
||||
return resp
|
||||
|
||||
|
||||
class OrderExtend(OrderView):
|
||||
permission = 'event.orders:write'
|
||||
@@ -2059,12 +2063,13 @@ class OrderChange(OrderView):
|
||||
else:
|
||||
variation = None
|
||||
try:
|
||||
ocm.add_position(item, variation,
|
||||
f.cleaned_data['price'],
|
||||
f.cleaned_data.get('addon_to'),
|
||||
f.cleaned_data.get('subevent'),
|
||||
f.cleaned_data.get('seat'),
|
||||
f.cleaned_data.get('used_membership'))
|
||||
for i in range(f.cleaned_data.get("count", 1)):
|
||||
ocm.add_position(item, variation,
|
||||
f.cleaned_data['price'],
|
||||
f.cleaned_data.get('addon_to'),
|
||||
f.cleaned_data.get('subevent'),
|
||||
f.cleaned_data.get('seat'),
|
||||
f.cleaned_data.get('used_membership'))
|
||||
except OrderError as e:
|
||||
f.custom_error = str(e)
|
||||
return False
|
||||
|
||||
@@ -1322,7 +1322,7 @@ class DeviceUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
|
||||
def form_valid(self, form):
|
||||
if form.has_changed():
|
||||
self.object.log_action('pretix.device.changed', user=self.request.user, data={
|
||||
k: getattr(self.object, k) if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()]
|
||||
k: form.cleaned_data[k] if k != 'limit_events' else [e.id for e in form.cleaned_data[k]]
|
||||
for k in form.changed_data
|
||||
})
|
||||
|
||||
|
||||
@@ -263,12 +263,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
|
||||
|
||||
resp = HttpResponse(data, content_type=mimet)
|
||||
ftype = fname.split(".")[-1]
|
||||
if settings.DEBUG:
|
||||
# attachment is more secure as we're dealing with user-generated stuff here, but inline is much more convenient during debugging
|
||||
resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype)
|
||||
resp._csp_ignore = True
|
||||
else:
|
||||
resp['Content-Disposition'] = 'attachment; filename="ticket-preview.{}"'.format(ftype)
|
||||
resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype)
|
||||
return resp
|
||||
elif "data" in request.POST:
|
||||
if cf:
|
||||
@@ -309,6 +304,5 @@ class FontsCSSView(TemplateView):
|
||||
class PdfView(TemplateView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf")
|
||||
resp = FileResponse(cf.file, content_type='application/pdf')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
|
||||
resp = FileResponse(cf.file, filename=cf.filename, content_type='application/pdf')
|
||||
return resp
|
||||
|
||||
@@ -917,6 +917,35 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Asyn
|
||||
if len(subevents) > 100_000:
|
||||
raise ValidationError(_('Please do not create more than 100.000 dates at once.'))
|
||||
|
||||
if form.cleaned_data.get("skip_if_overlap") and subevents:
|
||||
def overlaps(a_from, a_to, b_from, b_to):
|
||||
if a_from == b_from:
|
||||
return True
|
||||
if a_from > b_from:
|
||||
# a starts after b
|
||||
# check if it starts before b ends
|
||||
return b_to and a_from < b_to
|
||||
# a starts before b
|
||||
# check if it ends before b starts
|
||||
return a_to and a_to > b_from
|
||||
|
||||
date_min = min(se.date_from for se in subevents)
|
||||
date_max = max(se.date_to or se.date_from for se in subevents)
|
||||
dates_existing = list(self.request.event.subevents.annotate(
|
||||
date_fromto=Coalesce('date_to', 'date_from'),
|
||||
).filter(
|
||||
date_from__lte=date_max,
|
||||
date_fromto__gte=date_min,
|
||||
).values('date_from', 'date_to'))
|
||||
subevents = [
|
||||
se for se in subevents if not any(
|
||||
overlaps(se.date_from, se.date_to, other['date_from'], other['date_to'])
|
||||
for other in dates_existing
|
||||
)
|
||||
]
|
||||
if not subevents:
|
||||
raise ValidationError(_('All dates would be skipped because they conflict with existing dates.'))
|
||||
|
||||
for i, se in enumerate(subevents):
|
||||
se.save(clear_cache=False)
|
||||
if i % 100 == 0:
|
||||
|
||||
@@ -316,7 +316,7 @@ def nav_context_list(request):
|
||||
page = 1
|
||||
|
||||
qs_events = request.user.get_events_with_any_permission(request).filter(
|
||||
Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query)
|
||||
Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query) | Q(domain__domainname__iexact=query)
|
||||
).annotate(
|
||||
min_from=Min('subevents__date_from'),
|
||||
max_from=Max('subevents__date_from'),
|
||||
@@ -331,7 +331,7 @@ def nav_context_list(request):
|
||||
else:
|
||||
qs_orga = Organizer.objects.filter(pk__in=request.user.teams.values_list('organizer', flat=True))
|
||||
if query:
|
||||
qs_orga = qs_orga.filter(Q(name__icontains=query) | Q(slug__icontains=query))
|
||||
qs_orga = qs_orga.filter(Q(name__icontains=query) | Q(slug__icontains=query) | Q(domains__domainname__iexact=query))
|
||||
qs_orga = qs_orga.annotate(
|
||||
n_events=Count("events")
|
||||
).order_by("-n_events")
|
||||
@@ -619,7 +619,7 @@ def checkinlist_select2(request, **kwargs):
|
||||
|
||||
qs = request.event.checkin_lists.select_related('subevent').filter(
|
||||
qf
|
||||
).order_by('name')
|
||||
).order_by('subevent__date_from', 'name', 'pk')
|
||||
|
||||
total = qs.count()
|
||||
pagesize = 20
|
||||
|
||||
@@ -19,12 +19,26 @@
|
||||
# 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 ipaddress
|
||||
import socket
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime
|
||||
from http import cookies
|
||||
|
||||
from django.conf import settings
|
||||
from PIL import Image
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.connection import HTTPConnection, HTTPSConnection
|
||||
from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool
|
||||
from urllib3.exceptions import (
|
||||
ConnectTimeoutError, HTTPError, LocationParseError, NameResolutionError,
|
||||
NewConnectionError,
|
||||
)
|
||||
from urllib3.util.connection import (
|
||||
_TYPE_SOCKET_OPTIONS, _set_socket_options, allowed_gai_family,
|
||||
)
|
||||
from urllib3.util.timeout import _DEFAULT_TIMEOUT
|
||||
|
||||
|
||||
def monkeypatch_vobject_performance():
|
||||
@@ -89,6 +103,123 @@ def monkeypatch_requests_timeout():
|
||||
HTTPAdapter.send = httpadapter_send
|
||||
|
||||
|
||||
def monkeypatch_urllib3_ssrf_protection():
|
||||
"""
|
||||
pretix allows HTTP requests to untrusted URLs, e.g. through webhooks or external API URLs. This is dangerous since
|
||||
it can allow access to private networks that should not be reachable by users ("server-side request forgery", SSRF).
|
||||
Validating URLs at submission is not sufficient, since with DNS rebinding an attacker can make a domain name pass
|
||||
validation and then resolve to a private IP address on actual execution. Unfortunately, there seems no clean solution
|
||||
to this in Python land, so we monkeypatch urllib3's connection management to check the IP address to be external
|
||||
*after* the DNS resolution.
|
||||
|
||||
This does not work when a global http(s) proxy is used, but in that scenario the proxy can perform the validation.
|
||||
"""
|
||||
if getattr(settings, "ALLOW_HTTP_TO_PRIVATE_NETWORKS", False):
|
||||
# Settings are not supposed to change during runtime, so we can optimize performance and complexity by skipping
|
||||
# this if not needed.
|
||||
return
|
||||
|
||||
def create_connection(
|
||||
address: tuple[str, int],
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
source_address: tuple[str, int] | None = None,
|
||||
socket_options: _TYPE_SOCKET_OPTIONS | None = None,
|
||||
) -> socket.socket:
|
||||
# This is copied from urllib3.util.connection v2.3.0
|
||||
host, port = address
|
||||
if host.startswith("["):
|
||||
host = host.strip("[]")
|
||||
err = None
|
||||
|
||||
# Using the value from allowed_gai_family() in the context of getaddrinfo lets
|
||||
# us select whether to work with IPv4 DNS records, IPv6 records, or both.
|
||||
# The original create_connection function always returns all records.
|
||||
family = allowed_gai_family()
|
||||
|
||||
try:
|
||||
host.encode("idna")
|
||||
except UnicodeError:
|
||||
raise LocationParseError(f"'{host}', label empty or too long") from None
|
||||
|
||||
for res in socket.getaddrinfo(host, port, family, socket.SOCK_STREAM):
|
||||
af, socktype, proto, canonname, sa = res
|
||||
|
||||
if not getattr(settings, "ALLOW_HTTP_TO_PRIVATE_NETWORKS", False):
|
||||
ip_addr = ipaddress.ip_address(sa[0])
|
||||
if ip_addr.is_multicast:
|
||||
raise HTTPError(f"Request to multicast address {sa[0]} blocked")
|
||||
if ip_addr.is_loopback or ip_addr.is_link_local:
|
||||
raise HTTPError(f"Request to local address {sa[0]} blocked")
|
||||
if ip_addr.is_private:
|
||||
raise HTTPError(f"Request to private address {sa[0]} blocked")
|
||||
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(af, socktype, proto)
|
||||
|
||||
# If provided, set socket level options before connecting.
|
||||
_set_socket_options(sock, socket_options)
|
||||
|
||||
if timeout is not _DEFAULT_TIMEOUT:
|
||||
sock.settimeout(timeout)
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sa)
|
||||
# Break explicitly a reference cycle
|
||||
err = None
|
||||
return sock
|
||||
|
||||
except OSError as _:
|
||||
err = _
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
|
||||
if err is not None:
|
||||
try:
|
||||
raise err
|
||||
finally:
|
||||
# Break explicitly a reference cycle
|
||||
err = None
|
||||
else:
|
||||
raise OSError("getaddrinfo returns an empty list")
|
||||
|
||||
class ProtectionMixin:
|
||||
def _new_conn(self) -> socket.socket:
|
||||
# This is 1:1 the version from urllib3.connection.HTTPConnection._new_conn v2.3.0
|
||||
# just with a call to our own create_connection
|
||||
try:
|
||||
sock = create_connection(
|
||||
(self._dns_host, self.port),
|
||||
self.timeout,
|
||||
source_address=self.source_address,
|
||||
socket_options=self.socket_options,
|
||||
)
|
||||
except socket.gaierror as e:
|
||||
raise NameResolutionError(self.host, self, e) from e
|
||||
except socket.timeout as e:
|
||||
raise ConnectTimeoutError(
|
||||
self,
|
||||
f"Connection to {self.host} timed out. (connect timeout={self.timeout})",
|
||||
) from e
|
||||
|
||||
except OSError as e:
|
||||
raise NewConnectionError(
|
||||
self, f"Failed to establish a new connection: {e}"
|
||||
) from e
|
||||
|
||||
sys.audit("http.client.connect", self, self.host, self.port)
|
||||
return sock
|
||||
|
||||
class ProtectedHTTPConnection(ProtectionMixin, HTTPConnection):
|
||||
pass
|
||||
|
||||
class ProtectedHTTPSConnection(ProtectionMixin, HTTPSConnection):
|
||||
pass
|
||||
|
||||
HTTPConnectionPool.ConnectionCls = ProtectedHTTPConnection
|
||||
HTTPSConnectionPool.ConnectionCls = ProtectedHTTPSConnection
|
||||
|
||||
|
||||
def monkeypatch_cookie_morsel():
|
||||
# See https://code.djangoproject.com/ticket/34613
|
||||
cookies.Morsel._flags.add("partitioned")
|
||||
@@ -99,4 +230,5 @@ def monkeypatch_all_at_ready():
|
||||
monkeypatch_vobject_performance()
|
||||
monkeypatch_pillow_safer()
|
||||
monkeypatch_requests_timeout()
|
||||
monkeypatch_urllib3_ssrf_protection()
|
||||
monkeypatch_cookie_morsel()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-30 11:22+0000\n"
|
||||
"PO-Revision-Date: 2026-03-18 12:23+0000\n"
|
||||
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
|
||||
"PO-Revision-Date: 2026-04-17 03:00+0000\n"
|
||||
"Last-Translator: Tim <plicnetwork@gmail.com>\n"
|
||||
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"es/>\n"
|
||||
"Language: es\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 5.17\n"
|
||||
|
||||
#: pretix/_base_settings.py:87
|
||||
msgid "English"
|
||||
@@ -4150,7 +4150,7 @@ msgstr "Se encontraron varios productos coincidentes."
|
||||
#: pretix/base/modelimport_vouchers.py:205 pretix/base/models/items.py:1257
|
||||
#: pretix/base/models/vouchers.py:266 pretix/base/models/waitinglist.py:99
|
||||
msgid "Product variation"
|
||||
msgstr "Variación del producto"
|
||||
msgstr "Variante de producto"
|
||||
|
||||
#: pretix/base/modelimport_orders.py:161
|
||||
msgid "The variation can be specified by its internal ID or full name."
|
||||
@@ -4312,7 +4312,7 @@ msgstr "Ya existe un vale de compra con este código."
|
||||
#: pretix/base/models/vouchers.py:199 pretix/control/views/vouchers.py:121
|
||||
#: pretix/presale/templates/pretixpresale/organizers/customer_membership.html:52
|
||||
msgid "Maximum usages"
|
||||
msgstr "Usos máximos"
|
||||
msgstr "Número máximo de usos"
|
||||
|
||||
#: pretix/base/modelimport_vouchers.py:79
|
||||
msgid "The maximum number of usages must be set."
|
||||
@@ -4333,14 +4333,14 @@ msgstr "Reservar entrada con cargo a la cuota"
|
||||
|
||||
#: pretix/base/modelimport_vouchers.py:127 pretix/base/models/vouchers.py:236
|
||||
msgid "Allow to bypass quota"
|
||||
msgstr "Permitir que se anule la cuota"
|
||||
msgstr "Permitir omitir la cuota"
|
||||
|
||||
#: pretix/base/modelimport_vouchers.py:135 pretix/base/models/vouchers.py:242
|
||||
#: pretix/control/templates/pretixcontrol/vouchers/bulk.html:44
|
||||
#: pretix/control/templates/pretixcontrol/vouchers/detail.html:70
|
||||
#: pretix/control/views/vouchers.py:121
|
||||
msgid "Price effect"
|
||||
msgstr "Efecto sobre los precios"
|
||||
msgstr "Efecto en el precio"
|
||||
|
||||
#: pretix/base/modelimport_vouchers.py:150
|
||||
#, python-brace-format
|
||||
@@ -4351,7 +4351,7 @@ msgstr ""
|
||||
|
||||
#: pretix/base/modelimport_vouchers.py:160 pretix/base/models/vouchers.py:248
|
||||
msgid "Voucher value"
|
||||
msgstr "Valor del vale de compra"
|
||||
msgstr "Valor del vale"
|
||||
|
||||
#: pretix/base/modelimport_vouchers.py:165
|
||||
msgid "It is pointless to set a value without a price effect."
|
||||
@@ -4394,7 +4394,7 @@ msgstr "Etiqueta"
|
||||
|
||||
#: pretix/base/modelimport_vouchers.py:334 pretix/base/models/vouchers.py:300
|
||||
msgid "Shows hidden products that match this voucher"
|
||||
msgstr "Mostrar los ocultados productos válidos con este vale de compra"
|
||||
msgstr "Muestra los productos ocultos vinculados a este vale"
|
||||
|
||||
#: pretix/base/modelimport_vouchers.py:343 pretix/base/models/vouchers.py:304
|
||||
msgid "Offer all add-on products for free when redeeming this voucher"
|
||||
@@ -7322,8 +7322,8 @@ msgid ""
|
||||
"If activated, a holder of this voucher code can buy tickets, even if there "
|
||||
"are none left."
|
||||
msgstr ""
|
||||
"Si se activa, un titular de este vale de compra puede comprar entradas, "
|
||||
"incluso si no queda ninguna."
|
||||
"Si se activa, el poseedor de este código de vale podrá comprar entradas "
|
||||
"incluso si no quedan existencias."
|
||||
|
||||
#: pretix/base/models/vouchers.py:257 pretix/control/forms/vouchers.py:69
|
||||
msgid ""
|
||||
@@ -7338,14 +7338,14 @@ msgstr ""
|
||||
|
||||
#: pretix/base/models/vouchers.py:268
|
||||
msgid "This variation of the product select above is being used."
|
||||
msgstr "Esta variación del producto seleccionado arriba está siendo utilizada."
|
||||
msgstr "Se aplica a la variante del producto seleccionado arriba."
|
||||
|
||||
#: pretix/base/models/vouchers.py:277
|
||||
msgid ""
|
||||
"If enabled, the voucher is valid for any product affected by this quota."
|
||||
msgstr ""
|
||||
"Si está habilitado, el vale de compra es válido para cualquier producto "
|
||||
"afectado por esta cuota."
|
||||
"Si se activa, el vale será válido para cualquier producto incluido en esta "
|
||||
"cuota."
|
||||
|
||||
#: pretix/base/models/vouchers.py:284
|
||||
msgid "Specific seat"
|
||||
@@ -7357,16 +7357,16 @@ msgid ""
|
||||
"same value for multiple vouchers, you can get statistics on how many of them "
|
||||
"have been redeemed etc."
|
||||
msgstr ""
|
||||
"Puede utilizar este campo para agrupar múltiples vales de compra. Si "
|
||||
"introduce el mismo valor para varios vales de compra, puede obtener "
|
||||
"estadísticas sobre cuántos de ellos se han canjeado, etc."
|
||||
"Puedes usar este campo para agrupar varios vales. Si introduces el mismo "
|
||||
"valor en distintos vales, podrás obtener estadísticas sobre cuántos se han "
|
||||
"canjeado, etc."
|
||||
|
||||
#: pretix/base/models/vouchers.py:316 pretix/base/permissions.py:242
|
||||
#: pretix/control/navigation.py:289
|
||||
#: pretix/control/templates/pretixcontrol/vouchers/index.html:6
|
||||
#: pretix/control/templates/pretixcontrol/vouchers/index.html:8
|
||||
msgid "Vouchers"
|
||||
msgstr "Vales de compra"
|
||||
msgstr "Vales"
|
||||
|
||||
#: pretix/base/models/vouchers.py:342
|
||||
msgid "You cannot select a quota that belongs to a different event."
|
||||
@@ -13331,7 +13331,7 @@ msgstr ""
|
||||
#: pretix/base/settings.py:4157
|
||||
#, python-brace-format
|
||||
msgid "VAT-ID is not supported for \"{}\"."
|
||||
msgstr ""
|
||||
msgstr "El NIF no es compatible con «{}»."
|
||||
|
||||
#: pretix/base/settings.py:4164
|
||||
msgid "The last payment date cannot be before the end of presale."
|
||||
@@ -13365,7 +13365,7 @@ msgstr "Esto eliminará todos los números de teléfono de los pedidos."
|
||||
|
||||
#: pretix/base/shredder.py:290
|
||||
msgid "Emails"
|
||||
msgstr "Correos electrónicos"
|
||||
msgstr "Correos"
|
||||
|
||||
#: pretix/base/shredder.py:292
|
||||
msgid ""
|
||||
@@ -15170,7 +15170,7 @@ msgstr "Todos los productos"
|
||||
#: pretix/control/views/typeahead.py:780
|
||||
#, python-brace-format
|
||||
msgid "{product} – Any variation"
|
||||
msgstr "{product} - Cualquier variación"
|
||||
msgstr "{product} – Cualquier variación"
|
||||
|
||||
#: pretix/control/forms/filter.py:566 pretix/control/forms/orders.py:862
|
||||
msgctxt "subevent"
|
||||
@@ -15469,7 +15469,7 @@ msgstr "Buscar vale de compra"
|
||||
#: pretix/control/views/vouchers.py:133
|
||||
#, python-brace-format
|
||||
msgid "Any product in quota \"{quota}\""
|
||||
msgstr "Cualquier producto del contingente \"{quota}\""
|
||||
msgstr "Cualquier producto en la cuota \"{quota}\""
|
||||
|
||||
#: pretix/control/forms/filter.py:2440
|
||||
msgid "Refund status"
|
||||
@@ -17068,15 +17068,15 @@ msgstr "ID de butaca específico"
|
||||
|
||||
#: pretix/control/forms/vouchers.py:200 pretix/presale/forms/waitinglist.py:103
|
||||
msgid "Invalid product selected."
|
||||
msgstr "Producto no válido seleccionado."
|
||||
msgstr "Se ha seleccionado un producto no válido."
|
||||
|
||||
#: pretix/control/forms/vouchers.py:225
|
||||
msgid ""
|
||||
"The voucher only matches hidden products but you have not selected that it "
|
||||
"should show them."
|
||||
msgstr ""
|
||||
"El vale de compra solo coincide con productos ocultos pero no has "
|
||||
"seleccionado que los muestre."
|
||||
"El vale solo coincide con productos ocultos, pero no has seleccionado que "
|
||||
"deba mostrarlos."
|
||||
|
||||
#: pretix/control/forms/vouchers.py:271
|
||||
msgid "Codes"
|
||||
@@ -21474,7 +21474,7 @@ msgstr ""
|
||||
#: pretix/plugins/ticketoutputpdf/views.py:172
|
||||
#: pretix/presale/views/customer.py:544 pretix/presale/views/customer.py:597
|
||||
msgid "Your changes have been saved."
|
||||
msgstr "Los cambios se han guardado."
|
||||
msgstr "Se han guardado los cambios."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/event/plugins.html:34
|
||||
#: pretix/control/templates/pretixcontrol/organizers/plugins.html:34
|
||||
@@ -27567,28 +27567,30 @@ msgid "Add a two-factor authentication device"
|
||||
msgstr "Añadir un dispositivo de autenticación de dos factores"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:19
|
||||
#, fuzzy
|
||||
#| msgid "Smartphone with the Authenticator application"
|
||||
msgid "Smartphone with Authenticator app"
|
||||
msgstr "Celular con aplicación de autenticación"
|
||||
msgstr "Smartphone con la aplicación Authenticator"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:21
|
||||
msgid ""
|
||||
"Use your smartphone with any Time-based One-Time-Password app like freeOTP, "
|
||||
"Google Authenticator or Proton Authenticator."
|
||||
msgstr ""
|
||||
"Use su smartphone con cualquier aplicación de contraseñas de un solo uso "
|
||||
"basadas en el tiempo, como freeOTP, Google Authenticator o Proton "
|
||||
"Authenticator."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:30
|
||||
#, fuzzy
|
||||
#| msgid "WebAuthn-compatible hardware token (e.g. Yubikey)"
|
||||
msgid "WebAuthn-compatible hardware token"
|
||||
msgstr "Hardware compatible con token WebAuthn (p. ej. Yubikey)"
|
||||
msgstr "Token físico compatible con WebAuthn"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:32
|
||||
msgid ""
|
||||
"Use a hardware token like the Yubikey, or other biometric authentication "
|
||||
"like fingerprint or face recognition."
|
||||
msgstr ""
|
||||
"Utiliza un dispositivo de seguridad físico, como el Yubikey, u otro método "
|
||||
"de autenticación biométrica, como el reconocimiento de huellas dactilares o "
|
||||
"facial."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_confirm_totp.html:8
|
||||
msgid "To set up this device, please follow the following steps:"
|
||||
@@ -28696,7 +28698,7 @@ msgstr "Se ha creado la nueva lista de asistentes."
|
||||
#: pretix/plugins/ticketoutputpdf/views.py:132
|
||||
msgid "We could not save your changes. See below for details."
|
||||
msgstr ""
|
||||
"No hemos podido guardar los cambios. Consulte los detalles a continuación."
|
||||
"No se pudieron guardar los cambios. Consulta los detalles a continuación."
|
||||
|
||||
#: pretix/control/views/checkin.py:421 pretix/control/views/checkin.py:458
|
||||
msgid "The requested list does not exist."
|
||||
@@ -30862,7 +30864,7 @@ msgstr ""
|
||||
|
||||
#: pretix/plugins/badges/forms.py:33
|
||||
msgid "Template"
|
||||
msgstr "Plantilla"
|
||||
msgstr "Template"
|
||||
|
||||
#: pretix/plugins/badges/forms.py:34
|
||||
msgid ""
|
||||
|
||||
@@ -4,10 +4,10 @@ msgstr ""
|
||||
"Project-Id-Version: 1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-30 11:22+0000\n"
|
||||
"PO-Revision-Date: 2026-03-18 12:23+0000\n"
|
||||
"PO-Revision-Date: 2026-03-31 17:00+0000\n"
|
||||
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
|
||||
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/fr/"
|
||||
">\n"
|
||||
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"fr/>\n"
|
||||
"Language: fr\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -13454,7 +13454,7 @@ msgstr ""
|
||||
#: pretix/base/settings.py:4157
|
||||
#, python-brace-format
|
||||
msgid "VAT-ID is not supported for \"{}\"."
|
||||
msgstr ""
|
||||
msgstr "Le numéro de TVA n'est pas pris en charge pour « {} »."
|
||||
|
||||
#: pretix/base/settings.py:4164
|
||||
msgid "The last payment date cannot be before the end of presale."
|
||||
@@ -27774,28 +27774,30 @@ msgid "Add a two-factor authentication device"
|
||||
msgstr "Ajouter un dispositif d'authentification à deux facteurs"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:19
|
||||
#, fuzzy
|
||||
#| msgid "Smartphone with the Authenticator application"
|
||||
msgid "Smartphone with Authenticator app"
|
||||
msgstr "Smartphone avec l'application Authenticator"
|
||||
msgstr "Smartphone équipé de l'application Authenticator"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:21
|
||||
msgid ""
|
||||
"Use your smartphone with any Time-based One-Time-Password app like freeOTP, "
|
||||
"Google Authenticator or Proton Authenticator."
|
||||
msgstr ""
|
||||
"Utilisez votre smartphone avec n'importe quelle application de mots de passe "
|
||||
"à usage unique générés en temps réel, comme freeOTP, Google Authenticator ou "
|
||||
"Proton Authenticator."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:30
|
||||
#, fuzzy
|
||||
#| msgid "WebAuthn-compatible hardware token (e.g. Yubikey)"
|
||||
msgid "WebAuthn-compatible hardware token"
|
||||
msgstr "Token matériel compatible WebAuthn (par ex. Yubikey)"
|
||||
msgstr "Token matériel compatible WebAuthn"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:32
|
||||
msgid ""
|
||||
"Use a hardware token like the Yubikey, or other biometric authentication "
|
||||
"like fingerprint or face recognition."
|
||||
msgstr ""
|
||||
"Utilisez une clé matérielle telle que la Yubikey, ou un autre moyen "
|
||||
"d'authentification biométrique, comme la reconnaissance d'empreintes "
|
||||
"digitales ou faciale."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_confirm_totp.html:8
|
||||
msgid "To set up this device, please follow the following steps:"
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-30 11:22+0000\n"
|
||||
"PO-Revision-Date: 2026-03-23 21:00+0000\n"
|
||||
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
|
||||
"PO-Revision-Date: 2026-04-20 08:07+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"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Weblate 5.16.2\n"
|
||||
"X-Generator: Weblate 5.17\n"
|
||||
|
||||
#: pretix/_base_settings.py:87
|
||||
msgid "English"
|
||||
@@ -664,7 +664,7 @@ msgstr "ギフトカードを取引で使用済み"
|
||||
#: pretix/plugins/banktransfer/payment.py:483
|
||||
#: pretix/presale/forms/customer.py:152
|
||||
msgid "This field is required."
|
||||
msgstr "この項目は必須です。"
|
||||
msgstr "このフィールドは必須です。"
|
||||
|
||||
#: pretix/base/addressvalidation.py:213
|
||||
msgid "Enter a postal code in the format XXX."
|
||||
@@ -3800,7 +3800,7 @@ msgstr "単価:{net_price} 税抜 / {gross_price} 税込"
|
||||
#, python-brace-format
|
||||
msgctxt "invoice"
|
||||
msgid "Single price: {price}"
|
||||
msgstr "単価:{price}"
|
||||
msgstr "単価: {price}"
|
||||
|
||||
#: pretix/base/invoicing/pdf.py:947 pretix/base/invoicing/pdf.py:952
|
||||
msgctxt "invoice"
|
||||
@@ -8206,7 +8206,7 @@ msgstr "参加者の呼びかけに使う名前"
|
||||
#: pretix/base/services/placeholders.py:732
|
||||
#: pretix/control/forms/organizer.py:799
|
||||
msgid "Mr Doe"
|
||||
msgstr "山田様"
|
||||
msgstr "山田 太郎"
|
||||
|
||||
#: pretix/base/pdf.py:672 pretix/base/pdf.py:679
|
||||
#: pretix/plugins/badges/exporters.py:501
|
||||
@@ -11001,7 +11001,7 @@ msgstr ""
|
||||
#: pretix/base/settings.py:1869 pretix/base/settings.py:1877
|
||||
#: pretix/presale/templates/pretixpresale/fragment_calendar_nav.html:8
|
||||
msgid "List"
|
||||
msgstr "リスト"
|
||||
msgstr "一覧"
|
||||
|
||||
#: pretix/base/settings.py:1870 pretix/base/settings.py:1878
|
||||
msgid "Week calendar"
|
||||
@@ -12855,7 +12855,7 @@ msgstr "Dr"
|
||||
|
||||
#: pretix/base/settings.py:3819 pretix/base/settings.py:3836
|
||||
msgid "First name"
|
||||
msgstr "名(First Name)"
|
||||
msgstr "名"
|
||||
|
||||
#: pretix/base/settings.py:3820 pretix/base/settings.py:3837
|
||||
msgid "Middle name"
|
||||
@@ -12939,7 +12939,7 @@ msgstr "企業名を必須にするには、請求先住所を必須にする必
|
||||
#: pretix/base/settings.py:4157
|
||||
#, python-brace-format
|
||||
msgid "VAT-ID is not supported for \"{}\"."
|
||||
msgstr ""
|
||||
msgstr "VAT-IDは「{}」に対してサポートされていません。"
|
||||
|
||||
#: pretix/base/settings.py:4164
|
||||
msgid "The last payment date cannot be before the end of presale."
|
||||
@@ -19423,7 +19423,7 @@ msgstr "カスタムチェックインルール"
|
||||
#: pretix/control/templates/pretixcontrol/vouchers/bulk.html:117
|
||||
#: pretix/plugins/sendmail/templates/pretixplugins/sendmail/send_form.html:85
|
||||
msgid "Edit"
|
||||
msgstr "編集する"
|
||||
msgstr "編集"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/checkin/list_edit.html:89
|
||||
msgid "Visualize"
|
||||
@@ -20280,7 +20280,7 @@ msgstr "地理座標"
|
||||
#: pretix/control/templates/pretixcontrol/subevents/bulk.html:271
|
||||
#: pretix/control/templates/pretixcontrol/subevents/bulk.html:275
|
||||
msgid "Optional"
|
||||
msgstr "オプション(必須でない項目)"
|
||||
msgstr "任意"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/event/fragment_geodata.html:22
|
||||
#: pretix/control/templates/pretixcontrol/subevents/bulk_edit.html:58
|
||||
@@ -23636,8 +23636,8 @@ msgid ""
|
||||
"this product was part of the discount calculation for a different product in "
|
||||
"this order."
|
||||
msgstr ""
|
||||
"この製品の価格は自動割引により減額されたか、この製品がこの注文の別の製品の割"
|
||||
"引計算の一部になっています。"
|
||||
"自動割引によりこの商品の価格が引き下げられたか、同じ注文内の別の商品に対する"
|
||||
"割引計算の対象になっています。"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/index.html:496
|
||||
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html:103
|
||||
@@ -25008,7 +25008,7 @@ msgstr "デバイスの概要"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/organizers/device_edit.html:6
|
||||
msgid "Device:"
|
||||
msgstr "デバイス:"
|
||||
msgstr "デバイス:"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/organizers/device_edit.html:8
|
||||
msgid "Connect a new device"
|
||||
@@ -25897,7 +25897,7 @@ msgstr "二要素認証が無効です"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/organizers/team_members.html:57
|
||||
msgid "invited, pending response"
|
||||
msgstr "招待済み、返答待ち"
|
||||
msgstr "招待済み、応答待ち"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/organizers/team_members.html:59
|
||||
msgid "resend invite"
|
||||
@@ -26796,8 +26796,6 @@ msgid "Add a two-factor authentication device"
|
||||
msgstr "2要素認証デバイスを追加してください"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:19
|
||||
#, fuzzy
|
||||
#| msgid "Smartphone with the Authenticator application"
|
||||
msgid "Smartphone with Authenticator app"
|
||||
msgstr "Authenticatorアプリを搭載したスマートフォン"
|
||||
|
||||
@@ -26806,18 +26804,20 @@ msgid ""
|
||||
"Use your smartphone with any Time-based One-Time-Password app like freeOTP, "
|
||||
"Google Authenticator or Proton Authenticator."
|
||||
msgstr ""
|
||||
"freeOTP、Google Authenticator、Proton Authenticator などの時間ベースの"
|
||||
"ワンタイムパスワードアプリをスマートフォンでご利用ください。"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:30
|
||||
#, fuzzy
|
||||
#| msgid "WebAuthn-compatible hardware token (e.g. Yubikey)"
|
||||
msgid "WebAuthn-compatible hardware token"
|
||||
msgstr "WebAuthn対応のハードウェアトークン(例:Yubikey)"
|
||||
msgstr "WebAuthn対応のハードウェアトークン"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:32
|
||||
msgid ""
|
||||
"Use a hardware token like the Yubikey, or other biometric authentication "
|
||||
"like fingerprint or face recognition."
|
||||
msgstr ""
|
||||
"Yubikey などのハードウェアトークンや、指紋や顔認識などの生体認証を使用してく"
|
||||
"ださい。"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_confirm_totp.html:8
|
||||
msgid "To set up this device, please follow the following steps:"
|
||||
@@ -33303,7 +33303,7 @@ msgstr "本当にStripeアカウントを切断しますか?"
|
||||
|
||||
#: pretix/plugins/stripe/templates/pretixplugins/stripe/oauth_disconnect.html:16
|
||||
msgid "Disconnect"
|
||||
msgstr "切断します"
|
||||
msgstr "切断"
|
||||
|
||||
#: pretix/plugins/stripe/templates/pretixplugins/stripe/pending.html:6
|
||||
msgid "Payment instructions"
|
||||
|
||||
@@ -7,10 +7,10 @@ msgstr ""
|
||||
"Project-Id-Version: 1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-30 11:22+0000\n"
|
||||
"PO-Revision-Date: 2026-03-18 12:23+0000\n"
|
||||
"PO-Revision-Date: 2026-03-31 17:00+0000\n"
|
||||
"Last-Translator: Ruud Hendrickx <ruud@leckxicon.eu>\n"
|
||||
"Language-Team: Dutch <https://translate.pretix.eu/projects/pretix/pretix/nl/"
|
||||
">\n"
|
||||
"Language-Team: Dutch <https://translate.pretix.eu/projects/pretix/pretix/nl/>"
|
||||
"\n"
|
||||
"Language: nl\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -13283,7 +13283,7 @@ msgstr ""
|
||||
#: pretix/base/settings.py:4157
|
||||
#, python-brace-format
|
||||
msgid "VAT-ID is not supported for \"{}\"."
|
||||
msgstr ""
|
||||
msgstr "Btw-nummer wordt niet ondersteund voor \"{}\"."
|
||||
|
||||
#: pretix/base/settings.py:4164
|
||||
msgid "The last payment date cannot be before the end of presale."
|
||||
@@ -27461,28 +27461,28 @@ msgid "Add a two-factor authentication device"
|
||||
msgstr "Twee-factor-authenticatieapparaat toevoegen"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:19
|
||||
#, fuzzy
|
||||
#| msgid "Smartphone with the Authenticator application"
|
||||
msgid "Smartphone with Authenticator app"
|
||||
msgstr "Smartphone met de Authenticator-applicatie"
|
||||
msgstr "Smartphone met Authenticator-app"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:21
|
||||
msgid ""
|
||||
"Use your smartphone with any Time-based One-Time-Password app like freeOTP, "
|
||||
"Google Authenticator or Proton Authenticator."
|
||||
msgstr ""
|
||||
"Gebruik uw smartphone met een willekeurige app voor tijdgebonden eenmalige "
|
||||
"wachtwoorden, zoals freeOTP, Google Authenticator of Proton Authenticator."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:30
|
||||
#, fuzzy
|
||||
#| msgid "WebAuthn-compatible hardware token (e.g. Yubikey)"
|
||||
msgid "WebAuthn-compatible hardware token"
|
||||
msgstr "WebAuthn-compatibel hardware-token (bijvoorbeeld Yubikey)"
|
||||
msgstr "WebAuthn-compatibel hardwaretoken"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:32
|
||||
msgid ""
|
||||
"Use a hardware token like the Yubikey, or other biometric authentication "
|
||||
"like fingerprint or face recognition."
|
||||
msgstr ""
|
||||
"Gebruik een hardwaretoken zoals de Yubikey, of een andere vorm van "
|
||||
"biometrische authenticatie, zoals vingerafdruk- of gezichtsherkenning."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_confirm_totp.html:8
|
||||
msgid "To set up this device, please follow the following steps:"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-30 11:22+0000\n"
|
||||
"PO-Revision-Date: 2026-03-18 14:50+0000\n"
|
||||
"PO-Revision-Date: 2026-03-31 17:00+0000\n"
|
||||
"Last-Translator: Ruud Hendrickx <ruud@leckxicon.eu>\n"
|
||||
"Language-Team: Dutch (informal) <https://translate.pretix.eu/projects/pretix/"
|
||||
"pretix/nl_Informal/>\n"
|
||||
@@ -13314,7 +13314,7 @@ msgstr ""
|
||||
#: pretix/base/settings.py:4157
|
||||
#, python-brace-format
|
||||
msgid "VAT-ID is not supported for \"{}\"."
|
||||
msgstr ""
|
||||
msgstr "Btw-nummer wordt niet ondersteund voor \"{}\"."
|
||||
|
||||
#: pretix/base/settings.py:4164
|
||||
msgid "The last payment date cannot be before the end of presale."
|
||||
@@ -27518,28 +27518,28 @@ msgid "Add a two-factor authentication device"
|
||||
msgstr "Twee-factor-authenticatieapparaat toevoegen"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:19
|
||||
#, fuzzy
|
||||
#| msgid "Smartphone with the Authenticator application"
|
||||
msgid "Smartphone with Authenticator app"
|
||||
msgstr "Smartphone met de Authenticator-applicatie"
|
||||
msgstr "Smartphone met Authenticator-app"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:21
|
||||
msgid ""
|
||||
"Use your smartphone with any Time-based One-Time-Password app like freeOTP, "
|
||||
"Google Authenticator or Proton Authenticator."
|
||||
msgstr ""
|
||||
"Gebruik je smartphone met een willekeurige app voor tijdgebonden eenmalige "
|
||||
"wachtwoorden, zoals freeOTP, Google Authenticator of Proton Authenticator."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:30
|
||||
#, fuzzy
|
||||
#| msgid "WebAuthn-compatible hardware token (e.g. Yubikey)"
|
||||
msgid "WebAuthn-compatible hardware token"
|
||||
msgstr "WebAuthn-compatibel hardware-token (bijvoorbeeld Yubikey)"
|
||||
msgstr "WebAuthn-compatibel hardwaretoken"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_add.html:32
|
||||
msgid ""
|
||||
"Use a hardware token like the Yubikey, or other biometric authentication "
|
||||
"like fingerprint or face recognition."
|
||||
msgstr ""
|
||||
"Gebruik een hardwaretoken zoals de Yubikey, of een andere vorm van "
|
||||
"biometrische authenticatie, zoals vingerafdruk- of gezichtsherkenning."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/user/2fa_confirm_totp.html:8
|
||||
msgid "To set up this device, please follow the following steps:"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-30 11:22+0000\n"
|
||||
"PO-Revision-Date: 2026-03-25 08:00+0000\n"
|
||||
"PO-Revision-Date: 2026-03-30 21:00+0000\n"
|
||||
"Last-Translator: Renne Rocha <renne@rocha.dev.br>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translate.pretix.eu/projects/"
|
||||
"pretix/pretix/pt_BR/>\n"
|
||||
@@ -19613,7 +19613,7 @@ msgstr "Excluir"
|
||||
#: pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/import_form.html:91
|
||||
#: pretix/presale/templates/pretixpresale/fragment_event_list_filter.html:22
|
||||
msgid "Filter"
|
||||
msgstr "Filtro"
|
||||
msgstr "Filtrar"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/checkin/checkins.html:50
|
||||
msgid "Your search did not match any check-ins."
|
||||
@@ -28003,6 +28003,9 @@ msgid ""
|
||||
"According to your event settings, sold out products are hidden from "
|
||||
"customers. This way, customers will not be able to discover the waiting list."
|
||||
msgstr ""
|
||||
"De acordo com as configurações do seu evento, os produtos esgotados ficam "
|
||||
"ocultos para os clientes. Dessa forma, os clientes não poderão descobrir a "
|
||||
"lista de espera."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/waitinglist/index.html:38
|
||||
msgid "Send vouchers"
|
||||
@@ -28049,6 +28052,9 @@ msgid ""
|
||||
"waiting list in, you could sell tickets worth an additional "
|
||||
"<strong>%(amount)s</strong>."
|
||||
msgstr ""
|
||||
"Se você conseguir criar espaço suficiente em seu evento para acomodar todas "
|
||||
"as pessoas na lista de espera, poderá vender ingressos no valor de um "
|
||||
"adicional de <strong>%(amount)s</strong>."
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/waitinglist/index.html:115
|
||||
msgid "Successfully redeemed"
|
||||
|
||||
@@ -216,7 +216,7 @@ class PayView(PaypalOrderView, TemplateView):
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
@event_permission_required('event.settings.general:write')
|
||||
@event_permission_required('event.settings.payment:write')
|
||||
def isu_return(request, *args, **kwargs):
|
||||
getparams = ['merchantId', 'merchantIdInPayPal', 'permissionsGranted', 'accountStatus', 'consentStatus', 'productIntentID', 'isEmailConfirmed']
|
||||
sessionparams = ['payment_paypal_isu_event', 'payment_paypal_isu_tracking_id']
|
||||
@@ -526,7 +526,7 @@ def webhook(request, *args, **kwargs):
|
||||
return HttpResponse(status=200)
|
||||
|
||||
|
||||
@event_permission_required('event.settings.general:write')
|
||||
@event_permission_required('event.settings.payment:write')
|
||||
@require_POST
|
||||
def isu_disconnect(request, **kwargs):
|
||||
del request.event.settings.payment_paypal_connect_refresh_token
|
||||
|
||||
@@ -32,11 +32,8 @@
|
||||
# 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 itertools import chain
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -168,46 +165,6 @@ class QuestionsForm(BaseQuestionsForm):
|
||||
)
|
||||
|
||||
|
||||
class AddOnRadioSelect(forms.RadioSelect):
|
||||
option_template_name = 'pretixpresale/forms/addon_choice_option.html'
|
||||
|
||||
def optgroups(self, name, value, attrs=None):
|
||||
attrs = attrs or {}
|
||||
groups = []
|
||||
has_selected = False
|
||||
for index, (option_value, option_label, option_desc) in enumerate(chain(self.choices)):
|
||||
if option_value is None:
|
||||
option_value = ''
|
||||
if isinstance(option_label, (list, tuple)):
|
||||
raise TypeError('Choice groups are not supported here')
|
||||
group_name = None
|
||||
subgroup = []
|
||||
groups.append((group_name, subgroup, index))
|
||||
|
||||
selected = (
|
||||
force_str(option_value) in value and
|
||||
(has_selected is False or self.allow_multiple_selected)
|
||||
)
|
||||
if selected is True and has_selected is False:
|
||||
has_selected = True
|
||||
attrs['description'] = option_desc
|
||||
subgroup.append(self.create_option(
|
||||
name, option_value, option_label, selected, index,
|
||||
subindex=None, attrs=attrs,
|
||||
))
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
class AddOnVariationField(forms.ChoiceField):
|
||||
def valid_value(self, value):
|
||||
text_value = force_str(value)
|
||||
for k, v, d in self.choices:
|
||||
if value == k or text_value == force_str(k):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class MembershipForm(forms.Form):
|
||||
required_css_class = 'required'
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ class AuthenticationForm(forms.Form):
|
||||
self.request = request
|
||||
self.customer_cache = None
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['password'].help_text = "<a href='{}'>{}</a>".format(
|
||||
self.fields['password'].help_text = "<a target='_blank' href='{}'>{}</a>".format(
|
||||
build_absolute_uri(False, 'presale:organizer.customer.resetpw', kwargs={
|
||||
'organizer': request.organizer.slug,
|
||||
}),
|
||||
@@ -172,7 +172,7 @@ class RegistrationForm(forms.Form):
|
||||
)
|
||||
|
||||
self.fields['name_parts'] = NamePartsFormField(
|
||||
max_length=255,
|
||||
max_length=70,
|
||||
required=True,
|
||||
scheme=request.organizer.settings.name_scheme,
|
||||
titles=request.organizer.settings.name_scheme_titles,
|
||||
@@ -296,7 +296,8 @@ class SetPasswordForm(forms.Form):
|
||||
}
|
||||
email = forms.EmailField(
|
||||
label=_('Email'),
|
||||
disabled=True
|
||||
widget=forms.EmailInput(attrs={'autocomplete': 'username', 'readonly': 'readonly'}),
|
||||
required=False,
|
||||
)
|
||||
password = forms.CharField(
|
||||
label=_('Password'),
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{% load rich_text %}
|
||||
<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>{% include "django/forms/widgets/input.html" %} {{ widget.label }}</label> {% if widget.attrs.description %}<span class="fa fa-info-circle toggle-variation-description" aria-hidden="true"></span>
|
||||
<div class="variation-description addon-variation-description">{{ widget.attrs.description|rich_text }}</div>{% endif %}
|
||||
@@ -91,6 +91,9 @@ event_patterns = [
|
||||
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/cart/add',
|
||||
csrf_exempt(pretix.presale.views.cart.CartAdd.as_view()),
|
||||
name='event.cart.add'),
|
||||
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/cart/create',
|
||||
csrf_exempt(pretix.presale.views.cart.CartCreate.as_view()),
|
||||
name='event.cart.create'),
|
||||
|
||||
re_path(r'unlock/(?P<hash>[a-z0-9]{64})/$', pretix.presale.views.user.UnlockHashView.as_view(),
|
||||
name='event.payment.unlock'),
|
||||
|
||||
@@ -70,18 +70,21 @@ def cached_invoice_address(request):
|
||||
# do not create a session, if we don't have a session we also don't have an invoice address ;)
|
||||
request._checkout_flow_invoice_address = InvoiceAddress()
|
||||
return request._checkout_flow_invoice_address
|
||||
cs = cart_session(request)
|
||||
iapk = cs.get('invoice_address')
|
||||
if not iapk:
|
||||
cs = cart_session(request, create=False)
|
||||
if cs is None:
|
||||
request._checkout_flow_invoice_address = InvoiceAddress()
|
||||
else:
|
||||
try:
|
||||
with scopes_disabled():
|
||||
request._checkout_flow_invoice_address = InvoiceAddress.objects.get(
|
||||
pk=iapk, order__isnull=True
|
||||
)
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
iapk = cs.get('invoice_address')
|
||||
if not iapk:
|
||||
request._checkout_flow_invoice_address = InvoiceAddress()
|
||||
else:
|
||||
try:
|
||||
with scopes_disabled():
|
||||
request._checkout_flow_invoice_address = InvoiceAddress.objects.get(
|
||||
pk=iapk, order__isnull=True
|
||||
)
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
request._checkout_flow_invoice_address = InvoiceAddress()
|
||||
return request._checkout_flow_invoice_address
|
||||
|
||||
|
||||
@@ -111,6 +114,14 @@ class CartMixin:
|
||||
return cached_invoice_address(self.request)
|
||||
|
||||
def get_cart(self, answers=False, queryset=None, order=None, downloads=False, payments=None):
|
||||
from pretix.presale.views.cart import get_or_create_cart_id
|
||||
|
||||
if not get_or_create_cart_id(self.request, create=False) and not order:
|
||||
# The user has no cart, so we can save a lot of work
|
||||
return {
|
||||
'positions': [],
|
||||
# Other keys are not used on non-checkout pages
|
||||
}
|
||||
if queryset is not None:
|
||||
prefetch = []
|
||||
if answers:
|
||||
@@ -166,7 +177,8 @@ class CartMixin:
|
||||
else:
|
||||
fees = []
|
||||
|
||||
if not order:
|
||||
if not order and lcp:
|
||||
# Do not re-round for empty cart (useless) or confirmed order (incorrect)
|
||||
apply_rounding(self.request.event.settings.tax_rounding, self.invoice_address, self.request.event.currency, [*lcp, *fees])
|
||||
|
||||
total = sum([c.price for c in lcp]) + sum([f.value for f in fees])
|
||||
@@ -277,6 +289,12 @@ class CartMixin:
|
||||
}
|
||||
|
||||
def current_selected_payments(self, positions, fees, invoice_address, *, warn=False):
|
||||
from pretix.presale.views.cart import get_or_create_cart_id
|
||||
|
||||
if not get_or_create_cart_id(self.request, create=False):
|
||||
# No active cart ID, no payments there
|
||||
return []
|
||||
|
||||
raw_payments = copy.deepcopy(self.cart_session.get('payments', []))
|
||||
fees = [f for f in fees if f.fee_type != OrderFee.FEE_TYPE_PAYMENT] # we re-compute these here
|
||||
|
||||
@@ -361,9 +379,13 @@ def cart_exists(request):
|
||||
from pretix.presale.views.cart import get_or_create_cart_id
|
||||
|
||||
if not hasattr(request, '_cart_cache'):
|
||||
return CartPosition.objects.filter(
|
||||
cart_id=get_or_create_cart_id(request), event=request.event
|
||||
).exists()
|
||||
cid = get_or_create_cart_id(request, create=False)
|
||||
if cid:
|
||||
return CartPosition.objects.filter(
|
||||
cart_id=cid, event=request.event
|
||||
).exists()
|
||||
else:
|
||||
return False
|
||||
return bool(request._cart_cache)
|
||||
|
||||
|
||||
|
||||
@@ -417,7 +417,7 @@ def get_or_create_cart_id(request, create=True):
|
||||
return new_id
|
||||
|
||||
|
||||
def cart_session(request):
|
||||
def cart_session(request, create=True):
|
||||
"""
|
||||
Before pretix 1.8.0, all checkout-related information (like the entered email address) was stored
|
||||
in the user's regular session dictionary. This led to data interference and leaks for example if a
|
||||
@@ -428,7 +428,9 @@ def cart_session(request):
|
||||
active cart session sub-dictionary for read and write access.
|
||||
"""
|
||||
request.session.modified = True
|
||||
cart_id = get_or_create_cart_id(request)
|
||||
cart_id = get_or_create_cart_id(request, create=create)
|
||||
if not cart_id and not create:
|
||||
return None
|
||||
return request.session['carts'][cart_id]
|
||||
|
||||
|
||||
@@ -553,6 +555,18 @@ class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
request.sales_channel.identifier, time_machine_now(default=None))
|
||||
|
||||
|
||||
@method_decorator(allow_cors_if_namespaced, 'dispatch')
|
||||
class CartCreate(EventViewMixin, CartActionMixin, View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
if 'ajax' in self.request.GET:
|
||||
cart_id = get_or_create_cart_id(self.request, create=True)
|
||||
return JsonResponse({
|
||||
'cart_id': cart_id,
|
||||
})
|
||||
else:
|
||||
return redirect_to_url(self.get_success_url())
|
||||
|
||||
|
||||
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
||||
class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||
task = extend_cart_reservation
|
||||
@@ -841,9 +855,13 @@ class AnswerDownload(EventViewMixin, View):
|
||||
return Http404()
|
||||
|
||||
ftype, _ = mimetypes.guess_type(answer.file.name)
|
||||
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-cart-{}"'.format(
|
||||
filename = '{}-cart-{}'.format(
|
||||
self.request.event.slug.upper(),
|
||||
os.path.basename(answer.file.name).split('.', 1)[1]
|
||||
).encode("ascii", "ignore")
|
||||
)
|
||||
resp = FileResponse(
|
||||
answer.file,
|
||||
filename=filename,
|
||||
content_type=ftype or 'application/binary'
|
||||
)
|
||||
return resp
|
||||
|
||||
@@ -681,8 +681,6 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
|
||||
context = {}
|
||||
context['list_type'] = self.request.GET.get("style", self.request.event.settings.event_list_type)
|
||||
if context['list_type'] not in ("calendar", "week") and self.request.event.subevents.filter(date_from__gt=time_machine_now()).count() > 50:
|
||||
if self.request.event.settings.event_list_type not in ("calendar", "week"):
|
||||
self.request.event.settings.event_list_type = "calendar"
|
||||
context['list_type'] = "calendar"
|
||||
|
||||
if context['list_type'] == "calendar":
|
||||
|
||||
@@ -1220,30 +1220,26 @@ class OrderDownloadMixin:
|
||||
resp = HttpResponseRedirect(value.file.file.read())
|
||||
return resp
|
||||
else:
|
||||
resp = FileResponse(value.file.file, content_type=value.type)
|
||||
if self.order_position.subevent:
|
||||
# Subevent date in filename improves accessibility e.g. for screen reader users
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
|
||||
self.order_position.subevent.date_from.strftime('%Y_%m_%d'),
|
||||
self.output.identifier, value.extension
|
||||
)
|
||||
else:
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
|
||||
self.output.identifier, value.extension
|
||||
)
|
||||
return resp
|
||||
name_parts = (
|
||||
self.request.event.slug.upper(),
|
||||
self.order.code,
|
||||
str(self.order_position.positionid),
|
||||
self.order_position.subevent.date_from.strftime('%Y_%m_%d') if self.order_position.subevent else None,
|
||||
self.output.identifier
|
||||
)
|
||||
filename = "-".join(filter(None, name_parts)) + value.extension
|
||||
return FileResponse(value.file.file, filename=filename, content_type=value.type)
|
||||
elif isinstance(value, CachedCombinedTicket):
|
||||
if value.type == 'text/uri-list':
|
||||
resp = HttpResponseRedirect(value.file.file.read())
|
||||
return resp
|
||||
else:
|
||||
resp = FileResponse(value.file.file, content_type=value.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
|
||||
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension
|
||||
return FileResponse(
|
||||
value.file.file,
|
||||
filename="{}-{}-{}{}".format(
|
||||
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension),
|
||||
content_type=value.type
|
||||
)
|
||||
return resp
|
||||
else:
|
||||
return redirect(self.get_self_url())
|
||||
|
||||
@@ -1383,13 +1379,14 @@ class InvoiceDownload(EventViewMixin, OrderDetailMixin, View):
|
||||
return redirect(self.get_order_url())
|
||||
|
||||
try:
|
||||
resp = FileResponse(invoice.file.file, content_type='application/pdf')
|
||||
return FileResponse(
|
||||
invoice.file.file,
|
||||
filename='{}.pdf'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", invoice.number)),
|
||||
content_type='application/pdf'
|
||||
)
|
||||
except FileNotFoundError:
|
||||
invoice_pdf_task.apply(args=(invoice.pk,))
|
||||
return self.get(request, *args, **kwargs)
|
||||
resp['Content-Disposition'] = 'inline; filename="{}.pdf"'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", invoice.number))
|
||||
resp._csp_ignore = True # Some browser's PDF readers do not work with CSP
|
||||
return resp
|
||||
|
||||
|
||||
class OrderChangeMixin:
|
||||
|
||||
@@ -66,22 +66,27 @@ class WaitingView(EventViewMixin, FormView):
|
||||
if customer else None
|
||||
),
|
||||
)
|
||||
choices = []
|
||||
groups = {}
|
||||
for i in items:
|
||||
if not i.allow_waitinglist:
|
||||
continue
|
||||
|
||||
category_name = str(i.category.name) if i.category else ''
|
||||
group = groups.setdefault(category_name, [])
|
||||
|
||||
if i.has_variations:
|
||||
for v in i.available_variations:
|
||||
if v.cached_availability[0] == Quota.AVAILABILITY_OK:
|
||||
continue
|
||||
choices.append((f'{i.pk}-{v.pk}', f'{i.name} – {v.value}'))
|
||||
group.append((f'{i.pk}-{v.pk}', f'{i.name} – {v.value}'))
|
||||
|
||||
else:
|
||||
if i.cached_availability[0] == Quota.AVAILABILITY_OK:
|
||||
continue
|
||||
choices.append((f'{i.pk}', f'{i.name}'))
|
||||
return choices
|
||||
group.append((f'{i.pk}', f'{i.name}'))
|
||||
|
||||
# Remove categories where all items were available (no waiting list choices)
|
||||
return [(cat, choices) for cat, choices in groups.items() if choices]
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
|
||||
@@ -530,12 +530,10 @@ class WidgetAPIProductList(EventListMixin, View):
|
||||
]
|
||||
|
||||
if hasattr(self.request, 'event') and data['list_type'] not in ("calendar", "week"):
|
||||
# only allow list-view of more than 50 subevents if ordering is by data as this can be done in the database
|
||||
# only allow list-view of more than 50 subevents if ordering is by date as this can be done in the database
|
||||
# ordering by name is currently not supported in database due to I18NField-JSON
|
||||
ordering = self.request.event.settings.get('frontpage_subevent_ordering', default='date_ascending', as_type=str)
|
||||
if ordering not in ("date_ascending", "date_descending") and self.request.event.subevents.filter(date_from__gt=now()).count() > 50:
|
||||
if self.request.event.settings.event_list_type not in ("calendar", "week"):
|
||||
self.request.event.settings.event_list_type = "calendar"
|
||||
data['list_type'] = list_type = 'calendar'
|
||||
|
||||
if hasattr(self.request, 'event'):
|
||||
|
||||
+19
-2
@@ -157,7 +157,7 @@ DATABASES = {
|
||||
'HOST': config.get('database', 'host', fallback=''),
|
||||
'PORT': config.get('database', 'port', fallback=''),
|
||||
'CONN_MAX_AGE': 0 if db_backend == 'sqlite3' else 120,
|
||||
'CONN_HEALTH_CHECKS': db_backend != 'sqlite3', # Will only be used from Django 4.1 onwards
|
||||
'CONN_HEALTH_CHECKS': db_backend != 'sqlite3',
|
||||
'DISABLE_SERVER_SIDE_CURSORS': db_disable_server_side_cursors,
|
||||
'OPTIONS': db_options,
|
||||
'TEST': {}
|
||||
@@ -179,6 +179,21 @@ if config.has_section('replica'):
|
||||
}
|
||||
DATABASE_ROUTERS = ['pretix.helpers.database.ReplicaRouter']
|
||||
|
||||
if config.has_section('dbreadonly'):
|
||||
DATABASES['readonly'] = {
|
||||
'ENGINE': 'django.db.backends.' + db_backend,
|
||||
'NAME': config.get('dbreadonly', 'name', fallback=DATABASES['default']['NAME']),
|
||||
'USER': config.get('dbreadonly', 'user', fallback=DATABASES['default']['USER']),
|
||||
'PASSWORD': config.get('dbreadonly', 'password', fallback=DATABASES['default']['PASSWORD']),
|
||||
'HOST': config.get('dbreadonly', 'host', fallback=DATABASES['default']['HOST']),
|
||||
'PORT': config.get('dbreadonly', 'port', fallback=DATABASES['default']['PORT']),
|
||||
'CONN_MAX_AGE': 0, # do not spam primary with open connections as long as readonly is only used occasionally
|
||||
'CONN_HEALTH_CHECKS': db_backend != 'sqlite3',
|
||||
'DISABLE_SERVER_SIDE_CURSORS': db_disable_server_side_cursors,
|
||||
'OPTIONS': db_options,
|
||||
'TEST': {}
|
||||
}
|
||||
|
||||
STATIC_URL = config.get('urls', 'static', fallback='/static/')
|
||||
|
||||
MEDIA_URL = config.get('urls', 'media', fallback='/media/')
|
||||
@@ -208,6 +223,7 @@ CSRF_TRUSTED_ORIGINS = [urlparse(SITE_URL).scheme + '://' + urlparse(SITE_URL).h
|
||||
|
||||
TRUST_X_FORWARDED_FOR = config.getboolean('pretix', 'trust_x_forwarded_for', fallback=False)
|
||||
USE_X_FORWARDED_HOST = config.getboolean('pretix', 'trust_x_forwarded_host', fallback=False)
|
||||
ALLOW_HTTP_TO_PRIVATE_NETWORKS = config.getboolean('pretix', 'allow_http_to_private_networks', fallback=False)
|
||||
|
||||
|
||||
REQUEST_ID_HEADER = config.get('pretix', 'request_id_header', fallback=False)
|
||||
@@ -248,7 +264,8 @@ EMAIL_HOST_PASSWORD = config.get('mail', 'password', fallback='')
|
||||
EMAIL_USE_TLS = config.getboolean('mail', 'tls', fallback=False)
|
||||
EMAIL_USE_SSL = config.getboolean('mail', 'ssl', fallback=False)
|
||||
EMAIL_SUBJECT_PREFIX = '[pretix] '
|
||||
EMAIL_BACKEND = EMAIL_CUSTOM_SMTP_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_CUSTOM_SMTP_BACKEND = 'pretixbase.email.CheckPrivateNetworkSmtpBackend'
|
||||
EMAIL_TIMEOUT = 60
|
||||
|
||||
ADMINS = [('Admin', n) for n in config.get('mail', 'admins', fallback='').split(",") if n]
|
||||
|
||||
+12
-13
@@ -1835,10 +1835,9 @@
|
||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"license": "MIT",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -2879,9 +2878,9 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
@@ -4936,9 +4935,9 @@
|
||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -5715,9 +5714,9 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="
|
||||
},
|
||||
"pify": {
|
||||
"version": "4.0.1",
|
||||
|
||||
@@ -71,6 +71,7 @@ $(document).ajaxError(function (event, jqXHR, settings, thrownError) {
|
||||
});
|
||||
|
||||
var form_handlers = function (el) {
|
||||
el.trigger("rescan.areYouSure");
|
||||
el.find("[data-formset]").formset(
|
||||
{
|
||||
animateForms: true,
|
||||
|
||||
@@ -110,6 +110,10 @@ var setCookie = function (cname, cvalue, exdays) {
|
||||
var d = new Date();
|
||||
d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
|
||||
var expires = "expires=" + d.toUTCString();
|
||||
if (!cvalue) {
|
||||
var expires = "expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
cvalue = "";
|
||||
}
|
||||
document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
|
||||
};
|
||||
var getCookie = function (name) {
|
||||
@@ -726,17 +730,16 @@ var shared_methods = {
|
||||
buy_callback: function (data) {
|
||||
if (data.redirect) {
|
||||
if (data.cart_id) {
|
||||
this.$root.cart_id = data.cart_id;
|
||||
setCookie(this.$root.cookieName, data.cart_id, 30);
|
||||
this.$root.set_cart_id(data.cart_id);
|
||||
}
|
||||
if (data.redirect.substr(0, 1) === '/') {
|
||||
data.redirect = this.$root.target_url.replace(/^([^\/]+:\/\/[^\/]+)\/.*$/, "$1") + data.redirect;
|
||||
}
|
||||
var url = data.redirect;
|
||||
if (url.indexOf('?')) {
|
||||
url = url + '&iframe=1&locale=' + lang + '&take_cart_id=' + this.$root.cart_id;
|
||||
url = url + '&iframe=1&locale=' + lang + '&take_cart_id=' + encodeURIComponent(this.$root.get_cart_id());
|
||||
} else {
|
||||
url = url + '?iframe=1&locale=' + lang + '&take_cart_id=' + this.$root.cart_id;
|
||||
url = url + '?iframe=1&locale=' + lang + '&take_cart_id=' + encodeURIComponent(this.$root.get_cart_id());
|
||||
}
|
||||
url += this.$root.consent_parameter;
|
||||
if (this.$root.additionalURLParams) {
|
||||
@@ -779,15 +782,24 @@ var shared_methods = {
|
||||
}
|
||||
},
|
||||
resume: function () {
|
||||
if (!this.$root.get_cart_id() && this.$root.keep_cart) {
|
||||
// create an empty cart whose id we can persist
|
||||
this.$root.create_cart(this.resume)
|
||||
return;
|
||||
}
|
||||
var redirect_url;
|
||||
redirect_url = this.$root.target_url + 'w/' + widget_id + '/';
|
||||
if (this.$root.subevent && !this.$root.cart_id) {
|
||||
if (this.$root.subevent && this.$root.is_button && this.$root.items.length === 0) {
|
||||
// button with subevent but no items
|
||||
redirect_url += this.$root.subevent + '/';
|
||||
}
|
||||
redirect_url += '?iframe=1&locale=' + lang;
|
||||
if (this.$root.cart_id) {
|
||||
redirect_url += '&take_cart_id=' + this.$root.cart_id;
|
||||
if (this.$root.get_cart_id()) {
|
||||
redirect_url += '&take_cart_id=' + encodeURIComponent(this.$root.get_cart_id());
|
||||
if (this.$root.keep_cart) {
|
||||
// make sure the cart-id is used, even if the cart is currently empty
|
||||
redirect_url += '&ajax=1'
|
||||
}
|
||||
}
|
||||
if (this.$root.widget_data) {
|
||||
redirect_url += '&widget_data=' + encodeURIComponent(this.$root.widget_data_json);
|
||||
@@ -1864,12 +1876,11 @@ var shared_root_methods = {
|
||||
if (this.$root.variation_filter) {
|
||||
url += '&variations=' + encodeURIComponent(this.$root.variation_filter);
|
||||
}
|
||||
var cart_id = getCookie(this.cookieName);
|
||||
if (this.$root.voucher_code) {
|
||||
url += '&voucher=' + encodeURIComponent(this.$root.voucher_code);
|
||||
}
|
||||
if (cart_id) {
|
||||
url += "&cart_id=" + encodeURIComponent(cart_id);
|
||||
if (this.$root.get_cart_id()) {
|
||||
url += "&cart_id=" + encodeURIComponent(this.$root.get_cart_id());
|
||||
}
|
||||
if (this.$root.date !== null) {
|
||||
url += "&date=" + this.$root.date.substr(0, 7);
|
||||
@@ -1939,7 +1950,6 @@ var shared_root_methods = {
|
||||
root.display_add_to_cart = data.display_add_to_cart;
|
||||
root.waiting_list_enabled = data.waiting_list_enabled;
|
||||
root.show_variations_expanded = data.show_variations_expanded || !!root.variation_filter;
|
||||
root.cart_id = cart_id;
|
||||
root.cart_exists = data.cart_exists;
|
||||
root.vouchers_exist = data.vouchers_exist;
|
||||
root.has_seating_plan = data.has_seating_plan;
|
||||
@@ -2004,8 +2014,8 @@ var shared_root_methods = {
|
||||
if (this.$root.voucher_code) {
|
||||
redirect_url += '&voucher=' + encodeURIComponent(this.$root.voucher_code);
|
||||
}
|
||||
if (this.$root.cart_id) {
|
||||
redirect_url += '&take_cart_id=' + this.$root.cart_id;
|
||||
if (this.$root.get_cart_id()) {
|
||||
redirect_url += '&take_cart_id=' + encodeURIComponent(this.$root.get_cart_id());
|
||||
}
|
||||
if (this.$root.widget_data) {
|
||||
redirect_url += '&widget_data=' + encodeURIComponent(this.$root.widget_data_json);
|
||||
@@ -2027,7 +2037,28 @@ var shared_root_methods = {
|
||||
this.$root.subevent = event.subevent;
|
||||
this.$root.loading++;
|
||||
this.$root.reload();
|
||||
}
|
||||
},
|
||||
create_cart: function(callback) {
|
||||
var url = this.$root.target_url + 'w/' + widget_id + '/cart/create?ajax=1';
|
||||
|
||||
this.$root.overlay.frame_loading = true;
|
||||
api._getJSON(url, (data) => {
|
||||
this.$root.set_cart_id(data.cart_id);
|
||||
this.$root.overlay.frame_loading = false;
|
||||
callback()
|
||||
}, () => {
|
||||
this.$root.overlay.error_message = strings['cart_error'];
|
||||
this.$root.overlay.frame_loading = false;
|
||||
})
|
||||
},
|
||||
get_cart_id: function() {
|
||||
if (this.$root.keep_cart) {
|
||||
return getCookie(this.$root.cookieName);
|
||||
}
|
||||
},
|
||||
set_cart_id: function(newValue) {
|
||||
setCookie(this.$root.cookieName, newValue, 30);
|
||||
},
|
||||
};
|
||||
|
||||
var shared_root_computed = {
|
||||
@@ -2049,9 +2080,8 @@ var shared_root_computed = {
|
||||
},
|
||||
voucherFormTarget: function () {
|
||||
var form_target = this.target_url + 'w/' + widget_id + '/redeem?iframe=1&locale=' + lang;
|
||||
var cookie = getCookie(this.cookieName);
|
||||
if (cookie) {
|
||||
form_target += "&take_cart_id=" + cookie;
|
||||
if (this.get_cart_id()) {
|
||||
form_target += "&take_cart_id=" + encodeURIComponent(this.get_cart_id());
|
||||
}
|
||||
if (this.subevent) {
|
||||
form_target += "&subevent=" + this.subevent;
|
||||
@@ -2091,9 +2121,8 @@ var shared_root_computed = {
|
||||
checkout_url += '?' + this.$root.additionalURLParams;
|
||||
}
|
||||
var form_target = this.target_url + 'w/' + widget_id + '/cart/add?iframe=1&next=' + encodeURIComponent(checkout_url);
|
||||
var cookie = getCookie(this.cookieName);
|
||||
if (cookie) {
|
||||
form_target += "&take_cart_id=" + cookie;
|
||||
if (this.get_cart_id()) {
|
||||
form_target += "&take_cart_id=" + encodeURIComponent(this.get_cart_id());
|
||||
}
|
||||
form_target += this.$root.consent_parameter
|
||||
return form_target
|
||||
@@ -2329,6 +2358,7 @@ var create_widget = function (element, html_id=null) {
|
||||
has_seating_plan: false,
|
||||
has_seating_plan_waitinglist: false,
|
||||
meta_filter_fields: [],
|
||||
keep_cart: true,
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
@@ -2366,6 +2396,7 @@ var create_button = function (element, html_id=null) {
|
||||
var raw_items = element.attributes.items ? element.attributes.items.value : "";
|
||||
var skip_ssl = element.attributes["skip-ssl-check"] ? true : false;
|
||||
var disable_iframe = element.attributes["disable-iframe"] ? true : false;
|
||||
var keep_cart = element.attributes["keep-cart"] ? true : false;
|
||||
var button_text = element.innerHTML;
|
||||
var widget_data = JSON.parse(JSON.stringify(window.PretixWidget.widget_data));
|
||||
for (var i = 0; i < element.attributes.length; i++) {
|
||||
@@ -2417,7 +2448,8 @@ var create_button = function (element, html_id=null) {
|
||||
widget_data: widget_data,
|
||||
widget_id: 'pretix-widget-' + widget_id,
|
||||
html_id: html_id,
|
||||
button_text: button_text
|
||||
button_text: button_text,
|
||||
keep_cart: keep_cart || items.length > 0,
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
@@ -2426,7 +2458,7 @@ var create_button = function (element, html_id=null) {
|
||||
observer.observe(this.$el, observerOptions);
|
||||
},
|
||||
computed: shared_root_computed,
|
||||
methods: shared_root_methods
|
||||
methods: shared_root_methods,
|
||||
});
|
||||
create_overlay(app);
|
||||
return app;
|
||||
@@ -2492,13 +2524,14 @@ window.PretixWidget.open = function (target_url, voucher, subevent, items, widge
|
||||
frame_dismissed: false,
|
||||
widget_data: all_widget_data,
|
||||
widget_id: 'pretix-widget-' + widget_id,
|
||||
button_text: ""
|
||||
button_text: "",
|
||||
keep_cart: true
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
},
|
||||
computed: shared_root_computed,
|
||||
methods: shared_root_methods
|
||||
methods: shared_root_methods,
|
||||
});
|
||||
create_overlay(app);
|
||||
app.$nextTick(function () {
|
||||
|
||||
@@ -966,6 +966,7 @@ $table-bg-accent: rgba(128, 128, 128, 0.05);
|
||||
width: 80vw;
|
||||
max-width: 1080px;
|
||||
height: 80vh;
|
||||
max-height: 100dvh;
|
||||
}
|
||||
.pretix-widget-frame-inner iframe {
|
||||
width: 100% !important;
|
||||
|
||||
@@ -94,6 +94,9 @@ class DisableMigrations(object):
|
||||
def __getitem__(self, item):
|
||||
return None
|
||||
|
||||
def setdefault(self, key, default=None):
|
||||
return
|
||||
|
||||
|
||||
if not os.environ.get("GITHUB_WORKFLOW", ""):
|
||||
MIGRATION_MODULES = DisableMigrations()
|
||||
|
||||
@@ -171,6 +171,35 @@ def test_giftcard_detail_expand(token_client, organizer, event, giftcard):
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_giftcard_detail_expand_without_permissions(team, token_client, organizer, event, giftcard):
|
||||
with scopes_disabled():
|
||||
o = Order.objects.create(
|
||||
code='FOO', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
total=14, locale='en'
|
||||
)
|
||||
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
|
||||
personalized=True)
|
||||
op = o.positions.create(item=ticket, price=Decimal("14"))
|
||||
giftcard.owner_ticket = op
|
||||
giftcard.save()
|
||||
|
||||
team.all_event_permissions = False
|
||||
team.save()
|
||||
|
||||
res = dict(TEST_GC_RES)
|
||||
res["id"] = giftcard.pk
|
||||
res["issuance"] = giftcard.issuance.isoformat().replace('+00:00', 'Z')
|
||||
resp = token_client.get('/api/v1/organizers/{}/giftcards/{}/?expand=owner_ticket'.format(organizer.slug, giftcard.pk))
|
||||
assert resp.status_code == 200
|
||||
|
||||
assert resp.data["owner_ticket"] == {
|
||||
"id": op.pk,
|
||||
}
|
||||
|
||||
|
||||
TEST_GIFTCARD_CREATE_PAYLOAD = {
|
||||
"secret": "DEFABC",
|
||||
"value": "12.00",
|
||||
|
||||
@@ -730,6 +730,34 @@ def test_payment_create_confirmed(token_client, organizer, event, order):
|
||||
assert len(djmail.outbox) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_payment_create_confirmed_after_expiry(token_client, organizer, event, order):
|
||||
djmail.outbox = []
|
||||
order.expires = now() - datetime.timedelta(days=2)
|
||||
order.save()
|
||||
event.settings.payment_term_last = (now() - datetime.timedelta(days=2)).strftime('%Y-%m-%d')
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/'.format(
|
||||
organizer.slug, event.slug, order.code
|
||||
), format='json', data={
|
||||
'provider': 'banktransfer',
|
||||
'state': 'confirmed',
|
||||
'amount': order.total,
|
||||
'send_email': False,
|
||||
'info': {
|
||||
'foo': 'bar'
|
||||
}
|
||||
})
|
||||
with scopes_disabled():
|
||||
p = order.payments.last()
|
||||
assert resp.status_code == 201
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
|
||||
assert p.info_data == {'foo': 'bar'}
|
||||
order.refresh_from_db()
|
||||
assert order.status == Order.STATUS_PAID
|
||||
assert len(djmail.outbox) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_payment_create_pending(token_client, organizer, event, order):
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/orders/{}/payments/'.format(
|
||||
@@ -2053,7 +2081,7 @@ def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_q
|
||||
assert not resp.data['positions'][0].get('pdf_data')
|
||||
|
||||
# order list
|
||||
with django_assert_max_num_queries(33):
|
||||
with django_assert_max_num_queries(34):
|
||||
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
|
||||
organizer.slug, event.slug
|
||||
))
|
||||
@@ -2068,7 +2096,7 @@ def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_q
|
||||
assert not resp.data['results'][0]['positions'][0].get('pdf_data')
|
||||
|
||||
# position list
|
||||
with django_assert_max_num_queries(35):
|
||||
with django_assert_max_num_queries(36):
|
||||
resp = token_client.get('/api/v1/organizers/{}/events/{}/orderpositions/?pdf_data=true'.format(
|
||||
organizer.slug, event.slug
|
||||
))
|
||||
|
||||
@@ -252,6 +252,76 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_medium_detail_event_permission_missing(token_client, organizer, event, medium, giftcard, customer, team):
|
||||
team.all_organizer_permissions = False
|
||||
team.limit_organizer_permissions = {
|
||||
"organizer.reusablemedia:read": True,
|
||||
"organizer.customers:read": True,
|
||||
"organizer.giftcards:read": True,
|
||||
}
|
||||
team.all_event_permissions = False
|
||||
team.save()
|
||||
|
||||
with scopes_disabled():
|
||||
o = Order.objects.create(
|
||||
code='FOO', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
total=14, locale='en'
|
||||
)
|
||||
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
|
||||
personalized=True)
|
||||
op = o.positions.create(item=ticket, price=Decimal("14"))
|
||||
medium.linked_orderposition = op
|
||||
medium.linked_giftcard = giftcard
|
||||
medium.customer = customer
|
||||
medium.save()
|
||||
giftcard.owner_ticket = op
|
||||
giftcard.save()
|
||||
|
||||
resp = token_client.get(
|
||||
'/api/v1/organizers/{}/reusablemedia/{}/?expand=linked_giftcard&expand='
|
||||
'linked_giftcard.owner_ticket&expand=linked_orderposition&expand=customer'.format(
|
||||
organizer.slug, medium.pk
|
||||
)
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
assert resp.data["linked_orderposition"] == {
|
||||
"id": op.pk,
|
||||
}
|
||||
|
||||
assert resp.data["linked_giftcard"] == {
|
||||
"id": giftcard.pk,
|
||||
"secret": "ABCDEF",
|
||||
"issuance": giftcard.issuance.isoformat().replace("+00:00", "Z"),
|
||||
"value": "23.00",
|
||||
"currency": "EUR",
|
||||
"testmode": False,
|
||||
"expires": None,
|
||||
"conditions": None,
|
||||
"owner_ticket": {"id": op.pk},
|
||||
"issuer": "dummy",
|
||||
}
|
||||
|
||||
assert resp.data["customer"] == {
|
||||
"identifier": customer.identifier,
|
||||
"external_identifier": None,
|
||||
"email": "foo@example.org",
|
||||
"phone": None,
|
||||
"name": "Foo",
|
||||
"name_parts": {"_legacy": "Foo"},
|
||||
"is_active": True,
|
||||
"is_verified": False,
|
||||
"last_login": None,
|
||||
"date_joined": customer.date_joined.isoformat().replace("+00:00", "Z"),
|
||||
"locale": "en",
|
||||
"last_modified": customer.last_modified.isoformat().replace("+00:00", "Z"),
|
||||
"notes": None
|
||||
}
|
||||
|
||||
|
||||
TEST_MEDIUM_CREATE_PAYLOAD = {
|
||||
"type": "barcode",
|
||||
"identifier": "FOOBAR",
|
||||
|
||||
@@ -123,6 +123,8 @@ def env():
|
||||
ExchangeRate.objects.create(source_date=date.today(), source='eu:ecb:eurofxref-daily', source_currency='EUR', other_currency=currency, rate=rate)
|
||||
ExchangeRate.objects.create(source_date=date.today(), source='cz:cnb:rate-fixing-daily', source_currency='EUR',
|
||||
other_currency='CZK', rate=Decimal('25.0000'))
|
||||
ExchangeRate.objects.create(source_date=date.today(), source='pl:nbp:table-a', source_currency='EUR',
|
||||
other_currency='PLN', rate=Decimal('4.2355'))
|
||||
yield event, o
|
||||
|
||||
|
||||
@@ -347,6 +349,23 @@ def test_invoice_indirect_currency_conversion(env):
|
||||
assert inv.foreign_currency_source == 'eu:ecb:eurofxref-daily'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_invoice_pln_currency_conversion(env):
|
||||
event, order = env
|
||||
event.settings.invoice_eu_currencies = 'PLN'
|
||||
|
||||
event.settings.set('invoice_language', 'en')
|
||||
InvoiceAddress.objects.create(company='Acme Company', street='221B Baker Street', zipcode='12345', city='Warsaw',
|
||||
country=Country('PL'), vat_id='PL123456780', vat_id_validated=True, order=order,
|
||||
is_business=True)
|
||||
|
||||
inv = generate_invoice(order)
|
||||
assert inv.foreign_currency_display == "PLN"
|
||||
assert inv.foreign_currency_rate == Decimal("4.2355")
|
||||
assert inv.foreign_currency_rate_date == date.today()
|
||||
assert inv.foreign_currency_source == 'pl:nbp:table-a'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_invoice_czk_currency_conversion(env):
|
||||
event, order = env
|
||||
|
||||
@@ -35,8 +35,11 @@
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
from contextlib import contextmanager
|
||||
from decimal import Decimal
|
||||
from email.mime.text import MIMEText
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
@@ -591,3 +594,117 @@ def test_attached_ical_localization(env, order):
|
||||
assert len(djmail.outbox) == 1
|
||||
assert len(djmail.outbox[0].attachments) == 1
|
||||
assert description in djmail.outbox[0].attachments[0][1]
|
||||
|
||||
|
||||
PRIVATE_IPS_RES = [
|
||||
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('10.0.0.3', 443))],
|
||||
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('0.0.0.0', 443))],
|
||||
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('127.1.1.1', 443))],
|
||||
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('192.168.5.3', 443))],
|
||||
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('224.0.0.1', 443))],
|
||||
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('::1', 443, 0, 0))],
|
||||
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('fe80::1', 443, 0, 0))],
|
||||
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('ff00::1', 443, 0, 0))],
|
||||
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('fc00::1', 443, 0, 0))],
|
||||
]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def assert_mail_connection(res, should_connect, use_ssl):
|
||||
with (
|
||||
mock.patch('socket.socket') as mock_socket,
|
||||
mock.patch('socket.getaddrinfo', return_value=res),
|
||||
mock.patch('smtplib.SMTP.getreply', return_value=(220, "")),
|
||||
mock.patch('smtplib.SMTP.sendmail'),
|
||||
mock.patch('ssl.SSLContext.wrap_socket') as mock_ssl
|
||||
):
|
||||
yield
|
||||
|
||||
if should_connect:
|
||||
mock_socket.assert_called_once()
|
||||
mock_socket.return_value.connect.assert_called_once_with(res[0][-1])
|
||||
if use_ssl:
|
||||
mock_ssl.assert_called_once()
|
||||
else:
|
||||
mock_socket.assert_not_called()
|
||||
mock_socket.return_value.connect.assert_not_called()
|
||||
mock_ssl.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("res", PRIVATE_IPS_RES)
|
||||
@pytest.mark.parametrize("use_ssl", [
|
||||
True, False
|
||||
])
|
||||
def test_private_smtp_ip(res, use_ssl, settings):
|
||||
settings.EMAIL_CUSTOM_SMTP_BACKEND = 'pretix.base.email.CheckPrivateNetworkSmtpBackend'
|
||||
settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = False
|
||||
with assert_mail_connection(res=res, should_connect=False, use_ssl=use_ssl), pytest.raises(match="Request to .* blocked"):
|
||||
connection = djmail.get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||
host="localhost",
|
||||
use_ssl=use_ssl)
|
||||
connection.open()
|
||||
|
||||
settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = True
|
||||
with assert_mail_connection(res=res, should_connect=True, use_ssl=use_ssl):
|
||||
connection = djmail.get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||
host="localhost",
|
||||
use_ssl=use_ssl)
|
||||
connection.open()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_ssl", [
|
||||
True, False
|
||||
])
|
||||
@pytest.mark.parametrize("allow_private", [
|
||||
True, False
|
||||
])
|
||||
def test_public_smtp_ip(use_ssl, allow_private, settings):
|
||||
settings.EMAIL_CUSTOM_SMTP_BACKEND = 'pretix.base.email.CheckPrivateNetworkSmtpBackend'
|
||||
settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = allow_private
|
||||
|
||||
with assert_mail_connection(res=[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('8.8.8.8', 443))], should_connect=True, use_ssl=use_ssl):
|
||||
connection = djmail.get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
|
||||
host="localhost",
|
||||
use_ssl=use_ssl)
|
||||
connection.open()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("use_ssl", [
|
||||
True, False
|
||||
])
|
||||
@pytest.mark.parametrize("allow_private_networks", [
|
||||
True, False
|
||||
])
|
||||
@pytest.mark.parametrize("res", PRIVATE_IPS_RES)
|
||||
def test_send_mail_private_ip(res, use_ssl, allow_private_networks, env):
|
||||
settings.EMAIL_CUSTOM_SMTP_BACKEND = 'pretix.base.email.CheckPrivateNetworkSmtpBackend'
|
||||
settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = allow_private_networks
|
||||
|
||||
event, user, organizer = env
|
||||
event.settings.smtp_use_custom = True
|
||||
event.settings.smtp_host = "example.com"
|
||||
event.settings.smtp_use_ssl = use_ssl
|
||||
event.settings.smtp_use_tls = False
|
||||
|
||||
def send_mail():
|
||||
m = OutgoingMail.objects.create(
|
||||
to=['recipient@example.com'],
|
||||
subject='Test',
|
||||
body_plain='Test',
|
||||
sender='sender@example.com',
|
||||
event=event
|
||||
)
|
||||
assert m.status == OutgoingMail.STATUS_QUEUED
|
||||
mail_send_task.apply(kwargs={
|
||||
'outgoing_mail': m.pk,
|
||||
}, max_retries=0)
|
||||
m.refresh_from_db()
|
||||
return m
|
||||
|
||||
with assert_mail_connection(res=res, should_connect=allow_private_networks, use_ssl=use_ssl):
|
||||
m = send_mail()
|
||||
if allow_private_networks:
|
||||
assert m.status == OutgoingMail.STATUS_SENT
|
||||
else:
|
||||
assert m.status == OutgoingMail.STATUS_FAILED
|
||||
|
||||
@@ -991,3 +991,30 @@ def test_import_mixed_order_size_consistency(user, event, item):
|
||||
).get()
|
||||
assert ('Inconsistent data in row 2: Column Email address contains value "a2@example.com", but for this order, '
|
||||
'the value has already been set to "a1@example.com".') in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@scopes_disabled()
|
||||
def test_import_line_endings_mix(event, item, user):
|
||||
# Ensures import works with mixed file endings.
|
||||
# See Ticket#23230806 where a file to import ends with \r\n
|
||||
settings = dict(DEFAULT_SETTINGS)
|
||||
settings['item'] = 'static:{}'.format(item.pk)
|
||||
|
||||
cf = inputfile_factory()
|
||||
file = cf.file
|
||||
file.seek(0)
|
||||
data = file.read()
|
||||
data = data.replace(b'\n', b'\r')
|
||||
data = data.rstrip(b'\r\r')
|
||||
data = data + b'\r\n'
|
||||
|
||||
print(data)
|
||||
cf.file.save("input.csv", ContentFile(data))
|
||||
cf.save()
|
||||
|
||||
import_orders.apply(
|
||||
args=(event.pk, cf.id, settings, 'en', user.pk)
|
||||
)
|
||||
assert event.orders.count() == 3
|
||||
assert OrderPosition.objects.count() == 3
|
||||
|
||||
@@ -24,7 +24,6 @@ from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from django.core import mail as djmail
|
||||
from django.db import transaction
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scope
|
||||
|
||||
@@ -75,47 +74,42 @@ def user(team):
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def monkeypatch_on_commit(monkeypatch):
|
||||
monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notification_trigger_event_specific(event, order, user, monkeypatch_on_commit):
|
||||
def test_notification_trigger_event_specific(event, order, user, django_capture_on_commit_callbacks):
|
||||
djmail.outbox = []
|
||||
user.notification_settings.create(
|
||||
method='mail', event=event, action_type='pretix.event.order.paid', enabled=True
|
||||
)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
order.log_action('pretix.event.order.paid', {})
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].subject.endswith("DUMMY: Order FOO has been marked as paid.")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notification_trigger_global(event, order, user, monkeypatch_on_commit):
|
||||
def test_notification_trigger_global(event, order, user, django_capture_on_commit_callbacks):
|
||||
djmail.outbox = []
|
||||
user.notification_settings.create(
|
||||
method='mail', event=None, action_type='pretix.event.order.paid', enabled=True
|
||||
)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
order.log_action('pretix.event.order.paid', {})
|
||||
assert len(djmail.outbox) == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notification_trigger_global_wildcard(event, order, user, monkeypatch_on_commit):
|
||||
def test_notification_trigger_global_wildcard(event, order, user, django_capture_on_commit_callbacks):
|
||||
djmail.outbox = []
|
||||
user.notification_settings.create(
|
||||
method='mail', event=None, action_type='pretix.event.order.changed.*', enabled=True
|
||||
)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
order.log_action('pretix.event.order.changed.item', {})
|
||||
assert len(djmail.outbox) == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notification_enabled_global_ignored_specific(event, order, user, monkeypatch_on_commit):
|
||||
def test_notification_enabled_global_ignored_specific(event, order, user, django_capture_on_commit_callbacks):
|
||||
djmail.outbox = []
|
||||
user.notification_settings.create(
|
||||
method='mail', event=None, action_type='pretix.event.order.paid', enabled=True
|
||||
@@ -123,24 +117,24 @@ def test_notification_enabled_global_ignored_specific(event, order, user, monkey
|
||||
user.notification_settings.create(
|
||||
method='mail', event=event, action_type='pretix.event.order.paid', enabled=False
|
||||
)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
order.log_action('pretix.event.order.paid', {})
|
||||
assert len(djmail.outbox) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notification_ignore_same_user(event, order, user, monkeypatch_on_commit):
|
||||
def test_notification_ignore_same_user(event, order, user, django_capture_on_commit_callbacks):
|
||||
djmail.outbox = []
|
||||
user.notification_settings.create(
|
||||
method='mail', event=event, action_type='pretix.event.order.paid', enabled=True
|
||||
)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
order.log_action('pretix.event.order.paid', {}, user=user)
|
||||
assert len(djmail.outbox) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notification_ignore_insufficient_permissions(event, order, user, team, monkeypatch_on_commit):
|
||||
def test_notification_ignore_insufficient_permissions(event, order, user, team, django_capture_on_commit_callbacks):
|
||||
djmail.outbox = []
|
||||
team.all_event_permissions = False
|
||||
team.limit_event_permissions = {"event.vouchers:read": True}
|
||||
@@ -148,7 +142,7 @@ def test_notification_ignore_insufficient_permissions(event, order, user, team,
|
||||
user.notification_settings.create(
|
||||
method='mail', event=event, action_type='pretix.event.order.paid', enabled=True
|
||||
)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
order.log_action('pretix.event.order.paid', {})
|
||||
assert len(djmail.outbox) == 0
|
||||
|
||||
|
||||
@@ -28,8 +28,9 @@ from zoneinfo import ZoneInfo
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.core import mail as djmail
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Sum
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test import TestCase, TransactionTestCase, override_settings
|
||||
from django.utils.timezone import make_aware, now
|
||||
from django_countries.fields import Country
|
||||
from django_scopes import scope
|
||||
@@ -1225,12 +1226,6 @@ class DownloadReminderTests(TestCase):
|
||||
assert len(djmail.outbox) == 0
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def class_monkeypatch(request, monkeypatch):
|
||||
request.cls.monkeypatch = monkeypatch
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("class_monkeypatch")
|
||||
class OrderCancelTests(TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
@@ -1258,7 +1253,6 @@ class OrderCancelTests(TestCase):
|
||||
self.order.create_transactions()
|
||||
generate_invoice(self.order)
|
||||
djmail.outbox = []
|
||||
self.monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_cancel_canceled(self):
|
||||
@@ -1351,14 +1345,14 @@ class OrderCancelTests(TestCase):
|
||||
self.order.status = Order.STATUS_PAID
|
||||
self.order.save()
|
||||
djmail.outbox = []
|
||||
cancel_order(self.order.pk, send_mail=True)
|
||||
print([s.subject for s in djmail.outbox])
|
||||
print([s.to for s in djmail.outbox])
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
cancel_order(self.order.pk, send_mail=True)
|
||||
|
||||
assert len(djmail.outbox) == 2
|
||||
assert ["invoice@example.org"] == djmail.outbox[0].to
|
||||
assert any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
|
||||
assert ["dummy@dummy.test"] == djmail.outbox[1].to
|
||||
assert not any(["Invoice_" in a[0] for a in djmail.outbox[1].attachments])
|
||||
assert ["dummy@dummy.test"] == djmail.outbox[0].to
|
||||
assert not any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
|
||||
assert ["invoice@example.org"] == djmail.outbox[1].to
|
||||
assert any(["Invoice_" in a[0] for a in djmail.outbox[1].attachments])
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_cancel_paid_with_too_high_fee(self):
|
||||
@@ -1488,8 +1482,7 @@ class OrderCancelTests(TestCase):
|
||||
assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("class_monkeypatch")
|
||||
class OrderChangeManagerTests(TestCase):
|
||||
class BaseOrderChangeManagerTestCase:
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.o = Organizer.objects.create(name='Dummy', slug='dummy', plugins='pretix.plugins.banktransfer')
|
||||
@@ -1552,7 +1545,6 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.seat_a1 = self.event.seats.create(seat_number="A1", product=self.stalls, seat_guid="A1")
|
||||
self.seat_a2 = self.event.seats.create(seat_number="A2", product=self.stalls, seat_guid="A2")
|
||||
self.seat_a3 = self.event.seats.create(seat_number="A3", product=self.stalls, seat_guid="A3")
|
||||
self.monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
|
||||
|
||||
def _enable_reverse_charge(self):
|
||||
self.tr7.eu_reverse_charge = True
|
||||
@@ -1566,6 +1558,8 @@ class OrderChangeManagerTests(TestCase):
|
||||
country=Country('AT')
|
||||
)
|
||||
|
||||
|
||||
class OrderChangeManagerTests(BaseOrderChangeManagerTestCase, TestCase):
|
||||
@classscope(attr='o')
|
||||
def test_multiple_commits_forbidden(self):
|
||||
self.ocm.change_price(self.op1, Decimal('10.00'))
|
||||
@@ -2406,6 +2400,15 @@ class OrderChangeManagerTests(TestCase):
|
||||
self.ocm.commit()
|
||||
assert self.order.positions.count() == 2
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_add_item_quota_partial(self):
|
||||
q1 = self.event.quotas.create(name='Test', size=1)
|
||||
q1.items.add(self.shirt)
|
||||
self.ocm.add_position(self.shirt, None, None, None, count=2)
|
||||
with self.assertRaises(OrderError):
|
||||
self.ocm.commit()
|
||||
assert self.order.positions.count() == 2
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_add_item_addon(self):
|
||||
self.shirt.category = self.event.categories.create(name='Add-ons', is_addon=True)
|
||||
@@ -3895,15 +3898,16 @@ class OrderChangeManagerTests(TestCase):
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_set_valid_until(self):
|
||||
self.event.settings.ticket_secret_generator = "pretix_sig1"
|
||||
assign_ticket_secret(self.event, self.op1, force_invalidate=True, save=True)
|
||||
old_secret = self.op1.secret
|
||||
with transaction.atomic():
|
||||
self.event.settings.ticket_secret_generator = "pretix_sig1"
|
||||
assign_ticket_secret(self.event, self.op1, force_invalidate=True, save=True)
|
||||
old_secret = self.op1.secret
|
||||
|
||||
dt = make_aware(datetime(2022, 9, 20, 15, 0, 0, 0))
|
||||
self.ocm.change_valid_until(self.op1, dt)
|
||||
self.ocm.commit()
|
||||
self.op1.refresh_from_db()
|
||||
assert self.op1.secret != old_secret
|
||||
dt = make_aware(datetime(2022, 9, 20, 15, 0, 0, 0))
|
||||
self.ocm.change_valid_until(self.op1, dt)
|
||||
self.ocm.commit()
|
||||
self.op1.refresh_from_db()
|
||||
assert self.op1.secret != old_secret
|
||||
|
||||
@classscope(attr='o')
|
||||
def test_unset_valid_from_until(self):
|
||||
@@ -3928,6 +3932,8 @@ class OrderChangeManagerTests(TestCase):
|
||||
assert len(djmail.outbox) == 1
|
||||
assert len(["Invoice_" in a[0] for a in djmail.outbox[0].attachments]) == 2
|
||||
|
||||
|
||||
class OrderChangeManagerTransactionalTests(BaseOrderChangeManagerTestCase, TransactionTestCase):
|
||||
@classscope(attr='o')
|
||||
def test_new_invoice_send_somewhere_else(self):
|
||||
generate_invoice(self.order)
|
||||
|
||||
@@ -25,7 +25,6 @@ from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from django.db import transaction
|
||||
from django.utils.timezone import now
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
@@ -82,14 +81,9 @@ def force_str(v):
|
||||
return v.decode() if isinstance(v, bytes) else str(v)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def monkeypatch_on_commit(monkeypatch):
|
||||
monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@responses.activate
|
||||
def test_webhook_trigger_event_specific(event, order, webhook, monkeypatch_on_commit):
|
||||
def test_webhook_trigger_event_specific(event, order, webhook, django_capture_on_commit_callbacks):
|
||||
responses.add_callback(
|
||||
responses.POST, 'https://google.com',
|
||||
callback=lambda r: (200, {}, 'ok'),
|
||||
@@ -97,7 +91,7 @@ def test_webhook_trigger_event_specific(event, order, webhook, monkeypatch_on_co
|
||||
match_querystring=None, # https://github.com/getsentry/responses/issues/464
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
le = order.log_action('pretix.event.order.paid', {})
|
||||
assert len(responses.calls) == 1
|
||||
assert json.loads(force_str(responses.calls[0].request.body)) == {
|
||||
@@ -119,12 +113,12 @@ def test_webhook_trigger_event_specific(event, order, webhook, monkeypatch_on_co
|
||||
|
||||
@pytest.mark.django_db
|
||||
@responses.activate
|
||||
def test_webhook_trigger_global(event, order, webhook, monkeypatch_on_commit):
|
||||
def test_webhook_trigger_global(event, order, webhook, django_capture_on_commit_callbacks):
|
||||
webhook.limit_events.clear()
|
||||
webhook.all_events = True
|
||||
webhook.save()
|
||||
responses.add(responses.POST, 'https://google.com', status=200)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
le = order.log_action('pretix.event.order.paid', {})
|
||||
assert len(responses.calls) == 1
|
||||
assert json.loads(force_str(responses.calls[0].request.body)) == {
|
||||
@@ -138,13 +132,13 @@ def test_webhook_trigger_global(event, order, webhook, monkeypatch_on_commit):
|
||||
|
||||
@pytest.mark.django_db
|
||||
@responses.activate
|
||||
def test_webhook_trigger_global_wildcard(event, order, webhook, monkeypatch_on_commit):
|
||||
def test_webhook_trigger_global_wildcard(event, order, webhook, django_capture_on_commit_callbacks):
|
||||
webhook.listeners.create(action_type="pretix.event.order.changed.*")
|
||||
webhook.limit_events.clear()
|
||||
webhook.all_events = True
|
||||
webhook.save()
|
||||
responses.add(responses.POST, 'https://google.com', status=200)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
le = order.log_action('pretix.event.order.changed.item', {})
|
||||
assert len(responses.calls) == 1
|
||||
assert json.loads(force_str(responses.calls[0].request.body)) == {
|
||||
@@ -158,30 +152,30 @@ def test_webhook_trigger_global_wildcard(event, order, webhook, monkeypatch_on_c
|
||||
|
||||
@pytest.mark.django_db
|
||||
@responses.activate
|
||||
def test_webhook_ignore_wrong_action_type(event, order, webhook, monkeypatch_on_commit):
|
||||
def test_webhook_ignore_wrong_action_type(event, order, webhook, django_capture_on_commit_callbacks):
|
||||
responses.add(responses.POST, 'https://google.com', status=200)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
order.log_action('pretix.event.order.changed.item', {})
|
||||
assert len(responses.calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@responses.activate
|
||||
def test_webhook_ignore_disabled(event, order, webhook, monkeypatch_on_commit):
|
||||
def test_webhook_ignore_disabled(event, order, webhook, django_capture_on_commit_callbacks):
|
||||
webhook.enabled = False
|
||||
webhook.save()
|
||||
responses.add(responses.POST, 'https://google.com', status=200)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
order.log_action('pretix.event.order.changed.item', {})
|
||||
assert len(responses.calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@responses.activate
|
||||
def test_webhook_ignore_wrong_event(event, order, webhook, monkeypatch_on_commit):
|
||||
def test_webhook_ignore_wrong_event(event, order, webhook, django_capture_on_commit_callbacks):
|
||||
webhook.limit_events.clear()
|
||||
responses.add(responses.POST, 'https://google.com', status=200)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
order.log_action('pretix.event.order.changed.item', {})
|
||||
assert len(responses.calls) == 0
|
||||
|
||||
@@ -189,10 +183,10 @@ def test_webhook_ignore_wrong_event(event, order, webhook, monkeypatch_on_commit
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.xfail(reason="retries can't be tested with celery_always_eager")
|
||||
@responses.activate
|
||||
def test_webhook_retry(event, order, webhook, monkeypatch_on_commit):
|
||||
def test_webhook_retry(event, order, webhook, django_capture_on_commit_callbacks):
|
||||
responses.add(responses.POST, 'https://google.com', status=500)
|
||||
responses.add(responses.POST, 'https://google.com', status=200)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
order.log_action('pretix.event.order.paid', {})
|
||||
assert len(responses.calls) == 2
|
||||
with scopes_disabled():
|
||||
@@ -216,9 +210,9 @@ def test_webhook_retry(event, order, webhook, monkeypatch_on_commit):
|
||||
|
||||
@pytest.mark.django_db
|
||||
@responses.activate
|
||||
def test_webhook_disable_gone(event, order, webhook, monkeypatch_on_commit):
|
||||
def test_webhook_disable_gone(event, order, webhook, django_capture_on_commit_callbacks):
|
||||
responses.add(responses.POST, 'https://google.com', status=410)
|
||||
with transaction.atomic():
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
order.log_action('pretix.event.order.paid', {})
|
||||
assert len(responses.calls) == 1
|
||||
webhook.refresh_from_db()
|
||||
|
||||
@@ -131,3 +131,8 @@ def set_lock_namespaces(request):
|
||||
yield
|
||||
else:
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def class_monkeypatch(request, monkeypatch):
|
||||
request.cls.monkeypatch = monkeypatch
|
||||
|
||||
@@ -385,11 +385,6 @@ class RegistrationFormTest(TestCase):
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def class_monkeypatch(request, monkeypatch):
|
||||
request.cls.monkeypatch = monkeypatch
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("class_monkeypatch")
|
||||
class Login2FAFormTest(TestCase):
|
||||
|
||||
|
||||
@@ -49,11 +49,6 @@ from tests.base import SoupTest, extract_form_fields
|
||||
from pretix.base.models import Event, LogEntry, Order, Organizer, Team, User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def class_monkeypatch(request, monkeypatch):
|
||||
request.cls.monkeypatch = monkeypatch
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("class_monkeypatch")
|
||||
class EventsTest(SoupTest):
|
||||
@scopes_disabled()
|
||||
|
||||
@@ -1584,10 +1584,11 @@ class OrderChangeTests(SoupTest):
|
||||
'add_position-MAX_NUM_FORMS': '100',
|
||||
'add_position-0-itemvar': str(self.shirt.pk),
|
||||
'add_position-0-do': 'on',
|
||||
'add_position-0-count': '2',
|
||||
'add_position-0-price': '14.00',
|
||||
})
|
||||
with scopes_disabled():
|
||||
assert self.order.positions.count() == 3
|
||||
assert self.order.positions.count() == 4
|
||||
assert self.order.positions.last().item == self.shirt
|
||||
assert self.order.positions.last().price == 14
|
||||
|
||||
|
||||
@@ -33,11 +33,6 @@ from tests.base import SoupTest, extract_form_fields
|
||||
from pretix.base.models import Event, Organizer, OutgoingMail, Team, User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def class_monkeypatch(request, monkeypatch):
|
||||
request.cls.monkeypatch = monkeypatch
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("class_monkeypatch")
|
||||
class OrganizerTest(SoupTest):
|
||||
@scopes_disabled()
|
||||
|
||||
@@ -747,6 +747,92 @@ class SubEventsTest(SoupTest):
|
||||
assert ses[1].date_from.isoformat() == "2018-04-12T11:29:31+00:00"
|
||||
assert ses[-1].date_from.isoformat() == "2019-03-28T12:29:31+00:00"
|
||||
|
||||
def test_create_bulk_skip_existing(self):
|
||||
with scopes_disabled():
|
||||
self.event1.subevents.all().delete()
|
||||
# SubEvent ends at rrule start time
|
||||
self.event1.subevents.create(
|
||||
date_from=datetime.datetime(2018, 4, 4, 9, 0, tzinfo=datetime.timezone.utc),
|
||||
date_to=datetime.datetime(2018, 4, 4, 10, 0, tzinfo=datetime.timezone.utc),
|
||||
)
|
||||
# SubEvent overlaps rrule start
|
||||
self.event1.subevents.create(
|
||||
date_from=datetime.datetime(2018, 4, 5, 9, 30, tzinfo=datetime.timezone.utc),
|
||||
date_to=datetime.datetime(2018, 4, 5, 10, 30, tzinfo=datetime.timezone.utc),
|
||||
)
|
||||
# SubEvent times are same as rrule
|
||||
self.event1.subevents.create(
|
||||
date_from=datetime.datetime(2018, 4, 6, 10, 0, tzinfo=datetime.timezone.utc),
|
||||
date_to=datetime.datetime(2018, 4, 6, 11, 0, tzinfo=datetime.timezone.utc),
|
||||
)
|
||||
# SubEvent starts at rrule end time
|
||||
self.event1.subevents.create(
|
||||
date_from=datetime.datetime(2018, 4, 7, 11, 0, tzinfo=datetime.timezone.utc),
|
||||
date_to=datetime.datetime(2018, 4, 7, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
)
|
||||
# SubEvent overlaps entire rrule time
|
||||
self.event1.subevents.create(
|
||||
date_from=datetime.datetime(2018, 4, 8, 9, 0, tzinfo=datetime.timezone.utc),
|
||||
date_to=datetime.datetime(2018, 4, 8, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
)
|
||||
# SubEvent has before rrule time and no end
|
||||
self.event1.subevents.create(
|
||||
date_from=datetime.datetime(2018, 4, 9, 9, 0, tzinfo=datetime.timezone.utc),
|
||||
)
|
||||
existing_events = list(self.event1.subevents.values_list('pk', flat=True))
|
||||
|
||||
self.event1.settings.timezone = 'Europe/Berlin'
|
||||
doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', {
|
||||
'rruleformset-TOTAL_FORMS': '1',
|
||||
'rruleformset-INITIAL_FORMS': '0',
|
||||
'rruleformset-MIN_NUM_FORMS': '0',
|
||||
'rruleformset-MAX_NUM_FORMS': '1000',
|
||||
'rruleformset-0-end': 'count',
|
||||
'rruleformset-0-count': '10',
|
||||
'rruleformset-0-interval': '1',
|
||||
'rruleformset-0-freq': 'weekly',
|
||||
'rruleformset-0-dtstart': '2018-04-03',
|
||||
'rruleformset-0-weekly_byweekday': ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'],
|
||||
'rruleformset-0-yearly_same': 'on',
|
||||
'rruleformset-0-monthly_same': 'on',
|
||||
'timeformset-TOTAL_FORMS': '1',
|
||||
'timeformset-INITIAL_FORMS': '0',
|
||||
'timeformset-MIN_NUM_FORMS': '1',
|
||||
'timeformset-MAX_NUM_FORMS': '1000',
|
||||
'timeformset-0-time_from': '12:00:00',
|
||||
'timeformset-0-time_to': '13:00:00',
|
||||
'rruleformset-0-until': '2019-04-03',
|
||||
'skip_if_overlap': 'on',
|
||||
'name_0': 'Foo',
|
||||
'active': 'on',
|
||||
'frontpage_text_0': '',
|
||||
'quotas-TOTAL_FORMS': '1',
|
||||
'quotas-INITIAL_FORMS': '0',
|
||||
'quotas-MIN_NUM_FORMS': '0',
|
||||
'quotas-MAX_NUM_FORMS': '1000',
|
||||
'quotas-0-name': 'Q1',
|
||||
'quotas-0-size': '50',
|
||||
'quotas-0-itemvars': str(self.ticket.pk),
|
||||
'checkinlist_set-TOTAL_FORMS': '0',
|
||||
'checkinlist_set-INITIAL_FORMS': '0',
|
||||
'checkinlist_set-MIN_NUM_FORMS': '0',
|
||||
'checkinlist_set-MAX_NUM_FORMS': '1000',
|
||||
})
|
||||
assert doc.select(".alert-success")
|
||||
with scopes_disabled():
|
||||
ses = list(self.event1.subevents.exclude(pk__in=existing_events).order_by('date_from'))
|
||||
|
||||
assert len(ses) == 7
|
||||
assert [s.date_from.date().isoformat() for s in ses] == [
|
||||
'2018-04-03',
|
||||
'2018-04-04',
|
||||
'2018-04-07',
|
||||
'2018-04-09',
|
||||
'2018-04-10',
|
||||
'2018-04-11',
|
||||
'2018-04-12'
|
||||
]
|
||||
|
||||
def test_delete_bulk(self):
|
||||
self.subevent2.active = True
|
||||
self.subevent2.save()
|
||||
|
||||
@@ -286,11 +286,6 @@ class UserPasswordChangeTest(SoupTest):
|
||||
assert self.user.needs_password_change is False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def class_monkeypatch(request, monkeypatch):
|
||||
request.cls.monkeypatch = monkeypatch
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("class_monkeypatch")
|
||||
class UserSettings2FATest(SoupTest):
|
||||
def setUp(self):
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
#
|
||||
# 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 socket import AF_INET, SOCK_STREAM
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from django.test import override_settings
|
||||
from dns.inet import AF_INET6
|
||||
from urllib3.exceptions import HTTPError
|
||||
|
||||
|
||||
def test_local_blocked():
|
||||
with pytest.raises(HTTPError, match="Request to local address.*"):
|
||||
requests.get("http://localhost", timeout=0.1)
|
||||
with pytest.raises(HTTPError, match="Request to local address.*"):
|
||||
requests.get("https://localhost", timeout=0.1)
|
||||
|
||||
|
||||
def test_private_ip_blocked():
|
||||
with pytest.raises(HTTPError, match="Request to private address.*"):
|
||||
requests.get("http://10.0.0.1", timeout=0.1)
|
||||
with pytest.raises(HTTPError, match="Request to private address.*"):
|
||||
requests.get("https://10.0.0.1", timeout=0.1)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize("res", [
|
||||
[(AF_INET, SOCK_STREAM, 6, '', ('10.0.0.3', 443))],
|
||||
[(AF_INET, SOCK_STREAM, 6, '', ('0.0.0.0', 443))],
|
||||
[(AF_INET, SOCK_STREAM, 6, '', ('127.1.1.1', 443))],
|
||||
[(AF_INET, SOCK_STREAM, 6, '', ('192.168.5.3', 443))],
|
||||
[(AF_INET, SOCK_STREAM, 6, '', ('224.0.0.1', 443))],
|
||||
[(AF_INET6, SOCK_STREAM, 6, '', ('::1', 443, 0, 0))],
|
||||
[(AF_INET6, SOCK_STREAM, 6, '', ('fe80::1', 443, 0, 0))],
|
||||
[(AF_INET6, SOCK_STREAM, 6, '', ('ff00::1', 443, 0, 0))],
|
||||
[(AF_INET6, SOCK_STREAM, 6, '', ('fc00::1', 443, 0, 0))],
|
||||
])
|
||||
def test_dns_resolving_to_local_blocked(res):
|
||||
with mock.patch('socket.getaddrinfo') as mock_addr:
|
||||
mock_addr.return_value = res
|
||||
with pytest.raises(HTTPError, match="Request to (multicast|private|local) address.*"):
|
||||
requests.get("https://example.org", timeout=0.1)
|
||||
with pytest.raises(HTTPError, match="Request to (multicast|private|local) address.*"):
|
||||
requests.get("http://example.org", timeout=0.1)
|
||||
|
||||
|
||||
def test_dns_remote_allowed():
|
||||
class SocketOk(Exception):
|
||||
pass
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
raise SocketOk
|
||||
|
||||
with mock.patch('socket.getaddrinfo') as mock_addr, mock.patch('socket.socket') as mock_socket:
|
||||
mock_addr.return_value = [(AF_INET, SOCK_STREAM, 6, '', ('8.8.8.8', 443))]
|
||||
mock_socket.side_effect = side_effect
|
||||
with pytest.raises(SocketOk):
|
||||
requests.get("https://example.org", timeout=0.1)
|
||||
|
||||
|
||||
@override_settings(ALLOW_HTTP_TO_PRIVATE_NETWORKS=True)
|
||||
def test_local_is_allowed():
|
||||
class SocketOk(Exception):
|
||||
pass
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
raise SocketOk
|
||||
|
||||
with mock.patch('socket.getaddrinfo') as mock_addr, mock.patch('socket.socket') as mock_socket:
|
||||
mock_addr.return_value = [(AF_INET, SOCK_STREAM, 6, '', ('10.0.0.1', 443))]
|
||||
mock_socket.side_effect = side_effect
|
||||
with pytest.raises(SocketOk):
|
||||
requests.get("https://example.org", timeout=0.1)
|
||||
@@ -33,7 +33,7 @@ from django.conf import settings
|
||||
from django.core import mail as djmail
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.core.signing import dumps
|
||||
from django.test import TestCase
|
||||
from django.test import Client, TestCase, TransactionTestCase
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.timezone import now
|
||||
from django_countries.fields import Country
|
||||
@@ -60,12 +60,6 @@ from pretix.testutils.sessions import get_cart_session_key
|
||||
from .test_timemachine import TimemachineTestMixin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def class_monkeypatch(request, monkeypatch):
|
||||
request.cls.monkeypatch = monkeypatch
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("class_monkeypatch")
|
||||
class BaseCheckoutTestCase:
|
||||
@scopes_disabled()
|
||||
def setUp(self):
|
||||
@@ -104,7 +98,6 @@ class BaseCheckoutTestCase:
|
||||
self.workshopquota.items.add(self.workshop2)
|
||||
self.workshopquota.variations.add(self.workshop2a)
|
||||
self.workshopquota.variations.add(self.workshop2b)
|
||||
self.monkeypatch.setattr("django.db.transaction.on_commit", lambda t: t())
|
||||
|
||||
def _set_session(self, key, value):
|
||||
session = self.client.session
|
||||
@@ -4420,6 +4413,20 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
||||
assert len(djmail.outbox) == 1
|
||||
assert any(["Invoice_" in a[0] for a in djmail.outbox[0].attachments])
|
||||
|
||||
def test_checkout_empty_session_valid_cart(self):
|
||||
client = Client()
|
||||
with scopes_disabled():
|
||||
api_cid = "{}@api".format(get_random_string(48))
|
||||
CartPosition.objects.create(
|
||||
event=self.event, cart_id=api_cid, item=self.ticket,
|
||||
price=23, expires=now() + timedelta(minutes=10)
|
||||
)
|
||||
|
||||
response = client.get('/%s/%s/w/1234567890abcdef/checkout/questions/' % (self.orga.slug, self.event.slug), query_params={"take_cart_id": api_cid})
|
||||
assert '€23.00' in response.content.decode()
|
||||
|
||||
|
||||
class CheckoutTransactionTestCase(BaseCheckoutTestCase, TransactionTestCase):
|
||||
def test_order_confirmation_mail_invoice_sent_somewhere_else(self):
|
||||
self.event.settings.invoice_address_asked = True
|
||||
self.event.settings.invoice_address_required = True
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
import datetime
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from importlib import import_module
|
||||
from json import loads
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -80,6 +81,34 @@ class EventMiddlewareTest(EventTestMixin, SoupTest):
|
||||
doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug))
|
||||
self.assertIn(str(self.event.name), doc.find("h1").text)
|
||||
|
||||
def test_no_session_cookie_set_on_event_index_view(self):
|
||||
resp = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
assert settings.SESSION_COOKIE_NAME not in self.client.cookies
|
||||
|
||||
def test_no_cart_session_added_on_event_index_view(self):
|
||||
# Make sure a session is present by doing a cart op on another event
|
||||
event2 = Event.objects.create(
|
||||
organizer=self.orga, name='30C3b', slug='30c3b',
|
||||
date_from=datetime.datetime(now().year + 1, 12, 26, 14, 0, tzinfo=datetime.timezone.utc),
|
||||
live=True,
|
||||
)
|
||||
self.client.post('/%s/%s/cart/add' % (self.orga.slug, event2.slug), {
|
||||
'item_%d' % 1337: '1', # item does not need to exist
|
||||
'ajax': 1
|
||||
})
|
||||
assert settings.SESSION_COOKIE_NAME in self.client.cookies
|
||||
|
||||
# Visit shop, make sure no session is created
|
||||
resp = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug))
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
|
||||
session = SessionStore(self.client.cookies[settings.SESSION_COOKIE_NAME].value).load()
|
||||
assert set(session.keys()) == {
|
||||
f"current_cart_event_{event2.pk}", "carts"
|
||||
}
|
||||
|
||||
def test_not_found(self):
|
||||
resp = self.client.get('/%s/%s/' % ('foo', 'bar'))
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
@@ -1133,6 +1162,65 @@ class WaitingListTest(EventTestMixin, SoupTest):
|
||||
assert wle.voucher is None
|
||||
assert wle.locale == 'en'
|
||||
|
||||
def test_initial_selection(self):
|
||||
with scopes_disabled():
|
||||
cat = ItemCategory.objects.create(event=self.event, name='Tickets')
|
||||
self.item.category = cat
|
||||
self.item.save()
|
||||
|
||||
item2 = Item.objects.create(
|
||||
event=self.event, name='VIP ticket',
|
||||
default_price=Decimal('25.00'),
|
||||
active=True, category=cat,
|
||||
)
|
||||
self.q.items.add(item2)
|
||||
|
||||
response = self.client.get(
|
||||
'/%s/%s/waitinglist/?item=%d' % (
|
||||
self.orga.slug, self.event.slug, item2.pk
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
doc = BeautifulSoup(response.render().content, "lxml")
|
||||
|
||||
select = doc.find('select', {'name': 'itemvar'})
|
||||
optgroup = select.find('optgroup')
|
||||
self.assertIsNotNone(optgroup, 'Choices should be grouped by category')
|
||||
self.assertEqual(optgroup['label'], 'Tickets')
|
||||
|
||||
selected = select.find_all('option', selected=True)
|
||||
self.assertEqual(len(selected), 1, 'Exactly one option should be pre-selected')
|
||||
self.assertEqual(selected[0]['value'], str(item2.pk))
|
||||
|
||||
def test_initial_selection_with_variation(self):
|
||||
with scopes_disabled():
|
||||
cat = ItemCategory.objects.create(event=self.event, name='Tickets')
|
||||
self.item.category = cat
|
||||
self.item.has_variations = True
|
||||
self.item.save()
|
||||
|
||||
var1 = ItemVariation.objects.create(item=self.item, value='Standard')
|
||||
var2 = ItemVariation.objects.create(item=self.item, value='Premium')
|
||||
self.q.variations.add(var1, var2)
|
||||
|
||||
response = self.client.get(
|
||||
'/%s/%s/waitinglist/?item=%d&var=%d' % (
|
||||
self.orga.slug, self.event.slug,
|
||||
self.item.pk, var2.pk,
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
doc = BeautifulSoup(response.render().content, "lxml")
|
||||
|
||||
select = doc.find('select', {'name': 'itemvar'})
|
||||
optgroup = select.find('optgroup')
|
||||
self.assertIsNotNone(optgroup, 'Choices should be grouped by category')
|
||||
self.assertEqual(optgroup['label'], 'Tickets')
|
||||
|
||||
selected = select.find_all('option', selected=True)
|
||||
self.assertEqual(len(selected), 1, 'Exactly one option should be pre-selected')
|
||||
self.assertEqual(selected[0]['value'], '%d-%d' % (self.item.pk, var2.pk))
|
||||
|
||||
def test_subevent_valid(self):
|
||||
with scopes_disabled():
|
||||
self.event.has_subevents = True
|
||||
|
||||
Reference in New Issue
Block a user