Compare commits

..
Author SHA1 Message Date
Mira Weller 9da8c1f7b2 reauth flow token
add a special token to always allow completing a form submission, even if the reauthentication time has expired
2026-03-26 12:41:19 +01:00
Mira Weller 6b340682b2 ui changes 2026-03-20 12:56:57 +01:00
Mira Weller 0dc436067f always perform 2fa activation as dedicated step 2026-03-20 12:56:38 +01:00
Mira Weller db66c91108 generate emergency tokens during 2fa activation 2026-03-20 12:55:58 +01:00
Mira Weller 3a1db55e8b remove broken blackberry link 2026-03-20 12:06:56 +01:00
Mira Weller 57da5cbae2 improve 2fa type selection 2026-03-20 11:44:10 +01:00
259 changed files with 76142 additions and 82371 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
name: Packaging name: Packaging
strategy: strategy:
matrix: matrix:
python-version: ["3.13"] python-version: ["3.11"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
+4 -4
View File
@@ -24,10 +24,10 @@ jobs:
name: Check gettext syntax name: Check gettext syntax
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.13 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.13 python-version: 3.11
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
@@ -49,10 +49,10 @@ jobs:
name: Spellcheck name: Spellcheck
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.13 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.13 python-version: 3.11
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
+6 -6
View File
@@ -24,10 +24,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.13 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.13 python-version: 3.11
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
@@ -44,10 +44,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.13 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.13 python-version: 3.11
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
@@ -64,10 +64,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.13 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.13 python-version: 3.11
- name: Install Dependencies - name: Install Dependencies
run: pip3 install licenseheaders run: pip3 install licenseheaders
- name: Run licenseheaders - name: Run licenseheaders
+2 -4
View File
@@ -23,15 +23,13 @@ jobs:
name: Tests name: Tests
strategy: strategy:
matrix: matrix:
python-version: ["3.11", "3.13", "3.14"] python-version: ["3.10", "3.11", "3.13"]
database: [sqlite, postgres] database: [sqlite, postgres]
exclude: exclude:
- database: sqlite - database: sqlite
python-version: "3.10" python-version: "3.10"
- database: sqlite - database: sqlite
python-version: "3.11" python-version: "3.11"
- database: sqlite
python-version: "3.12"
services: services:
postgres: postgres:
image: postgres:15 image: postgres:15
@@ -83,4 +81,4 @@ jobs:
file: src/coverage.xml file: src/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false fail_ci_if_error: false
if: matrix.database == 'postgres' && matrix.python-version == '3.13' if: matrix.database == 'postgres' && matrix.python-version == '3.11'
+5 -10
View File
@@ -1,16 +1,11 @@
Contributing to pretix Contributing to pretix
====================== ======================
Welcome to pretix, we are happy that you would like to contribute. Hey there and welcome to pretix!
Before you do so, please make sure to read the following documents:
- [Contribution workflow](https://docs.pretix.eu/dev/development/contribution/general.html) * 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).
- [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)
Before we can accept your first PR we'll need you to sign [our **Contributor License Agreement** (CLA)](https://pretix.eu/about/en/cla). * 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.
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 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 contributing new functionality, always open a discussion first.**
+1 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.13-trixie FROM python:3.11-bookworm
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
@@ -31,7 +31,6 @@ RUN apt-get update && \
mkdir /etc/pretix && \ mkdir /etc/pretix && \
mkdir /data && \ mkdir /data && \
useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \ useradd -ms /bin/bash -d /pretix -u 15371 pretixuser && \
chmod 0755 /pretix && \
echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \ echo 'pretixuser ALL=(ALL) NOPASSWD:SETENV: /usr/bin/supervisord' >> /etc/sudoers && \
mkdir /static && \ mkdir /static && \
mkdir /etc/supervisord mkdir /etc/supervisord
+6 -13
View File
@@ -16,7 +16,6 @@ Field Type Description
id integer Internal ID of the program time id integer Internal ID of the program time
start datetime The start date time for this program time slot. start datetime The start date time for this program time slot.
end datetime The end date time for this program time slot. end datetime The end date time for this program time slot.
location multi-lingual string The program time slot's location (or ``null``)
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: TODO .. versionchanged:: TODO
@@ -55,20 +54,17 @@ Endpoints
{ {
"id": 2, "id": 2,
"start": "2025-08-14T22:00:00Z", "start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z", "end": "2025-08-15T00:00:00Z"
"location": null
}, },
{ {
"id": 3, "id": 3,
"start": "2025-08-12T22:00:00Z", "start": "2025-08-12T22:00:00Z",
"end": "2025-08-13T22:00:00Z", "end": "2025-08-13T22:00:00Z"
"location": null
}, },
{ {
"id": 14, "id": 14,
"start": "2025-08-15T22:00:00Z", "start": "2025-08-15T22:00:00Z",
"end": "2025-08-17T22:00:00Z", "end": "2025-08-17T22:00:00Z"
"location": null
} }
] ]
} }
@@ -103,8 +99,7 @@ Endpoints
{ {
"id": 1, "id": 1,
"start": "2025-08-15T22:00:00Z", "start": "2025-08-15T22:00:00Z",
"end": "2025-10-27T23:00:00Z", "end": "2025-10-27T23:00:00Z"
"location": null
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -130,8 +125,7 @@ Endpoints
{ {
"start": "2025-08-15T10:00:00Z", "start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z", "end": "2025-08-15T22:00:00Z"
"location": null
} }
**Example response**: **Example response**:
@@ -145,8 +139,7 @@ Endpoints
{ {
"id": 17, "id": 17,
"start": "2025-08-15T10:00:00Z", "start": "2025-08-15T10:00:00Z",
"end": "2025-08-15T22:00:00Z", "end": "2025-08-15T22:00:00Z"
"location": null
} }
:param organizer: The ``slug`` field of the organizer of the event/item to create a program time for :param organizer: The ``slug`` field of the organizer of the event/item to create a program time for
-24
View File
@@ -1,24 +0,0 @@
.. _`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
+11 -30
View File
@@ -1,39 +1,23 @@
Contribution workflow General remarks
===================== ===============
You are interested in contributing to pretix? That is awesome! You are interested in contributing to pretix? That is awesome!
If youre new to contributing to open source software, dont be afraid. Well happily review your code and give you If youre new to contributing to open source software, dont be afraid. Well happily review your code and give you
constructive and friendly feedback on your changes. Every contribution should go through the following steps. constructive and friendly feedback on your changes.
Discussion & Design First of all, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
-------------------
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! If you run into any problems on your way, please do not hesitate to ask us anytime!
While developing, please have a look at our :ref:`aipolicy` and our guidelines on :ref:`codestyle`. 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.
Sending a patch Sending a patch
--------------- ---------------
Once you have a first draft of your changes, please `create a pull request`_ on our `GitHub repository`_. 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`_.
We recommend that you create a feature branch for every issue you work on so the changes can We recommend that you create a feature branch for every issue you work on so the changes can
be reviewed individually. be reviewed individually.
@@ -41,17 +25,14 @@ 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 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. 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 through GitHub Actions and we wont We automatically run the tests and the code style check on every pull request on Travis CI and we wont
accept any pull requests without all tests passing. However, if you don't find out *why* they are not passing, 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. 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, 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. 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 us through GitHub discussions. Again: If you get stuck, do not hesitate to contact any of us, or Raphael personally at mail@raphaelmichel.de.
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/ .. _create a pull request: https://help.github.com/articles/creating-a-pull-request/
.. _GitHub repository: https://github.com/pretix/pretix .. _GitHub repository: https://github.com/pretix/pretix
-1
View File
@@ -6,5 +6,4 @@ Contributing to pretix
general general
style style
ai
codeofconduct codeofconduct
+4 -4
View File
@@ -1,7 +1,5 @@
.. spelling:word-list:: Rebase rebasing .. spelling:word-list:: Rebase rebasing
.. _`codestyle`:
Coding style and quality Coding style and quality
======================== ========================
@@ -30,6 +28,8 @@ Code
Commits and Pull Requests Commits and Pull Requests
------------------------- -------------------------
Most commits should start as pull requests, therefore this applies to the titles of pull requests as well since 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" 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 feature if the PR contains multiple commits that do not carry value to keep. If there is value in keeping the
@@ -86,7 +86,7 @@ individual commits, we use "Rebase and merge" instead. Merge commits should be a
.. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/ .. _PEP 8: https://legacy.python.org/dev/peps/pep-0008/
.. _flake8: https://pypi.python.org/pypi/flake8 .. _flake8: https://pypi.python.org/pypi/flake8
.. _Django Coding Style: https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/ .. _Django Coding Style: https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/
.. _translation: https://docs.djangoproject.com/en/6.0/topics/i18n/translation/ .. _translation: https://docs.djangoproject.com/en/1.11/topics/i18n/translation/
.. _class-based views: https://docs.djangoproject.com/en/6.0/topics/class-based-views/ .. _class-based views: https://docs.djangoproject.com/en/1.11/topics/class-based-views/
.. _pytest-style: https://docs.pytest.org/en/latest/assert.html .. _pytest-style: https://docs.pytest.org/en/latest/assert.html
.. _fixtures: https://docs.pytest.org/en/latest/fixture.html .. _fixtures: https://docs.pytest.org/en/latest/fixture.html
+13 -14
View File
@@ -3,7 +3,7 @@ name = "pretix"
dynamic = ["version"] dynamic = ["version"]
description = "Reinventing presales, one ticket at a time" description = "Reinventing presales, one ticket at a time"
readme = "README.rst" readme = "README.rst"
requires-python = ">=3.11" requires-python = ">=3.10"
license = {file = "LICENSE"} license = {file = "LICENSE"}
keywords = ["tickets", "web", "shop", "ecommerce"] keywords = ["tickets", "web", "shop", "ecommerce"]
authors = [ authors = [
@@ -19,11 +19,10 @@ classifiers = [
"Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
"Environment :: Web Environment", "Environment :: Web Environment",
"License :: OSI Approved :: GNU Affero General Public License v3", "License :: OSI Approved :: GNU Affero General Public License v3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Framework :: Django :: 4.2",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Framework :: Django :: 5.2",
] ]
dependencies = [ dependencies = [
@@ -33,11 +32,11 @@ dependencies = [
"bleach==6.3.*", "bleach==6.3.*",
"celery==5.6.*", "celery==5.6.*",
"chardet==5.2.*", "chardet==5.2.*",
"cryptography>=47.0.0", "cryptography>=44.0.0",
"css-inline==0.20.*", "css-inline==0.20.*",
"defusedcsv>=3.0.0", "defusedcsv>=1.1.0",
"dnspython==2.*", "dnspython==2.*",
"Django[argon2]==5.2.*", "Django[argon2]==4.2.*,>=4.2.26",
"django-bootstrap3==26.1", "django-bootstrap3==26.1",
"django-compressor==4.6.0", "django-compressor==4.6.0",
"django-countries==8.2.*", "django-countries==8.2.*",
@@ -60,7 +59,7 @@ dependencies = [
"dnspython==2.8.*", "dnspython==2.8.*",
"drf_ujson2==1.7.*", "drf_ujson2==1.7.*",
"geoip2==5.*", "geoip2==5.*",
"importlib_metadata==9.*", # Polyfill, we can probably drop this once we require Python 3.10+ "importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
"isoweek", "isoweek",
"jsonschema", "jsonschema",
"kombu==5.6.*", "kombu==5.6.*",
@@ -76,7 +75,7 @@ dependencies = [
"paypal-checkout-serversdk==1.0.*", "paypal-checkout-serversdk==1.0.*",
"PyJWT==2.12.*", "PyJWT==2.12.*",
"phonenumberslite==9.0.*", "phonenumberslite==9.0.*",
"Pillow==12.2.*", "Pillow==12.1.*",
"pretix-plugin-build", "pretix-plugin-build",
"protobuf==7.34.*", "protobuf==7.34.*",
"psycopg2-binary", "psycopg2-binary",
@@ -90,14 +89,14 @@ dependencies = [
"pytz-deprecation-shim==0.1.*", "pytz-deprecation-shim==0.1.*",
"pyuca", "pyuca",
"qrcode==8.2", "qrcode==8.2",
"redis==7.4.*", "redis==7.1.*",
"reportlab==4.4.*", "reportlab==4.4.*",
"requests==2.32.*", "requests==2.32.*",
"sentry-sdk==2.58.*", "sentry-sdk==2.54.*",
"sepaxml==2.7.*", "sepaxml==2.7.*",
"stripe==7.9.*", "stripe==7.9.*",
"text-unidecode==1.*", "text-unidecode==1.*",
"tlds>=2026041800", "tlds>=2020041600",
"tqdm==4.*", "tqdm==4.*",
"ua-parser==1.0.*", "ua-parser==1.0.*",
"vobject==0.9.*", "vobject==0.9.*",
@@ -117,7 +116,7 @@ dev = [
"isort==8.0.*", "isort==8.0.*",
"pep8-naming==0.15.*", "pep8-naming==0.15.*",
"potypo", "potypo",
"pytest-asyncio>=1.3.0", "pytest-asyncio>=0.24",
"pytest-cache", "pytest-cache",
"pytest-cov", "pytest-cov",
"pytest-django==4.*", "pytest-django==4.*",
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2026.5.0.dev0" __version__ = "2026.3.0.dev0"
+2 -2
View File
@@ -115,10 +115,10 @@ class PluginsField(serializers.Field):
def to_representation(self, obj): def to_representation(self, obj):
from pretix.base.plugins import get_all_plugins from pretix.base.plugins import get_all_plugins
active_plugins = set(obj.get_plugins())
return sorted([ return sorted([
p.module for p in get_all_plugins() p.module for p in get_all_plugins()
if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in active_plugins if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins()
]) ])
def to_internal_value(self, data): def to_internal_value(self, data):
+2 -2
View File
@@ -191,7 +191,7 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
class InlineItemProgramTimeSerializer(serializers.ModelSerializer): class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ItemProgramTime model = ItemProgramTime
fields = ('start', 'end', 'location') fields = ('start', 'end')
class ItemBundleSerializer(serializers.ModelSerializer): class ItemBundleSerializer(serializers.ModelSerializer):
@@ -222,7 +222,7 @@ class ItemBundleSerializer(serializers.ModelSerializer):
class ItemProgramTimeSerializer(serializers.ModelSerializer): class ItemProgramTimeSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ItemProgramTime model = ItemProgramTime
fields = ('id', 'start', 'end', 'location') fields = ('id', 'start', 'end')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
+3 -25
View File
@@ -31,9 +31,7 @@ from pretix.api.serializers.order import OrderPositionSerializer
from pretix.api.serializers.organizer import ( from pretix.api.serializers.organizer import (
CustomerSerializer, GiftCardSerializer, CustomerSerializer, GiftCardSerializer,
) )
from pretix.base.models import ( from pretix.base.models import Order, OrderPosition, ReusableMedium
Device, Order, OrderPosition, ReusableMedium, TeamAPIToken,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -82,7 +80,8 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
) )
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'): if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
# Permission Check performed in to_representation # No additional permission check performed, documented limitation of the permission system
# Would get to complex/unusable otherwise since the permission depends on the event
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True) self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
else: else:
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField( self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
@@ -118,27 +117,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
) )
return data 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: class Meta:
model = ReusableMedium model = ReusableMedium
fields = ( fields = (
+6 -13
View File
@@ -769,11 +769,7 @@ class PaymentDetailsField(serializers.Field):
pp = value.payment_provider pp = value.payment_provider
if not pp: if not pp:
return {} return {}
try: return pp.api_payment_details(value)
return pp.api_payment_details(value)
except Exception:
logger.exception("Failed to retrieve payment_details")
return {}
class OrderPaymentSerializer(I18nAwareModelSerializer): class OrderPaymentSerializer(I18nAwareModelSerializer):
@@ -1416,7 +1412,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
qa = QuotaAvailability() qa = QuotaAvailability()
qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0]) qa.queue(*[q for q, d in quota_diff_for_locking.items() if d > 0])
qa.compute() qa.compute()
v_avail = {}
# These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental # These are not technically correct as diff use due to the time offset applied above, so let's prevent accidental
# use further down # use further down
@@ -1446,13 +1441,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
voucher_usage[v] += 1 voucher_usage[v] += 1
if voucher_usage[v] > 0: if voucher_usage[v] > 0:
if v not in v_avail: redeemed_in_carts = CartPosition.objects.filter(
v.refresh_from_db(fields=['redeemed']) Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
redeemed_in_carts = CartPosition.objects.filter( ).exclude(pk__in=[cp.pk for cp in delete_cps])
Q(voucher=v) & Q(event=self.context['event']) & Q(expires__gte=now_dt) v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
).exclude(pk__in=[cp.pk for cp in delete_cps]) if v_avail < voucher_usage[v]:
v_avail[v] = v.max_usages - v.redeemed - redeemed_in_carts.count()
if v_avail[v] < voucher_usage[v]:
errs[i]['voucher'] = [ errs[i]['voucher'] = [
'The voucher has already been used the maximum number of times.' 'The voucher has already been used the maximum number of times.'
] ]
-13
View File
@@ -286,19 +286,6 @@ class GiftCardSerializer(I18nAwareModelSerializer):
) )
return data 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: class Meta:
model = GiftCard model = GiftCard
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket', fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket',
+1 -1
View File
@@ -1122,7 +1122,7 @@ class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
permission = 'event.orders:read' permission = 'event.orders:read'
def get_queryset(self): def get_queryset(self):
qs = Checkin.all.filter(list__event=self.request.event).select_related( qs = Checkin.all.filter().select_related(
"position", "position",
"device", "device",
) )
+28 -43
View File
@@ -381,15 +381,12 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list') resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp return resp
else: else:
return FileResponse( resp = FileResponse(ct.file.file, content_type=ct.type)
ct.file.file, resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
filename='{}-{}-{}{}'.format( self.request.event.slug.upper(), order.code,
self.request.event.slug.upper(), order.code, provider.identifier, ct.extension
provider.identifier, ct.extension
),
as_attachment=True,
content_type=ct.type
) )
return resp
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def mark_paid(self, request, **kwargs): def mark_paid(self, request, **kwargs):
@@ -1306,17 +1303,14 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
raise NotFound() raise NotFound()
ftype, ignored = mimetypes.guess_type(answer.file.name) ftype, ignored = mimetypes.guess_type(answer.file.name)
return FileResponse( resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
answer.file, resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format(
filename='{}-{}-{}-{}'.format( self.request.event.slug.upper(),
self.request.event.slug.upper(), pos.order.code,
pos.order.code, pos.positionid,
pos.positionid, os.path.basename(answer.file.name).split('.', 1)[1]
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"]) @action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"])
def printlog(self, request, **kwargs): def printlog(self, request, **kwargs):
@@ -1371,18 +1365,15 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
if hasattr(image_file, 'seek'): if hasattr(image_file, 'seek'):
image_file.seek(0) image_file.seek(0)
return FileResponse( resp = FileResponse(image_file, content_type=ftype or 'application/binary')
image_file, resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format(
filename='{}-{}-{}-{}.{}'.format( self.request.event.slug.upper(),
self.request.event.slug.upper(), pos.order.code,
pos.order.code, pos.positionid,
pos.positionid, key,
key, extension,
extension,
),
as_attachment=True,
content_type=ftype or 'application/binary'
) )
return resp
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)') @action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs): def download(self, request, output, **kwargs):
@@ -1408,15 +1399,12 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list') resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp return resp
else: else:
return FileResponse( resp = FileResponse(ct.file.file, content_type=ct.type)
ct.file.file, resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
filename='{}-{}-{}-{}{}'.format( self.request.event.slug.upper(), pos.order.code, pos.positionid,
self.request.event.slug.upper(), pos.order.code, pos.positionid, provider.identifier, ct.extension
provider.identifier, ct.extension
),
as_attachment=True,
content_type=ct.type
) )
return resp
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def regenerate_secrets(self, request, **kwargs): def regenerate_secrets(self, request, **kwargs):
@@ -1998,12 +1986,9 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
if not invoice.file: if not invoice.file:
raise RetryException() raise RetryException()
return FileResponse( resp = FileResponse(invoice.file.file, content_type='application/pdf')
invoice.file.file, resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
filename='{}.pdf'.format(invoice.number), return resp
as_attachment=True,
content_type='application/pdf'
)
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
def transmit(self, request, **kwargs): def transmit(self, request, **kwargs):
-80
View File
@@ -19,10 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
import ipaddress
import logging import logging
import smtplib
import socket
from itertools import groupby from itertools import groupby
from smtplib import SMTPResponseException from smtplib import SMTPResponseException
from typing import TypeVar from typing import TypeVar
@@ -240,80 +237,3 @@ def base_renderers(sender, **kwargs):
def get_email_context(**kwargs): def get_email_context(**kwargs):
return PlaceholderContext(**kwargs).render_all() 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
+3 -13
View File
@@ -47,7 +47,6 @@ from django.utils.formats import localize
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from pretix.base.models import Event 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 from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
SafeWorkbook, remove_invalid_excel_chars as excel_safe, SafeWorkbook, remove_invalid_excel_chars as excel_safe,
) )
@@ -60,20 +59,11 @@ class BaseExporter:
This is the base class for all data exporters This is the base class for all data exporters
""" """
def __init__(self, event, organizer, permission_holder: PermissionHolder=None, progress_callback=lambda v: None): def __init__(self, event, organizer, 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.event = event
self.organizer = organizer self.organizer = organizer
self.progress_callback = progress_callback self.progress_callback = progress_callback
self.is_multievent = isinstance(event, QuerySet) self.is_multievent = isinstance(event, QuerySet)
self.permission_holder = permission_holder
if isinstance(event, QuerySet): if isinstance(event, QuerySet):
self.events = event self.events = event
self.event = None self.event = None
@@ -190,7 +180,7 @@ class BaseExporter:
return True return True
@classmethod @classmethod
def get_required_event_permission(cls) -> Optional[str]: def get_required_event_permission(cls) -> str:
""" """
The permission level required to use this exporter for events. For multi-event-exports, this will be used 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. to limit the selection of events. Will be ignored if the ``OrganizerLevelExportMixin`` mixin is used.
@@ -205,7 +195,7 @@ class OrganizerLevelExportMixin:
raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin") raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin")
@classmethod @classmethod
def get_required_organizer_permission(cls) -> Optional[str]: def get_required_organizer_permission(cls) -> str:
""" """
The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to 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. allow everyone with any access to the organizer.
-14
View File
@@ -1103,25 +1103,13 @@ class PaymentListExporter(ListExporter):
def iterate_list(self, form_data): def iterate_list(self, form_data):
provider_names = dict(get_all_payment_providers()) 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( payments = OrderPayment.objects.filter(
order__event__in=self.events, order__event__in=self.events,
state__in=form_data.get('payment_states', []) 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') ).select_related('order').prefetch_related('order__event').order_by('created')
refunds = OrderRefund.objects.filter( refunds = OrderRefund.objects.filter(
order__event__in=self.events, order__event__in=self.events,
state__in=form_data.get('refund_states', []) 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') ).select_related('order').prefetch_related('order__event').order_by('created')
if form_data.get('end_date_range'): if form_data.get('end_date_range'):
@@ -1147,7 +1135,6 @@ class PaymentListExporter(ListExporter):
headers = [ headers = [
_('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'), _('Event slug'), _('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
_('Status code'), _('Amount'), _('Payment method'), _('Comment'), _('Matching ID'), _('Payment details'), _('Status code'), _('Amount'), _('Payment method'), _('Comment'), _('Matching ID'), _('Payment details'),
_('Invoice numbers'),
] ]
yield headers yield headers
@@ -1185,7 +1172,6 @@ class PaymentListExporter(ListExporter):
obj.comment if isinstance(obj, OrderRefund) else "", obj.comment if isinstance(obj, OrderRefund) else "",
matching_id, matching_id,
payment_details, payment_details,
obj.order_invoice_numbers,
] ]
yield row yield row
+2 -1
View File
@@ -196,7 +196,8 @@ class RegistrationForm(forms.Form):
def clean_password(self): def clean_password(self):
password1 = self.cleaned_data.get('password', '') password1 = self.cleaned_data.get('password', '')
user = User(email=self.cleaned_data.get('email')) user = User(email=self.cleaned_data.get('email'))
validate_password(password1, user=user) if validate_password(password1, user=user) is not None:
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
return password1 return password1
def clean_email(self): def clean_email(self):
+4 -10
View File
@@ -45,6 +45,7 @@ import pycountry
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.gis.geoip2 import GeoIP2
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import ( from django.core.validators import (
@@ -90,7 +91,7 @@ from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, PERSON_NAME_SALUTATIONS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
) )
from pretix.base.templatetags.rich_text import URL_RE, rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.base.timemachine import time_machine_now from pretix.base.timemachine import time_machine_now
from pretix.control.forms import ( from pretix.control.forms import (
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField, ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
@@ -101,7 +102,6 @@ from pretix.helpers.countries import (
from pretix.helpers.escapejson import escapejson_attr from pretix.helpers.escapejson import escapejson_attr
from pretix.helpers.http import get_client_ip from pretix.helpers.http import get_client_ip
from pretix.helpers.i18n import get_format_without_seconds from pretix.helpers.i18n import get_format_without_seconds
from pretix.helpers.security import get_geoip
from pretix.presale.signals import question_form_fields from pretix.presale.signals import question_form_fields
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -227,15 +227,9 @@ class NamePartsFormField(forms.MultiValueField):
# bots. # bots.
r'^[^$€/%§{}<>~]*$', r'^[^$€/%§{}<>~]*$',
message=_('Please do not use special characters in names.') 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.scheme_name = kwargs.pop('scheme')
self.titles = kwargs.pop('titles') self.titles = kwargs.pop('titles')
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name) self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
@@ -293,7 +287,7 @@ class NamePartsFormField(forms.MultiValueField):
if self.require_all_fields and not all(v for v in value): if self.require_all_fields and not all(v for v in value):
raise forms.ValidationError(self.error_messages['incomplete'], code='required') raise forms.ValidationError(self.error_messages['incomplete'], code='required')
if sum(len(v) for v in value.values() if v) > (self.max_length or 250): if sum(len(v) for v in value.values() if v) > 250:
raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length') raise forms.ValidationError(_('Please enter a shorter name.'), code='max_length')
if value.get("salutation") == "empty": if value.get("salutation") == "empty":
@@ -399,7 +393,7 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
def guess_country_from_request(request, event): def guess_country_from_request(request, event):
if settings.HAS_GEOIP: if settings.HAS_GEOIP:
g = get_geoip() g = GeoIP2()
try: try:
res = g.country(get_client_ip(request)) res = g.country(get_client_ip(request))
if res['country_code'] and len(res['country_code']) == 2: if res['country_code'] and len(res['country_code']) == 2:
+1 -1
View File
@@ -1160,7 +1160,7 @@ class Modern1Renderer(ClassicInvoiceRenderer):
return stylesheet return stylesheet
def _draw_invoice_from(self, canvas): def _draw_invoice_from(self, canvas):
if not self.invoice.address_invoice_from: if not self.invoice.invoice_from:
return return
c = [ c = [
self._clean_text(l) self._clean_text(l)
@@ -36,9 +36,8 @@ from django.core.management.commands.makemigrations import Command as Parent
from ._migrations import monkeypatch_migrations from ._migrations import monkeypatch_migrations
monkeypatch_migrations()
class Command(Parent): class Command(Parent):
pass
def handle(self, *args, **kwargs):
monkeypatch_migrations()
return super().handle(*args, **kwargs)
@@ -64,7 +64,7 @@ class Command(BaseCommand):
if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS: if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS:
return return
for receiver in periodic_task._live_receivers(self)[0]: for receiver in periodic_task._live_receivers(self):
name = f'{receiver.__module__}.{receiver.__name__}' name = f'{receiver.__module__}.{receiver.__name__}'
if options['list_tasks']: if options['list_tasks']:
print(name) print(name)
-13
View File
@@ -24,7 +24,6 @@ from urllib.parse import urlparse, urlsplit
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings from django.conf import settings
from django.core.exceptions import BadRequest
from django.http import Http404, HttpRequest, HttpResponse from django.http import Http404, HttpRequest, HttpResponse
from django.middleware.common import CommonMiddleware from django.middleware.common import CommonMiddleware
from django.urls import get_script_prefix, resolve from django.urls import get_script_prefix, resolve
@@ -348,18 +347,6 @@ class SecurityMiddleware(MiddlewareMixin):
return resp return resp
class RejectInvalidInputMiddleware(MiddlewareMixin):
def process_request(self, request):
# Nullbytes in GET/POST parameters are mostly harmless, as they will later fail on database insertion, but it
# keeps spamming our error logs whenever someone tries to run a vulnerability scanner.
if "\x00" in request.META['QUERY_STRING'] or "%00" in request.META['QUERY_STRING']:
raise BadRequest("Invalid characters in input.")
if request.method in ('POST', 'PUT', 'PATCH') and request.content_type == "application/x-www-form-urlencoded":
if any("\x00" in value for key, value_list in request.POST.lists() for value in value_list):
raise BadRequest("Invalid characters in input.")
class CustomCommonMiddleware(CommonMiddleware): class CustomCommonMiddleware(CommonMiddleware):
def get_full_path_with_slash(self, request): def get_full_path_with_slash(self, request):
@@ -41,20 +41,16 @@ class Migration(migrations.Migration):
name='datetime', name='datetime',
field=models.DateTimeField(), field=models.DateTimeField(),
), ),
migrations.AddIndex( migrations.AlterIndexTogether(
'logentry', name='logentry',
models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b1fe5a_idx"), index_together={('datetime', 'id')},
), ),
migrations.AddIndex( migrations.AlterIndexTogether(
'order', name='order',
models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"), index_together={('datetime', 'id'), ('last_modified', 'id')},
), ),
migrations.AddIndex( migrations.AlterIndexTogether(
'order', name='transaction',
models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"), index_together={('datetime', 'id')},
),
migrations.AddIndex(
'transaction',
models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b20405_idx"),
), ),
] ]
@@ -61,10 +61,7 @@ class Migration(migrations.Migration):
options={ options={
'ordering': ('identifier', 'type', 'organizer'), 'ordering': ('identifier', 'type', 'organizer'),
'unique_together': {('identifier', 'type', 'organizer')}, 'unique_together': {('identifier', 'type', 'organizer')},
'indexes': [ 'index_together': {('identifier', 'type', 'organizer'), ('updated', 'id')},
models.Index(fields=('identifier', 'type', 'organizer'), name='reusable_medium_organizer_index'),
models.Index(fields=('updated', 'id'), name="pretixbase__updated_093277_idx")
],
}, },
bases=(models.Model, pretix.base.models.base.LoggingMixin), bases=(models.Model, pretix.base.models.base.LoggingMixin),
), ),
+25
View File
@@ -9,6 +9,31 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RenameIndex(
model_name="logentry",
new_name="pretixbase__datetim_b1fe5a_idx",
old_fields=("datetime", "id"),
),
migrations.RenameIndex(
model_name="order",
new_name="pretixbase__datetim_66aff0_idx",
old_fields=("datetime", "id"),
),
migrations.RenameIndex(
model_name="order",
new_name="pretixbase__last_mo_4ebf8b_idx",
old_fields=("last_modified", "id"),
),
migrations.RenameIndex(
model_name="reusablemedium",
new_name="pretixbase__updated_093277_idx",
old_fields=("updated", "id"),
),
migrations.RenameIndex(
model_name="transaction",
new_name="pretixbase__datetim_b20405_idx",
old_fields=("datetime", "id"),
),
migrations.AlterField( migrations.AlterField(
model_name="attendeeprofile", model_name="attendeeprofile",
name="id", name="id",
@@ -1,6 +1,6 @@
# Generated by Django 4.2.10 on 2024-04-02 15:16 # Generated by Django 4.2.10 on 2024-04-02 15:16
from django.db import migrations, models from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -10,8 +10,8 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RemoveIndex( migrations.AlterIndexTogether(
"reusablemedium", name="reusablemedium",
'reusable_medium_organizer_index', index_together=set(),
), ),
] ]
@@ -1,19 +0,0 @@
# Generated by Django 4.2.27 on 2026-01-21 12:06
import i18nfield.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0298_pluggable_permissions"),
]
operations = [
migrations.AddField(
model_name="itemprogramtime",
name="location",
field=i18nfield.fields.I18nTextField(max_length=200, null=True),
)
]
-4
View File
@@ -70,10 +70,6 @@ def parse_csv(file, length=None, mode="strict", charset=None):
except ImportError: except ImportError:
charset = file.charset charset = file.charset
data = data.decode(charset or "utf-8", mode) 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 the file was modified on a Mac, it only contains \r as line breaks
if '\r' in data and '\n' not in data: if '\r' in data and '\n' not in data:
data = data.replace('\r', '\n') data = data.replace('\r', '\n')
+3 -9
View File
@@ -29,9 +29,7 @@ import inspect
import logging import logging
import os import os
import threading import threading
from pathlib import Path
import django
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
@@ -76,14 +74,10 @@ def _transactions_mark_order_dirty(order_id, using=None):
if "PYTEST_CURRENT_TEST" in os.environ: 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 # 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. # or not.
for frame in inspect.stack()[1:]: for frame in inspect.stack():
if ( if 'pretix/base/models/orders' in frame.filename:
'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 continue
elif 'test_' in frame.filename or 'conftest.py' in frame.filename: elif 'test_' in frame.filename or 'conftest.py in frame.filename':
return return
elif 'pretix/' in frame.filename or 'pretix_' in frame.filename: elif 'pretix/' in frame.filename or 'pretix_' in frame.filename:
# This went through non-test code, let's consider it non-test # This went through non-test code, let's consider it non-test
-21
View File
@@ -38,7 +38,6 @@ import operator
import secrets import secrets
from datetime import timedelta from datetime import timedelta
from functools import reduce from functools import reduce
from typing import Protocol
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import ( from django.contrib.auth.models import (
@@ -68,14 +67,6 @@ class EmailAddressTakenError(IntegrityError):
pass 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): class UserManager(BaseUserManager):
""" """
This is the user manager for our custom user model. See the User This is the user manager for our custom user model. See the User
@@ -705,18 +696,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return self.teams.exists() 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): class UserKnownLoginSource(models.Model):
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources") user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
agent_type = models.CharField(max_length=255, null=True, blank=True) agent_type = models.CharField(max_length=255, null=True, blank=True)
+1 -2
View File
@@ -229,7 +229,7 @@ class Device(LoggedModel):
""" """
return self._organizer_permission_set() if self.organizer == organizer else set() return self._organizer_permission_set() if self.organizer == organizer else set()
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool: def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
""" """
Checks if this token is part of a team that grants access of type ``perm_name`` Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``. to the event ``event``.
@@ -238,7 +238,6 @@ class Device(LoggedModel):
:param event: The event to check :param event: The event to check
:param perm_name: The permission, e.g. ``event.orders:read`` :param perm_name: The permission, e.g. ``event.orders:read``
:param request: This parameter is ignored and only defined for compatibility reasons. :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 :return: bool
""" """
has_event_access = (self.all_events and organizer == self.organizer) or ( has_event_access = (self.all_events and organizer == self.organizer) or (
-6
View File
@@ -715,12 +715,6 @@ class Event(EventMixin, LoggedModel):
self.settings.name_scheme = 'given_family' self.settings.name_scheme = 'given_family'
self.settings.payment_banktransfer_invoice_immediately = True self.settings.payment_banktransfer_invoice_immediately = True
self.settings.low_availability_percentage = 10 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 @property
def social_image(self): def social_image(self):
-7
View File
@@ -2306,17 +2306,10 @@ class ItemProgramTime(models.Model):
:type start: datetime :type start: datetime
:param end: The date and time this program time ends :param end: The date and time this program time ends
:type end: datetime :type end: datetime
:param location: venue
:type location: str
""" """
item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE) item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE)
start = models.DateTimeField(verbose_name=_("Start")) start = models.DateTimeField(verbose_name=_("Start"))
end = models.DateTimeField(verbose_name=_("End")) end = models.DateTimeField(verbose_name=_("End"))
location = I18nTextField(
null=True, blank=True,
max_length=200,
verbose_name=_("Location"),
)
def clean(self): def clean(self):
if hasattr(self, 'item') and self.item and self.item.event.has_subevents: if hasattr(self, 'item') and self.item and self.item.event.has_subevents:
+1 -1
View File
@@ -88,7 +88,7 @@ class LogEntry(models.Model):
class Meta: class Meta:
ordering = ('-datetime', '-id') ordering = ('-datetime', '-id')
indexes = [models.Index(fields=["datetime", "id"], name="pretixbase__datetim_b1fe5a_idx")] indexes = [models.Index(fields=["datetime", "id"])]
def display(self): def display(self):
from pretix.base.logentrytype_registry import log_entry_types from pretix.base.logentrytype_registry import log_entry_types
+1 -1
View File
@@ -122,7 +122,7 @@ class ReusableMedium(LoggedModel):
class Meta: class Meta:
unique_together = (("identifier", "type", "organizer"),) unique_together = (("identifier", "type", "organizer"),)
indexes = [ indexes = [
models.Index(fields=("updated", "id"), name="pretixbase__updated_093277_idx"), models.Index(fields=("updated", "id")),
] ]
ordering = "identifier", "type", "organizer" ordering = "identifier", "type", "organizer"
+5 -5
View File
@@ -336,8 +336,8 @@ class Order(LockModel, LoggedModel):
verbose_name_plural = _("Orders") verbose_name_plural = _("Orders")
ordering = ("-datetime", "-pk") ordering = ("-datetime", "-pk")
indexes = [ indexes = [
models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"), models.Index(fields=["datetime", "id"]),
models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"), models.Index(fields=["last_modified", "id"]),
] ]
constraints = [ constraints = [
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"), models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
@@ -590,7 +590,7 @@ class Order(LockModel, LoggedModel):
not kwargs.get('force_save_with_deferred_fields', None) and 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)) (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 Order with deferred fields since we can't check if you missed " _fail("It is unsafe to call save() on an OrderFee 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 " "creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.") "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: 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)) _transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
elif not kwargs.get('force_save_with_deferred_fields', None): elif not kwargs.get('force_save_with_deferred_fields', None):
_fail("It is unsafe to call save() on an OrderPosition with deferred fields since we can't check if you missed " _fail("It is unsafe to call save() on an OrderFee 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 " "creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
"this.") "this.")
@@ -3080,7 +3080,7 @@ class Transaction(models.Model):
class Meta: class Meta:
ordering = 'datetime', 'pk' ordering = 'datetime', 'pk'
indexes = [ indexes = [
models.Index(fields=['datetime', 'id'], name="pretixbase__datetim_b20405_idx") models.Index(fields=['datetime', 'id'])
] ]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
+1 -8
View File
@@ -319,9 +319,6 @@ class TeamQuerySet(models.QuerySet):
def event_permission_q(cls, perm_name): def event_permission_q(cls, perm_name):
from ..permissions import assert_valid_event_permission 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 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]]) 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) assert_valid_event_permission(perm_name, allow_legacy=False)
@@ -334,9 +331,6 @@ class TeamQuerySet(models.QuerySet):
def organizer_permission_q(cls, perm_name): def organizer_permission_q(cls, perm_name):
from ..permissions import assert_valid_organizer_permission 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 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]]) 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) assert_valid_organizer_permission(perm_name, allow_legacy=False)
@@ -556,7 +550,7 @@ class TeamAPIToken(models.Model):
""" """
return self.team.organizer_permission_set() if self.team.organizer == organizer else set() 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, session_key=None) -> bool: def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
""" """
Checks if this token is part of a team that grants access of type ``perm_name`` Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``. to the event ``event``.
@@ -565,7 +559,6 @@ class TeamAPIToken(models.Model):
:param event: The event to check :param event: The event to check
:param perm_name: The permission, e.g. ``event.orders:read`` :param perm_name: The permission, e.g. ``event.orders:read``
:param request: This parameter is ignored and only defined for compatibility reasons. :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 :return: bool
""" """
has_event_access = (self.team.all_events and organizer == self.team.organizer) or ( has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
+15 -32
View File
@@ -54,7 +54,7 @@ from bidi import get_display
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Exists, Max, Min, OuterRef from django.db.models import Max, Min
from django.db.models.fields.files import FieldFile from django.db.models.fields.files import FieldFile
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
@@ -76,7 +76,7 @@ from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph from reportlab.platypus import Paragraph
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Checkin, Event, Order, OrderPosition, Question from pretix.base.models import Event, Order, OrderPosition, Question
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import layout_image_variables, layout_text_variables from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
@@ -379,13 +379,6 @@ DEFAULT_VARIABLES = OrderedDict((
str(p) for p in generate_compressed_addon_list(op, order, ev) 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", { ("organizer", {
"label": _("Organizer name"), "label": _("Organizer name"),
"editor_sample": _("Event organizer company"), "editor_sample": _("Event organizer company"),
@@ -498,9 +491,9 @@ DEFAULT_VARIABLES = OrderedDict((
) if op.valid_until else "" ) if op.valid_until else ""
}), }),
("program_times", { ("program_times", {
"label": _("Program times"), "label": _("Program times: date and time"),
"editor_sample": _( "editor_sample": _(
"2017-05-31 10:00 12:00, Room 1\n2017-05-31 14:00 16:00, Room 2\n2017-05-31 14:00 2017-06-01 14:00, Building A"), "2017-05-31 10:00 12:00\n2017-05-31 14:00 16:00\n2017-05-31 14:00 2017-06-01 14:00"),
"evaluate": lambda op, order, ev: get_program_times(op, ev) "evaluate": lambda op, order, ev: get_program_times(op, ev)
}), }),
("medium_identifier", { ("medium_identifier", {
@@ -748,31 +741,21 @@ def get_seat(op: OrderPosition):
def get_program_times(op: OrderPosition, ev: Event): def get_program_times(op: OrderPosition, ev: Event):
ptstr = [] return '\n'.join([
for pt in op.item.program_times.all(): datetimerange(
ptstr.append([ pt.start.astimezone(ev.timezone),
datetimerange( pt.end.astimezone(ev.timezone),
pt.start.astimezone(ev.timezone), as_html=False
pt.end.astimezone(ev.timezone), ) for pt in op.item.program_times.all()
as_html=False ])
),
(', ' + ', '.join(
l.strip() for l in str(pt.location).splitlines() if l.strip())
) if str(pt.location).strip() else ''
])
return '\n'.join(''.join(l) for l in ptstr)
def generate_compressed_addon_list(op, order, event, only_checked_in=False): def generate_compressed_addon_list(op, order, event):
itemcount = defaultdict(int) itemcount = defaultdict(int)
addon_qs = ( addons = [p for p in (
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {}) op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
else op.addons.select_related('item', 'variation') 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: for pos in addons:
itemcount[pos.item, pos.variation] += 1 itemcount[pos.item, pos.variation] += 1
@@ -929,7 +912,7 @@ class Renderer:
# We do not use str.format like in emails so we (a) can evaluate lazily and (b) can re-implement this # We do not use str.format like in emails so we (a) can evaluate lazily and (b) can re-implement this
# 1:1 on other platforms that render PDFs through our API (libpretixprint) # 1:1 on other platforms that render PDFs through our API (libpretixprint)
return re.sub(r'\{([-a-zA-Z0-9:_]+)\}', replace, text) return re.sub(r'\{([a-zA-Z0-9:_]+)\}', replace, text)
elif o['content'].startswith('itemmeta:'): elif o['content'].startswith('itemmeta:'):
if op.variation_id: if op.variation_id:
+24 -29
View File
@@ -49,39 +49,14 @@ class PluginType(Enum):
EXPORT = 4 EXPORT = 4
def plugin_is_available(meta, event=None, organizer=None):
if not hasattr(meta.app, 'is_available'):
return True
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT:
if event:
return meta.app.is_available(event)
elif organizer:
if not hasattr(organizer, '_plugin_availability_fallback_event'):
with scope(organizer=organizer):
setattr(organizer, '_plugin_availability_fallback_event', organizer.events.first())
return (
organizer._plugin_availability_fallback_event
and meta.app.is_available(organizer._plugin_availability_fallback_event)
)
elif level == PLUGIN_LEVEL_ORGANIZER:
if organizer:
return meta.app.is_available(organizer)
elif event:
return meta.app.is_available(event.organizer)
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer):
return meta.app.is_available(event or organizer)
return True
def get_all_plugins(*, event=None, organizer=None) -> List[type]: def get_all_plugins(*, event=None, organizer=None) -> List[type]:
""" """
Returns the PretixPluginMeta classes of all plugins found in the installed Django apps. Returns the PretixPluginMeta classes of all plugins found in the installed Django apps.
""" """
assert not event or not organizer assert not event or not organizer
plugins = [] plugins = []
event_fallback = None
event_fallback_used = False
for app in apps.get_app_configs(): for app in apps.get_app_configs():
if hasattr(app, 'PretixPluginMeta'): if hasattr(app, 'PretixPluginMeta'):
meta = app.PretixPluginMeta meta = app.PretixPluginMeta
@@ -90,8 +65,28 @@ def get_all_plugins(*, event=None, organizer=None) -> List[type]:
if app.name in settings.PRETIX_PLUGINS_EXCLUDE: if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue continue
if not plugin_is_available(meta, event, organizer): level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
continue if level == PLUGIN_LEVEL_EVENT:
if event and hasattr(app, 'is_available'):
if not app.is_available(event):
continue
elif organizer and hasattr(app, 'is_available'):
if not event_fallback_used:
with scope(organizer=organizer):
event_fallback = organizer.events.first()
event_fallback_used = True
if not event_fallback or not app.is_available(event_fallback):
continue
elif level == PLUGIN_LEVEL_ORGANIZER:
if organizer and hasattr(app, 'is_available'):
if not app.is_available(organizer):
continue
elif event and hasattr(app, 'is_available'):
if not app.is_available(event.organizer):
continue
elif level == PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID and (event or organizer) and hasattr(app, 'is_available'):
if not app.is_available(event or organizer):
continue
plugins.append(meta) plugins.append(meta)
return sorted( return sorted(
+2 -2
View File
@@ -162,12 +162,12 @@ error_messages = {
'price_too_high': gettext_lazy('The entered price is to high.'), 'price_too_high': gettext_lazy('The entered price is to high.'),
'voucher_invalid': gettext_lazy('This voucher code is not known in our database.'), 'voucher_invalid': gettext_lazy('This voucher code is not known in our database.'),
'voucher_min_usages': ngettext_lazy( 'voucher_min_usages': ngettext_lazy(
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching product.', 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.', 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products.',
'number' 'number'
), ),
'voucher_min_usages_removed': ngettext_lazy( 'voucher_min_usages_removed': ngettext_lazy(
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching product. ' 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
'We have therefore removed some positions from your cart that can no longer be purchased like this.', 'We have therefore removed some positions from your cart that can no longer be purchased like this.',
'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. ' 'The voucher code "%(voucher)s" can only be used if you select at least %(number)s matching products. '
'We have therefore removed some positions from your cart that can no longer be purchased like this.', 'We have therefore removed some positions from your cart that can no longer be purchased like this.',
-28
View File
@@ -38,7 +38,6 @@ SOURCE_NAMES = {
None: _('European Central Bank'), # backwards-compatibility None: _('European Central Bank'), # backwards-compatibility
'eu:ecb:eurofxref-daily': _('European Central Bank'), 'eu:ecb:eurofxref-daily': _('European Central Bank'),
'cz:cnb:rate-fixing-daily': _('Czech National Bank'), 'cz:cnb:rate-fixing-daily': _('Czech National Bank'),
'pl:nbp:table-a': _('National Bank of Poland'),
} }
@@ -50,7 +49,6 @@ def fetch_rates(sender, **kwargs):
source_tasks = { source_tasks = {
'eu:ecb:eurofxref-daily': fetch_ecb_rates, 'eu:ecb:eurofxref-daily': fetch_ecb_rates,
'cz:cnb:rate-fixing-daily': fetch_cnb_cz_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(): for source_name, task in source_tasks.items():
@@ -146,29 +144,3 @@ def fetch_cnb_cz_rates():
rate=rate, 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,
)
)
+3 -19
View File
@@ -40,7 +40,6 @@ from pretix.base.models import (
CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken, CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken,
User, cachedfile_name, User, cachedfile_name,
) )
from pretix.base.models.auth import UserWithStaffSession
from pretix.base.models.exports import ScheduledOrganizerExport from pretix.base.models.exports import ScheduledOrganizerExport
from pretix.base.services.mail import mail from pretix.base.services.mail import mail
from pretix.base.services.tasks import ( from pretix.base.services.tasks import (
@@ -212,12 +211,7 @@ 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: if not perm_holder.has_event_permission(event.organizer, event, permission_name, request) and not staff_session:
continue continue
exporter: BaseExporter = response( exporter: BaseExporter = response(event=event, organizer=event.organizer, **kwargs)
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): if not exporter.available_for_user(user if user and user.is_authenticated else None):
continue continue
@@ -249,12 +243,7 @@ def init_organizer_exporters(
continue continue
if issubclass(response, OrganizerLevelExportMixin): if issubclass(response, OrganizerLevelExportMixin):
exporter: BaseExporter = response( exporter: BaseExporter = response(event=Event.objects.none(), organizer=organizer, **kwargs)
event=Event.objects.none(),
organizer=organizer,
permission_holder=token or device or (UserWithStaffSession(user) if staff_session else user),
**kwargs,
)
try: try:
if not perm_holder.has_organizer_permission(organizer, response.get_required_organizer_permission(), request) and not staff_session: if not perm_holder.has_organizer_permission(organizer, response.get_required_organizer_permission(), request) and not staff_session:
@@ -306,12 +295,7 @@ def init_organizer_exporters(
if not _has_permission_on_any_team_cache[permission_name] and not staff_session: if not _has_permission_on_any_team_cache[permission_name] and not staff_session:
continue continue
exporter: BaseExporter = response( exporter: BaseExporter = response(event=_event_list_cache[permission_name], organizer=organizer, **kwargs)
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): if not exporter.available_for_user(user if user and user.is_authenticated else None):
continue continue
+1 -15
View File
@@ -58,7 +58,6 @@ from pretix.base.invoicing.transmission import (
from pretix.base.models import ( from pretix.base.models import (
ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee, ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
) )
from pretix.base.models.orders import OrderPayment
from pretix.base.models.tax import EU_CURRENCIES from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.services.tasks import ( from pretix.base.services.tasks import (
TransactionAwareProfiledEventTask, TransactionAwareTask, TransactionAwareProfiledEventTask, TransactionAwareTask,
@@ -103,7 +102,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString) introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
additional = invoice.event.settings.get('invoice_additional_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) footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
if lp and lp.payment_provider and lp.state not in (OrderPayment.PAYMENT_STATE_FAILED, OrderPayment.PAYMENT_STATE_CANCELED): if lp and lp.payment_provider:
if 'payment' in inspect.signature(lp.payment_provider.render_invoice_text).parameters: if 'payment' in inspect.signature(lp.payment_provider.render_invoice_text).parameters:
payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp)) payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp))
else: else:
@@ -205,19 +204,6 @@ def build_invoice(invoice: Invoice) -> Invoice:
invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP) invoice.foreign_currency_rate = rate.rate.quantize(Decimal('0.0001'), ROUND_HALF_UP)
invoice.foreign_currency_rate_date = rate.source_date invoice.foreign_currency_rate_date = rate.source_date
invoice.foreign_currency_source = 'cz:cnb:rate-fixing-daily' 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: except InvoiceAddress.DoesNotExist:
ia = None ia = None
+1 -1
View File
@@ -411,7 +411,7 @@ def mail_send_task(self, **kwargs) -> bool:
try: try:
outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail) outgoing_mail = OutgoingMail.objects.select_for_update(of=OF_SELF).get(pk=outgoing_mail)
except OutgoingMail.DoesNotExist: except OutgoingMail.DoesNotExist:
logger.info(f"Ignoring job for non existing email {outgoing_mail}") logger.info(f"Ignoring job for non existing email {outgoing_mail.guid}")
return False return False
if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT: if outgoing_mail.status == OutgoingMail.STATUS_INFLIGHT:
logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}") logger.info(f"Ignoring job for inflight email {outgoing_mail.guid}")
+50 -70
View File
@@ -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.i18n import get_language_without_region, language
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Device, Event, GiftCard, Item, ItemVariation, LogEntry, CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
Membership, Order, OrderPayment, OrderPosition, Quota, Seat, Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
SeatCategoryMapping, User, Voucher, Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.orders import ( from pretix.base.models.orders import (
@@ -727,6 +727,8 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
_check_date(event, time_machine_now_dt) _check_date(event, time_machine_now_dt)
products_seen = Counter() products_seen = Counter()
q_avail = Counter()
v_avail = Counter()
v_usages = Counter() v_usages = Counter()
v_budget = {} v_budget = {}
deleted_positions = set() deleted_positions = set()
@@ -791,9 +793,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
shared_lock_objects=[event] shared_lock_objects=[event]
) )
q_avail = Counter()
v_avail = Counter()
# Check maximum order size # Check maximum order size
limit = min(int(event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE) limit = min(int(event.settings.max_items_per_order), settings.PRETIX_MAX_ORDER_SIZE)
if sum(1 for cp in sorted_positions if not cp.addon_to) > limit: if sum(1 for cp in sorted_positions if not cp.addon_to) > limit:
@@ -1619,7 +1618,7 @@ class OrderChangeManager:
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership')) MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff')) CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership', AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
'valid_from', 'valid_until', 'is_bundled', 'result', 'count')) 'valid_from', 'valid_until', 'is_bundled', 'result'))
SplitOperation = namedtuple('SplitOperation', ('position',)) SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff')) FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff')) AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
@@ -1633,24 +1632,16 @@ class OrderChangeManager:
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple()) ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
class AddPositionResult: class AddPositionResult:
_positions: Optional[List[OrderPosition]] _position: Optional[OrderPosition]
def __init__(self): def __init__(self):
self._positions = None self._position = None
@property @property
def position(self) -> OrderPosition: def position(self) -> OrderPosition:
if self._positions is None: if self._position is None:
raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.") raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.")
if len(self._positions) != 1: return self._position
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): def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order self.order = order
@@ -1857,12 +1848,8 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None, def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None, subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None, count: int = 1) -> 'OrderChangeManager.AddPositionResult': valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult':
if count < 1:
raise ValueError("Count must be positive")
if isinstance(seat, str): if isinstance(seat, str):
if count > 1:
raise ValueError("Cannot combine count > 1 with seat")
if not seat: if not seat:
seat = None seat = None
else: else:
@@ -1916,14 +1903,14 @@ class OrderChangeManager:
if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'): if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'):
self._invoice_dirty = True self._invoice_dirty = True
self._totaldiff_guesstimate += price.gross * count self._totaldiff_guesstimate += price.gross
self._quotadiff.update({q: count for q in new_quotas}) self._quotadiff.update(new_quotas)
if seat: if seat:
self._seatdiff.update([seat]) self._seatdiff.update([seat])
result = self.AddPositionResult() result = self.AddPositionResult()
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership, self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
valid_from, valid_until, is_bundled, result, count)) valid_from, valid_until, is_bundled, result))
return result return result
def split(self, position: OrderPosition): def split(self, position: OrderPosition):
@@ -2543,35 +2530,29 @@ class OrderChangeManager:
secret_dirty.remove(position) secret_dirty.remove(position)
position.save(update_fields=['canceled', 'secret']) position.save(update_fields=['canceled', 'secret'])
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
new_pos = [] pos = OrderPosition.objects.create(
new_logs = [] item=op.item, variation=op.variation, addon_to=op.addon_to,
for i in range(op.count): price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code,
pos = OrderPosition.objects.create( tax_value=op.price.tax, tax_rule=op.item.tax_rule,
item=op.item, variation=op.variation, addon_to=op.addon_to, positionid=nextposid, subevent=op.subevent, seat=op.seat,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until,
tax_value=op.price.tax, tax_rule=op.item.tax_rule, is_bundled=op.is_bundled,
positionid=nextposid, subevent=op.subevent, seat=op.seat, )
used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until, nextposid += 1
is_bundled=op.is_bundled, self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
) 'position': pos.pk,
nextposid += 1 'item': op.item.pk,
new_pos.append(pos) 'variation': op.variation.pk if op.variation else None,
new_logs.append(self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ 'addon_to': op.addon_to.pk if op.addon_to else None,
'position': pos.pk, 'price': op.price.gross,
'item': op.item.pk, 'positionid': pos.positionid,
'variation': op.variation.pk if op.variation else None, 'membership': pos.used_membership_id,
'addon_to': op.addon_to.pk if op.addon_to else None, 'subevent': op.subevent.pk if op.subevent else None,
'price': op.price.gross, 'seat': op.seat.pk if op.seat else None,
'positionid': pos.positionid, 'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'membership': pos.used_membership_id, 'valid_until': op.valid_until.isoformat() if op.valid_until else None,
'subevent': op.subevent.pk if op.subevent else None, })
'seat': op.seat.pk if op.seat else None, op.result._position = pos
'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): elif isinstance(op, self.SplitOperation):
position = position_cache.setdefault(op.position.pk, op.position) position = position_cache.setdefault(op.position.pk, op.position)
split_positions.append(position) split_positions.append(position)
@@ -2896,7 +2877,7 @@ class OrderChangeManager:
return total return total
def _check_order_size(self): def _check_order_size(self):
if (len(self.order.positions.all()) + sum([op.count for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE:
raise OrderError( raise OrderError(
self.error_messages['max_order_size'] % { self.error_messages['max_order_size'] % {
'max': settings.PRETIX_MAX_ORDER_SIZE, 'max': settings.PRETIX_MAX_ORDER_SIZE,
@@ -2957,7 +2938,7 @@ class OrderChangeManager:
]) + len([ ]) + len([
o for o in self._operations if isinstance(o, self.SplitOperation) o for o in self._operations if isinstance(o, self.SplitOperation)
]) ])
adds = sum([o.count for o in self._operations if isinstance(o, self.AddOperation)]) adds = len([o for o in self._operations if isinstance(o, self.AddOperation)])
if current > 0 and current - cancels + adds < 1: if current > 0 and current - cancels + adds < 1:
raise OrderError(self.error_messages['complete_cancel']) raise OrderError(self.error_messages['complete_cancel'])
@@ -3004,18 +2985,17 @@ class OrderChangeManager:
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart: elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
fake_cart.remove(positions_to_fake_cart[op.position]) fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation): elif isinstance(op, self.AddOperation):
for i in range(op.count): cp = CartPosition(
cp = CartPosition( event=self.event,
event=self.event, item=op.item,
item=op.item, variation=op.variation,
variation=op.variation, used_membership=op.membership,
used_membership=op.membership, subevent=op.subevent,
subevent=op.subevent, seat=op.seat,
seat=op.seat, )
) cp.override_valid_from = op.valid_from
cp.override_valid_from = op.valid_from cp.override_valid_until = op.valid_until
cp.override_valid_until = op.valid_until fake_cart.append(cp)
fake_cart.append(cp)
try: try:
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode) 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: except ValidationError as e:
+1 -11
View File
@@ -100,7 +100,7 @@ def primary_font_kwargs():
choices = [('Open Sans', 'Open Sans')] choices = [('Open Sans', 'Open Sans')]
choices += sorted([ choices += sorted([
(a, FontSelect.FontOption(title=a, data=v)) for a, v in get_fonts(pdf_support_required=False).items() (a, {"title": a, "data": v}) for a, v in get_fonts(pdf_support_required=False).items()
], key=lambda a: a[0]) ], key=lambda a: a[0])
return { return {
'choices': choices, 'choices': choices,
@@ -574,7 +574,6 @@ DEFAULTS = {
('True', _('Based on European Central Bank daily rates, whenever the invoice recipient is in an EU ' ('True', _('Based on European Central Bank daily rates, whenever the invoice recipient is in an EU '
'country that uses a different currency.')), 'country that uses a different currency.')),
('CZK', _('Based on Czech National Bank daily rates, whenever the invoice amount is not in CZK.')), ('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( 'serializer_kwargs': dict(
@@ -583,7 +582,6 @@ DEFAULTS = {
('True', _('Based on European Central Bank daily rates, whenever the invoice recipient is in an EU ' ('True', _('Based on European Central Bank daily rates, whenever the invoice recipient is in an EU '
'country that uses a different currency.')), 'country that uses a different currency.')),
('CZK', _('Based on Czech National Bank daily rates, whenever the invoice amount is not in CZK.')), ('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.')),
), ),
), ),
}, },
@@ -4150,14 +4148,6 @@ def validate_event_settings(event, settings_dict):
) )
]} ]}
) )
if (
settings_dict.get('invoice_address_from_vat_id') and
settings_dict.get('invoice_address_from_country') and
settings_dict.get('invoice_address_from_country') not in VAT_ID_COUNTRIES
):
raise ValidationError({
'invoice_address_from_vat_id': _('VAT-ID is not supported for "{}".').format(settings_dict.get('invoice_address_from_country'))
})
payment_term_last = settings_dict.get('payment_term_last') payment_term_last = settings_dict.get('payment_term_last')
if payment_term_last and event.presale_end: if payment_term_last and event.presale_end:
+19 -60
View File
@@ -32,7 +32,6 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # 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. # License for the specific language governing permissions and limitations under the License.
import logging
import warnings import warnings
from typing import Any, Callable, Generic, List, Tuple, TypeVar from typing import Any, Callable, Generic, List, Tuple, TypeVar
@@ -49,8 +48,6 @@ from .plugins import (
PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_ORGANIZER,
) )
logger = logging.getLogger(__name__)
app_cache = {} app_cache = {}
T = TypeVar('T') T = TypeVar('T')
@@ -63,25 +60,23 @@ def _populate_app_cache():
def get_defining_app(o): def get_defining_app(o):
# If sentry packed this in a wrapper, unpack that # If sentry packed this in a wrapper, unpack that
module = getattr(o, "__module__", None) if "sentry" in o.__module__:
if module and "sentry" in module:
o = o.__wrapped__ o = o.__wrapped__
if hasattr(o, "__mocked_app"): if hasattr(o, "__mocked_app"):
return o.__mocked_app return o.__mocked_app
# Find the Django application this belongs to # Find the Django application this belongs to
searchpath = module or getattr(o.__class__, "__module__", None) or "" searchpath = o.__module__
# Core modules are always active # Core modules are always active
if searchpath and any(searchpath.startswith(cm) for cm in settings.CORE_MODULES): if any(searchpath.startswith(cm) for cm in settings.CORE_MODULES):
return 'CORE' return 'CORE'
if not app_cache: if not app_cache:
_populate_app_cache() _populate_app_cache()
app = None while True:
while searchpath:
app = app_cache.get(searchpath) app = app_cache.get(searchpath)
if "." not in searchpath or app: if "." not in searchpath or app:
break break
@@ -162,7 +157,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal):
if not app_cache: if not app_cache:
_populate_app_cache() _populate_app_cache()
for receiver in self._live_receivers(sender)[0]: for receiver in self._sorted_receivers(sender):
if self._is_receiver_active(sender, receiver): if self._is_receiver_active(sender, receiver):
response = receiver(signal=self, sender=sender, **named) response = receiver(signal=self, sender=sender, **named)
responses.append((receiver, response)) responses.append((receiver, response))
@@ -184,7 +179,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal):
if not app_cache: if not app_cache:
_populate_app_cache() _populate_app_cache()
for receiver in self._live_receivers(sender)[0]: for receiver in self._sorted_receivers(sender):
if self._is_receiver_active(sender, receiver): if self._is_receiver_active(sender, receiver):
named[chain_kwarg_name] = response named[chain_kwarg_name] = response
response = receiver(signal=self, sender=sender, **named) response = receiver(signal=self, sender=sender, **named)
@@ -209,7 +204,7 @@ class PluginSignal(Generic[T], django.dispatch.Signal):
if not app_cache: if not app_cache:
_populate_app_cache() _populate_app_cache()
for receiver in self._live_receivers(sender)[0]: for receiver in self._sorted_receivers(sender):
if self._is_receiver_active(sender, receiver): if self._is_receiver_active(sender, receiver):
try: try:
response = receiver(signal=self, sender=sender, **named) response = receiver(signal=self, sender=sender, **named)
@@ -219,35 +214,17 @@ class PluginSignal(Generic[T], django.dispatch.Signal):
responses.append((receiver, response)) responses.append((receiver, response))
return responses return responses
def asend(self, sender: T, **named): def _sorted_receivers(self, sender):
raise NotImplementedError() # NOQA orig_list = self._live_receivers(sender)
def asend_robust(self, sender: T, **named):
raise NotImplementedError() # NOQA
def _live_receivers(self, sender):
orig_list, orig_async_list = super()._live_receivers(sender)
if orig_async_list:
logger.error('Async receivers are not supported.')
raise NotImplementedError
def _getattr_fallback_to_class(obj, key):
return getattr(obj, key, getattr(obj.__class__, key))
def _is_core_module(receiver):
m = _getattr_fallback_to_class(receiver, "__module__")
return any(m.startswith(c) for c in settings.CORE_MODULES)
sorted_list = sorted( sorted_list = sorted(
orig_list, orig_list,
key=lambda receiver: ( key=lambda receiver: (
0 if _is_core_module(receiver) else 1, 0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1,
_getattr_fallback_to_class(receiver, "__module__"), receiver.__module__,
_getattr_fallback_to_class(receiver, "__name__"), receiver.__name__,
) )
) )
return sorted_list, [] return sorted_list
class EventPluginSignal(PluginSignal[Event]): class EventPluginSignal(PluginSignal[Event]):
@@ -323,41 +300,23 @@ class GlobalSignal(django.dispatch.Signal):
if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS: if not self.receivers or self.sender_receivers_cache.get(sender) is NO_RECEIVERS:
return response return response
for receiver in self._live_receivers(sender)[0]: for receiver in self._live_receivers(sender):
named[chain_kwarg_name] = response named[chain_kwarg_name] = response
response = receiver(signal=self, sender=sender, **named) response = receiver(signal=self, sender=sender, **named)
return response return response
def asend(self, sender: T, **named):
raise NotImplementedError() # NOQA
def asend_robust(self, sender: T, **named):
raise NotImplementedError() # NOQA
def _live_receivers(self, sender): def _live_receivers(self, sender):
# Ensure consistent sorting of receivers # Ensure consistent sorting of receivers
orig_list, orig_async_list = super()._live_receivers(sender) orig_list = super()._live_receivers(sender)
if orig_async_list:
logger.error('Async receivers are not supported.')
raise NotImplementedError
def _getattr_fallback_to_class(obj, key):
return getattr(obj, key, getattr(obj.__class__, key))
def _is_core_module(receiver):
m = _getattr_fallback_to_class(receiver, "__module__")
return any(m.startswith(c) for c in settings.CORE_MODULES)
sorted_list = sorted( sorted_list = sorted(
orig_list, orig_list,
key=lambda receiver: ( key=lambda receiver: (
0 if _is_core_module(receiver) else 1, 0 if any(receiver.__module__.startswith(m) for m in settings.CORE_MODULES) else 1,
_getattr_fallback_to_class(receiver, "__module__"), receiver.__module__,
_getattr_fallback_to_class(receiver, "__name__"), receiver.__name__,
) )
) )
return sorted_list, [] return sorted_list
class DeprecatedSignal(GlobalSignal): class DeprecatedSignal(GlobalSignal):
-4
View File
@@ -20,7 +20,6 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import logging import logging
import re
from collections import defaultdict from collections import defaultdict
from datetime import timedelta from datetime import timedelta
from importlib import import_module from importlib import import_module
@@ -53,7 +52,6 @@ from pretix.celery_app import app
from pretix.helpers.http import redirect_to_url from pretix.helpers.http import redirect_to_url
logger = logging.getLogger('pretix.base.tasks') logger = logging.getLogger('pretix.base.tasks')
RE_ASYNC_ID = re.compile(r"^[a-zA-Z0-9\-]+$")
class AsyncMixin: class AsyncMixin:
@@ -135,8 +133,6 @@ class AsyncMixin:
def get_result(self, request): def get_result(self, request):
if not request.GET.get('async_id'): if not request.GET.get('async_id'):
raise BadRequest("No async_id given") raise BadRequest("No async_id given")
if not RE_ASYNC_ID.match(request.GET.get('async_id')):
raise BadRequest("Invalid async_id given")
res = AsyncResult(request.GET.get('async_id')) res = AsyncResult(request.GET.get('async_id'))
if 'ajax' in self.request.GET: if 'ajax' in self.request.GET:
return JsonResponse(self._return_ajax_result(res, timeout=0.25)) return JsonResponse(self._return_ajax_result(res, timeout=0.25))
-6
View File
@@ -34,7 +34,6 @@
import datetime import datetime
import os import os
from dataclasses import dataclass
from django import forms from django import forms
from django.conf import settings from django.conf import settings
@@ -421,11 +420,6 @@ class SplitDateTimeField(forms.SplitDateTimeField):
class FontSelect(forms.RadioSelect): class FontSelect(forms.RadioSelect):
option_template_name = 'pretixcontrol/font_option.html' option_template_name = 'pretixcontrol/font_option.html'
@dataclass
class FontOption:
title: str
data: str
class ItemMultipleChoiceField(SafeModelMultipleChoiceField): class ItemMultipleChoiceField(SafeModelMultipleChoiceField):
def label_from_instance(self, obj): def label_from_instance(self, obj):
+4 -11
View File
@@ -63,7 +63,7 @@ from pretix.base.forms import (
from pretix.base.models import Event, Organizer, TaxRule, Team from pretix.base.models import Event, Organizer, TaxRule, Team
from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent
from pretix.base.models.organizer import TeamQuerySet from pretix.base.models.organizer import TeamQuerySet
from pretix.base.models.tax import TAX_CODE_LISTS, VAT_ID_COUNTRIES from pretix.base.models.tax import TAX_CODE_LISTS
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.services.placeholders import FormPlaceholderMixin from pretix.base.services.placeholders import FormPlaceholderMixin
from pretix.base.settings import ( from pretix.base.settings import (
@@ -73,8 +73,8 @@ from pretix.base.settings import (
) )
from pretix.base.validators import multimail_validate from pretix.base.validators import multimail_validate
from pretix.control.forms import ( from pretix.control.forms import (
FontSelect, MultipleLanguagesWidget, SalesChannelCheckboxSelectMultiple, MultipleLanguagesWidget, SalesChannelCheckboxSelectMultiple, SlugWidget,
SlugWidget, SplitDateTimeField, SplitDateTimePickerWidget, SplitDateTimeField, SplitDateTimePickerWidget,
) )
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
@@ -531,13 +531,6 @@ class EventUpdateForm(I18nModelForm):
class EventSettingsValidationMixin: class EventSettingsValidationMixin:
def clean_invoice_address_from_vat_id(self):
value = self.cleaned_data.get('invoice_address_from_vat_id')
country = self.cleaned_data.get('invoice_address_from_country')
if value and country and country not in VAT_ID_COUNTRIES:
return None
return value
def clean(self): def clean(self):
data = super().clean() data = super().clean()
settings_dict = self.obj.settings.freeze() settings_dict = self.obj.settings.freeze()
@@ -729,7 +722,7 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
del self.fields['event_list_filters'] del self.fields['event_list_filters']
del self.fields['event_calendar_future_only'] del self.fields['event_calendar_future_only']
self.fields['primary_font'].choices = [('Open Sans', 'Open Sans')] + sorted([ self.fields['primary_font'].choices = [('Open Sans', 'Open Sans')] + sorted([
(a, FontSelect.FontOption(title=a, data=v)) for a, v in get_fonts(self.event, pdf_support_required=False).items() (a, {"title": a, "data": v}) for a, v in get_fonts(self.event, pdf_support_required=False).items()
], key=lambda a: a[0]) ], key=lambda a: a[0])
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields # create "virtual" fields for better UX when editing <name>_asked and <name>_required fields
-127
View File
@@ -1528,133 +1528,6 @@ class SubEventFilterForm(FilterForm):
return self.event.organizer.meta_properties.filter(filter_allowed=True) return self.event.organizer.meta_properties.filter(filter_allowed=True)
class QuotaFilterForm(FilterForm):
orders = {
'-date': ('-subevent__date_from', 'name', 'pk'),
'date': ('subevent__date_from', '-name', '-pk'),
'size': ('size', 'name', 'pk'),
'-size': ('-size', '-name', '-pk'),
'name': ('name', 'pk'),
'-name': ('-name', '-pk'),
}
subevent = forms.ModelChoiceField(
label=pgettext_lazy('subevent', 'Date'),
queryset=SubEvent.objects.none(),
required=False,
empty_label=pgettext_lazy('subevent', 'All dates')
)
date_from = forms.DateField(
label=_('Date from'),
required=False,
widget=DatePickerWidget({
'placeholder': _('Date from'),
}),
)
date_until = forms.DateField(
label=_('Date until'),
required=False,
widget=DatePickerWidget({
'placeholder': _('Date until'),
}),
)
time_from = forms.TimeField(
label=_('Start time from'),
required=False,
widget=TimePickerWidget({}),
)
time_until = forms.TimeField(
label=_('Start time until'),
required=False,
widget=TimePickerWidget({}),
)
weekday = forms.MultipleChoiceField(
label=_('Weekday'),
choices=(
('2', _('Monday')),
('3', _('Tuesday')),
('4', _('Wednesday')),
('5', _('Thursday')),
('6', _('Friday')),
('7', _('Saturday')),
('1', _('Sunday')),
),
widget=forms.CheckboxSelectMultiple,
required=False
)
query = forms.CharField(
label=_('Quota name'),
widget=forms.TextInput(),
required=False
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
if self.event.has_subevents:
self.fields['date_from'].widget = DatePickerWidget()
self.fields['date_until'].widget = DatePickerWidget()
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'All dates')
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
else:
del self.fields['subevent']
del self.fields['date_from']
del self.fields['date_until']
del self.fields['time_from']
del self.fields['time_until']
del self.fields['weekday']
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('weekday'):
qs = qs.annotate(wday=ExtractWeekDay('subevent__date_from')).filter(wday__in=fdata.get('weekday'))
if fdata.get('subevent'):
qs = qs.filter(subevent=fdata["subevent"])
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(name__icontains=query)
if fdata.get('date_until'):
date_end = make_aware(datetime.combine(
fdata.get('date_until') + timedelta(days=1),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(
Q(subevent__date_to__isnull=True, subevent__date_from__lt=date_end) |
Q(subevent__date_to__isnull=False, subevent__date_to__lt=date_end)
)
if fdata.get('date_from'):
date_start = make_aware(datetime.combine(
fdata.get('date_from'),
time(hour=0, minute=0, second=0, microsecond=0)
), get_current_timezone())
qs = qs.filter(subevent__date_from__gte=date_start)
if fdata.get('time_until'):
qs = qs.filter(subevent__date_from__time__lte=fdata.get('time_until'))
if fdata.get('time_from'):
qs = qs.filter(subevent__date_from__time__gte=fdata.get('time_from'))
if fdata.get('ordering'):
qs = qs.order_by(*get_deterministic_ordering(Quota, self.get_order_by()))
else:
qs = qs.order_by('-subevent__date_from', 'name', 'pk')
return qs
class OrganizerFilterForm(FilterForm): class OrganizerFilterForm(FilterForm):
orders = { orders = {
'slug': 'slug', 'slug': 'slug',
+1 -61
View File
@@ -43,7 +43,6 @@ from django.core.exceptions import ValidationError
from django.db.models import Max, Q from django.db.models import Max, Q
from django.forms import ChoiceField, RadioSelect from django.forms import ChoiceField, RadioSelect
from django.forms.formsets import DELETION_FIELD_NAME from django.forms.formsets import DELETION_FIELD_NAME
from django.forms.utils import ErrorDict
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape, format_html from django.utils.html import escape, format_html
@@ -376,60 +375,6 @@ class QuotaForm(I18nModelForm):
return inst return inst
class QuotaBulkEditForm(QuotaForm):
def __init__(self, *args, **kwargs):
self.mixed_values = kwargs.pop('mixed_values')
self.queryset = kwargs.pop('queryset')
super().__init__(**kwargs)
self.fields.pop("subevent", None) # Would add extra complexity and it's hard to imagine a use case for that
self.fields["name"].required = False
self.fields["itemvars"].required = False
def clean(self):
d = super().clean()
if self.prefix + "name" in self.data.getlist('_bulk') and not d.get("name"):
raise ValidationError({"name": _("This field is required.")})
if self.prefix + "itemvars" in self.data.getlist('_bulk') and not d.get("itemvars"):
raise ValidationError({"itemvars": _("This field is required.")})
return d
def save(self, commit=True):
objs = list(self.queryset)
fields = set()
for k in self.fields:
cb_val = self.prefix + k
if cb_val not in self.data.getlist('_bulk'):
continue
fields.add(k)
if k == 'itemvars':
selected_items = set(list(self.event.items.filter(id__in=[
i.split('-')[0] for i in self.cleaned_data['itemvars']
])))
selected_variations = list(ItemVariation.objects.filter(item__event=self.event, id__in=[
i.split('-')[1] for i in self.cleaned_data['itemvars'] if '-' in i
]))
for obj in objs:
obj.items.set(selected_items)
obj.variations.set(selected_variations)
else:
for obj in objs:
setattr(obj, k, self.cleaned_data[k])
fields = [f for f in fields if f != 'itemvars']
if fields:
Quota.objects.bulk_update(objs, fields, 200)
def full_clean(self):
if len(self.data) == 0:
# form wasn't submitted
self._errors = ErrorDict()
return
super().full_clean()
class ItemCreateForm(I18nModelForm): class ItemCreateForm(I18nModelForm):
NONE = 'none' NONE = 'none'
EXISTING = 'existing' EXISTING = 'existing'
@@ -629,7 +574,7 @@ class ItemCreateForm(I18nModelForm):
instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation, instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation,
count=b.count, designated_price=b.designated_price) count=b.count, designated_price=b.designated_price)
for pt in self.cleaned_data['copy_from'].program_times.all(): for pt in self.cleaned_data['copy_from'].program_times.all():
instance.program_times.create(start=pt.start, end=pt.end, location=pt.location) instance.program_times.create(start=pt.start, end=pt.end)
item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance) item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance)
@@ -1409,10 +1354,6 @@ class ItemProgramTimeForm(I18nModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['end'].widget.attrs['data-date-after'] = '#id_{prefix}-start_0'.format(prefix=self.prefix) self.fields['end'].widget.attrs['data-date-after'] = '#id_{prefix}-start_0'.format(prefix=self.prefix)
self.fields['location'].widget.attrs['rows'] = '3'
self.fields['location'].widget.attrs['placeholder'] = _(
'Sample Conference Center, Heidelberg, Germany'
)
class Meta: class Meta:
model = ItemProgramTime model = ItemProgramTime
@@ -1420,7 +1361,6 @@ class ItemProgramTimeForm(I18nModelForm):
fields = [ fields = [
'start', 'start',
'end', 'end',
'location'
] ]
field_classes = { field_classes = {
'start': forms.SplitDateTimeField, 'start': forms.SplitDateTimeField,
-8
View File
@@ -331,10 +331,6 @@ class OtherOperationsForm(forms.Form):
class OrderPositionAddForm(forms.Form): class OrderPositionAddForm(forms.Form):
count = forms.IntegerField(
label=_('Number of products to add'),
initial=1,
)
itemvar = forms.ChoiceField( itemvar = forms.ChoiceField(
label=_('Product') label=_('Product')
) )
@@ -436,10 +432,6 @@ class OrderPositionAddForm(forms.Form):
d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0] d['used_membership'] = [m for m in self.memberships if str(m.pk) == d['used_membership']][0]
else: else:
d['used_membership'] = None 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 return d
+1 -11
View File
@@ -28,7 +28,7 @@ from django.forms import formset_factory
from django.forms.utils import ErrorDict from django.forms.utils import ErrorDict
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _
from i18nfield.forms import I18nInlineFormSet from i18nfield.forms import I18nInlineFormSet
from pretix.base.forms import I18nModelForm from pretix.base.forms import I18nModelForm
@@ -102,16 +102,6 @@ class SubEventBulkForm(SubEventForm):
required=False, required=False,
limit_choices=('date_from', 'date_to'), 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): def __init__(self, *args, **kwargs):
self.event = kwargs['event'] self.event = kwargs['event']
+6 -6
View File
@@ -34,11 +34,11 @@
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
from collections import defaultdict from collections import defaultdict
from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import Optional from typing import Optional
import bleach import bleach
import dateutil.parser
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
from django.utils.formats import date_format from django.utils.formats import date_format
@@ -248,7 +248,7 @@ class OrderValidFromChanged(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data): def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('The validity start date for position #{posid} has been changed to {value}.').format( return _('The validity start date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
value=date_format(datetime.fromisoformat(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get( value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
'new_value') else '' 'new_value') else ''
) )
@@ -260,7 +260,7 @@ class OrderValidUntilChanged(OrderChangeLogEntryType):
def display_prefixed(self, event: Event, logentry: LogEntry, data): def display_prefixed(self, event: Event, logentry: LogEntry, data):
return _('The validity end date for position #{posid} has been changed to {value}.').format( return _('The validity end date for position #{posid} has been changed to {value}.').format(
posid=data.get('positionid', '?'), posid=data.get('positionid', '?'),
value=date_format(datetime.fromisoformat(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else '' value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else ''
) )
@@ -364,7 +364,7 @@ class CheckinErrorLogEntryType(OrderLogEntryType):
data['posid'] = logentry.parsed_data.get('positionid', '?') data['posid'] = logentry.parsed_data.get('positionid', '?')
if 'datetime' in data: if 'datetime' in data:
dt = datetime.fromisoformat(data.get('datetime')) dt = dateutil.parser.parse(data.get('datetime'))
if abs((logentry.datetime - dt).total_seconds()) > 5 or data.get('forced'): if abs((logentry.datetime - dt).total_seconds()) > 5 or data.get('forced'):
if event: if event:
data['datetime'] = date_format(dt.astimezone(event.timezone), "SHORT_DATETIME_FORMAT") data['datetime'] = date_format(dt.astimezone(event.timezone), "SHORT_DATETIME_FORMAT")
@@ -430,7 +430,7 @@ class OrderPrintLogEntryType(OrderLogEntryType):
return _('Position #{posid} has been printed at {datetime} with type "{type}".').format( return _('Position #{posid} has been printed at {datetime} with type "{type}".').format(
posid=data.get('positionid'), posid=data.get('positionid'),
datetime=date_format( datetime=date_format(
datetime.fromisoformat(data["datetime"]).astimezone(logentry.event.timezone), dateutil.parser.parse(data["datetime"]).astimezone(logentry.event.timezone),
"SHORT_DATETIME_FORMAT" "SHORT_DATETIME_FORMAT"
) if logentry.event else data["datetime"], ) if logentry.event else data["datetime"],
type=dict(PrintLog.PRINT_TYPES)[data["type"]], type=dict(PrintLog.PRINT_TYPES)[data["type"]],
@@ -985,7 +985,7 @@ class LegacyCheckinLogEntryType(OrderLogEntryType):
def display(self, logentry, data): def display(self, logentry, data):
# deprecated # deprecated
dt = datetime.fromisoformat(data.get('datetime')) dt = dateutil.parser.parse(data.get('datetime'))
tz = logentry.event.timezone tz = logentry.event.timezone
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT") dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
if 'list' in data: if 'list' in data:
+1 -1
View File
@@ -363,7 +363,7 @@ def get_global_navigation(request):
'icon': 'dashboard', 'icon': 'dashboard',
}, },
] ]
if request.user.is_in_any_teams or request.user.is_staff: if request.user.is_in_any_teams:
nav += [ nav += [
{ {
'label': _('Events'), 'label': _('Events'),
@@ -34,7 +34,6 @@
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
{% bootstrap_field form.start layout="control" %} {% bootstrap_field form.start layout="control" %}
{% bootstrap_field form.end layout="control" %} {% bootstrap_field form.end layout="control" %}
{% bootstrap_field form.location layout="control" %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
@@ -60,7 +59,6 @@
<div class="panel-body form-horizontal"> <div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.start layout="control" %} {% bootstrap_field formset.empty_form.start layout="control" %}
{% bootstrap_field formset.empty_form.end layout="control" %} {% bootstrap_field formset.empty_form.end layout="control" %}
{% bootstrap_field formset.empty_form.location layout="control" %}
</div> </div>
</div> </div>
{% endescapescript %} {% endescapescript %}
@@ -1,49 +0,0 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block content %}
<h1>
{% trans "Change multiple quotas" %}
<small>
{% blocktrans trimmed with number=quotas.count %}
{{ number }} selected
{% endblocktrans %}
</small>
</h1>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form_errors form %}
<div class="hidden">
{% for d in quotas %}
<input type="hidden" name="quota" value="{{ d.pk }}">
{% endfor %}
</div>
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="bulkedit" %}
{% bootstrap_field form.size layout="bulkedit" %}
</fieldset>
<fieldset>
<legend>{% trans "Items" %}</legend>
<p>
{% blocktrans trimmed %}
Please select the products or product variations this quota should be applied to. If you apply two
quotas to the same product, it will only be available if <strong>both</strong> quotas have capacity
left.
{% endblocktrans %}
</p>
{% bootstrap_field form.itemvars layout="bulkedit" %}
</fieldset>
<fieldset>
<legend>{% trans "Advanced options" %}</legend>
{% bootstrap_field form.close_when_sold_out layout="bulkedit" %}
{% bootstrap_field form.release_after_exit layout="bulkedit" %}
{% bootstrap_field form.ignore_for_event_availability layout="bulkedit" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -1,34 +0,0 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete quotas" %}{% endblock %}
{% block content %}
<h1>{% trans "Delete quotas" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if allowed %}
<p>{% blocktrans trimmed count num=allowed|length %}
Are you sure you want to delete the following quota?
{% plural %}
Are you sure you want to delete the following {{ num }} quotas?
{% endblocktrans %}</p>
<ul>
{% for q in allowed %}
<li>
{{ q }} {% if q.subevent %}({{ q.subevent }}){% endif %}
<input type="hidden" name="quota" value="{{ q.pk }}">
</li>
{% endfor %}
</ul>
{% endif %}
<div class="form-group submit-group">
<a href="{% url "control:event.items.quotas" organizer=request.event.organizer.slug event=request.event.slug %}"
class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save" value="delete_confirm" name="action">
{% trans "Delete" %}
</button>
</div>
</form>
{% endblock %}
@@ -1,7 +1,6 @@
{% extends "pretixcontrol/items/base.html" %} {% extends "pretixcontrol/items/base.html" %}
{% load i18n %} {% load i18n %}
{% load urlreplace %} {% load urlreplace %}
{% load bootstrap3 %}
{% block title %}{% trans "Quotas" %}{% endblock %} {% block title %}{% trans "Quotas" %}{% endblock %}
{% block inside %} {% block inside %}
<h1>{% trans "Quotas" %}</h1> <h1>{% trans "Quotas" %}</h1>
@@ -14,12 +13,21 @@
number of a specific ticket type at the same time. number of a specific ticket type at the same time.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
{% if quotas|length == 0 and not filter_form.filtered %} {% if request.event.has_subevents %}
<form class="form-inline helper-display-inline" action="" method="get">
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
</form>
{% endif %}
{% if quotas|length == 0 %}
<div class="empty-collection"> <div class="empty-collection">
<p> <p>
{% blocktrans trimmed %} {% if request.GET.subevent %}
You haven't created any quotas yet. {% trans "Your search did not match any quotas." %}
{% endblocktrans %} {% else %}
{% blocktrans trimmed %}
You haven't created any quotas yet.
{% endblocktrans %}
{% endif %}
</p> </p>
{% if 'event.items:write' in request.eventpermset %} {% if 'event.items:write' in request.eventpermset %}
@@ -28,160 +36,79 @@
{% endif %} {% endif %}
</div> </div>
{% else %} {% else %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Filter" %}
</h3>
</div>
<form class="panel-body filter-form" action="" method="get">
<div class="row">
<div class="{% if not filter_form.subevent %}col-lg-6{% else %}col-lg-2{% endif %} col-md-6 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.query %}
</div>
{% if filter_form.subevent %}
<div class="col-lg-2 col-md-6 col-md-2 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.subevent %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.date_from %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.date_until %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.time_from %}
</div>
<div class="col-lg-2 col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.time_until %}
</div>
<div class="col-xs-12 one-line-checkboxes">
{% bootstrap_field filter_form.weekday %}
</div>
{% endif %}
</div>
<div class="text-right flip">
<button class="btn btn-primary btn-lg" type="submit">
<span class="fa fa-filter"></span>
{% trans "Filter" %}
</button>
</div>
</form>
</div>
{% if 'event.items:write' in request.eventpermset %} {% if 'event.items:write' in request.eventpermset %}
<p> <p>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new quota" %} <a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new quota" %}
</a> </a>
</p> </p>
{% endif %} {% endif %}
<form action="{% url "control:event.items.quotas.bulkaction" organizer=request.event.organizer.slug event=request.event.slug %}" method="post"> <div class="table-responsive">
{% csrf_token %} <table class="table table-hover table-quotas">
{% for field in filter_form %} <thead>
{{ field.as_hidden }} <tr>
{% endfor %} <th>{% trans "Quota name" %}
<div class="table-responsive"> <a href="?{% url_replace request 'ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
<table class="table table-hover table-quotas"> <a href="?{% url_replace request 'ordering' 'name' %}"><i class="fa fa-caret-up"></i></a>
<thead> </th>
<tr> <th>{% trans "Products" %}</th>
{% if "event.items:write" in request.eventpermset %} {% if request.event.has_subevents %}
<th> <th>{% trans "Date" context "subevent" %}
<label aria-label="{% trans "select all rows for batch-operation" %}" class="batch-select-label"><input type="checkbox" data-toggle-table/></label> <a href="?{% url_replace request 'ordering' '-date' %}"><i class="fa fa-caret-down"></i></a>
</th> <a href="?{% url_replace request 'ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
{% endif %}
<th>{% trans "Quota name" %}
<a href="?{% url_replace request 'filter-ordering' '-name' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'name' %}"><i class="fa fa-caret-up"></i></a>
</th> </th>
<th>{% trans "Products" %}</th>
{% if request.event.has_subevents %}
<th>{% trans "Date" context "subevent" %}
<a href="?{% url_replace request 'filter-ordering' '-date' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
</th>
{% endif %}
<th>{% trans "Total capacity" %}
<a href="?{% url_replace request 'filter-ordering' '-size' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'filter-ordering' 'size' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Capacity left" %}</th>
<th class="action-col-2"></th>
</tr>
{% if "event.items:write" in request.eventpermset and page_obj.paginator.num_pages > 1 %}
<tr class="table-select-all warning hidden">
<td>
<input type="checkbox" name="__ALL" id="__all" data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="6">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %} {% endif %}
</thead> <th>{% trans "Total capacity" %}
<tbody> <a href="?{% url_replace request 'ordering' '-size' %}"><i class="fa fa-caret-down"></i></a>
{% for q in quotas %} <a href="?{% url_replace request 'ordering' 'size' %}"><i class="fa fa-caret-up"></i></a>
<tr> </th>
{% if "event.items:write" in request.eventpermset %} <th>{% trans "Capacity left" %}</th>
<td> <th class="action-col-2"></th>
<label aria-label="{% trans "select row for batch-operation" %}" class="batch-select-label"><input type="checkbox" name="quota" class="batch-select-checkbox" value="{{ q.pk }}"/></label> </tr>
</td> </thead>
<tbody>
{% for q in quotas %}
<tr>
<td>
<strong><a href="{% url "control:event.items.quotas.show" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}">{{ q.name }}</a></strong>
{% if q.ignore_for_event_availability %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Ignore this quota when determining event availability" %}"></span>
{% endif %} {% endif %}
</td>
<td>
<ul>
{% for item in q.cached_items %}
{% if not item.has_variations %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a></li>
{% endif %}
{% endfor %}
{% for v in q.variations.all %}
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=v.item.id %}#tab-0-3-open">
{{ v.item }} {{ v }}</a></li>
{% endfor %}
</ul>
</td>
{% if request.event.has_subevents %}
<td> <td>
<strong><a href="{% url "control:event.items.quotas.show" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}">{{ q.name }}</a></strong> {{ q.subevent.name }} {{ q.subevent.get_date_range_display_with_times }}
{% if q.ignore_for_event_availability %}
<span class="fa fa-eye-slash text-muted" data-toggle="tooltip" title="{% trans "Ignore this quota when determining event availability" %}"></span>
{% endif %}
</td> </td>
<td> {% endif %}
<ul> <td>{% if q.size == None %}Unlimited{% else %}{{ q.size }}{% endif %}</td>
{% for item in q.cached_items %} <td>{% include "pretixcontrol/items/fragment_quota_availability.html" with availability=q.cached_avail closed=q.closed %}</td>
{% if not item.has_variations %} <td class="text-right flip">
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a></li> {% if 'event.items:write' in request.eventpermset %}
{% endif %} <a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
{% endfor %} <a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ q.id }}"
{% for v in q.variations.all %} class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<li><a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=v.item.id %}#tab-0-3-open"> <span class="fa fa-copy"></span>
{{ v.item }} {{ v }}</a></li> </a>
{% endfor %} <a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</ul>
</td>
{% if request.event.has_subevents %}
<td>
{{ q.subevent.name }} {{ q.subevent.get_date_range_display_with_times }}
</td>
{% endif %} {% endif %}
<td>{% if q.size == None %}Unlimited{% else %}{{ q.size }}{% endif %}</td> </td>
<td>{% include "pretixcontrol/items/fragment_quota_availability.html" with availability=q.cached_avail closed=q.closed %}</td> </tr>
<td class="text-right flip"> {% endfor %}
{% if 'event.items:write' in request.eventpermset %} </tbody>
<a href="{% url "control:event.items.quotas.edit" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a> </table>
<a href="{% url "control:event.items.quotas.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ q.id }}" </div>
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
<a href="{% url "control:event.items.quotas.delete" organizer=request.event.organizer.slug event=request.event.slug quota=q.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if "event.items:write" in request.eventpermset %}
<div class="batch-select-actions">
<button type="submit" class="btn btn-danger btn-save" name="action" value="delete">
<i class="fa fa-trash"></i>{% trans "Delete selected" %}
</button>
<button type="submit" class="btn btn-primary btn-save" name="action" value="edit"
formaction="{% url "control:event.items.quotas.bulkedit" organizer=request.event.organizer.slug event=request.event.slug %}">
<i class="fa fa-edit"></i>{% trans "Edit selected" %}
</button>
</div>
{% endif %}
</form>
{% endif %} {% endif %}
{% include "pretixcontrol/pagination.html" %} {% include "pretixcontrol/pagination.html" %}
{% endblock %} {% endblock %}
@@ -329,7 +329,6 @@
{{ add_form.custom_error }} {{ add_form.custom_error }}
</div> </div>
{% endif %} {% endif %}
{% bootstrap_field add_form.count layout="control" %}
{% bootstrap_field add_form.itemvar layout="control" %} {% bootstrap_field add_form.itemvar layout="control" %}
{% bootstrap_field add_form.price addon_after=request.event.currency layout="control" %} {% bootstrap_field add_form.price addon_after=request.event.currency layout="control" %}
{% if add_form.addon_to %} {% if add_form.addon_to %}
@@ -365,7 +364,6 @@
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div class="form-horizontal"> <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.itemvar layout="control" %}
{% bootstrap_field add_position_formset.empty_form.price addon_after=request.event.currency 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 %} {% if add_position_formset.empty_form.addon_to %}
@@ -471,9 +471,7 @@
{% endif %} {% endif %}
{% if line.subevent %} {% if line.subevent %}
<br/> <br/>
<span class="fa fa-calendar fa-fw"></span> <span class="fa fa-calendar fa-fw"></span> {{ line.subevent.name }} &middot; {{ line.subevent.get_date_range_display_with_times }}
<a href="{% url "control:event.subevent" organizer=request.event.organizer.slug event=request.event.slug subevent=line.subevent_id %}">{{ line.subevent.name }}</a>
&middot; {{ line.subevent.get_date_range_display_with_times }}
{% endif %} {% endif %}
{% if line.used_membership %} {% if line.used_membership %}
<br /><span class="fa fa-id-card fa-fw" aria-hidden="true"></span> <br /><span class="fa fa-id-card fa-fw" aria-hidden="true"></span>
@@ -379,8 +379,6 @@
<i class="fa fa-calendar"></i> {% trans "Add many time slots" %}</button> <i class="fa fa-calendar"></i> {% trans "Add many time slots" %}</button>
</p> </p>
</div> </div>
<hr />
{% bootstrap_field form.skip_if_overlap layout="control" horizontal_label_class='sr-only' horizontal_field_class='col-md-12' %}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "General information" %}</legend> <legend>{% trans "General information" %}</legend>
@@ -6,44 +6,35 @@
<h1>{% trans "Add a two-factor authentication device" %}</h1> <h1>{% trans "Add a two-factor authentication device" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
{% bootstrap_field form.name layout='horizontal' %} {% bootstrap_field form.name layout='horizontal' %}
<div class="form-group{% if form.devicetype.errors %} has-error{% endif %}"> <div class="form-group">
<label class="col-md-3 control-label">{% trans "Device type" %}</label> <label class="col-md-3 control-label">{% trans "Device type" %}</label>
<div class="col-md-9"> <div class="col-md-9">
<div> <div class="big-radio radio">
<div class="big-radio radio"> <label>
<label> <input type="radio" value="totp" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "totp" %}checked{% endif %}>
<input type="radio" required value="totp" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "totp" %}checked{% endif %}> <strong>{% trans "Smartphone with the Authenticator application" %}</strong><br>
<strong>{% trans "Smartphone with Authenticator app" %}</strong><br> <div class="help-block">
<div class="help-block"> {% blocktrans trimmed %}
{% blocktrans trimmed %} Use your smartphone with any Time-based One-Time-Password app like freeOTP, Google Authenticator or Proton Authenticator.
Use your smartphone with any Time-based One-Time-Password app like freeOTP, Google Authenticator or Proton Authenticator. {% endblocktrans %}
{% endblocktrans %} </div>
</div> </label>
</label> </div>
</div> <div class="big-radio radio">
<div class="big-radio radio"> <label>
<label> <input type="radio" value="webauthn" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "webauthn" %}checked{% endif %}>
<input type="radio" required value="webauthn" name="{{ form.devicetype.html_name }}" {% if form.devicetype.value == "webauthn" %}checked{% endif %}> <strong>{% trans "WebAuthn-compatible hardware token" %}</strong><br>
<strong>{% trans "WebAuthn-compatible hardware token" %}</strong><br> <div class="help-block">
<div class="help-block"> {% blocktrans trimmed %}
{% blocktrans trimmed %} Use a hardware token like the Yubikey, or biometric authentication on iOS, macOS and Android.
Use a hardware token like the Yubikey, or other biometric authentication like fingerprint or face recognition. {% endblocktrans %}
{% endblocktrans %} </div>
</div> </label>
</label>
</div>
</div> </div>
{% if form.devicetype.errors %}
<div class="help-block">
{% for error in form.devicetype.errors %}
<p>{{ error|escape }}</p>
{% endfor %}
</div>
{% endif %}
</div> </div>
</div> </div>
@@ -69,14 +69,11 @@
{% trans "Enter the displayed code here:" %} {% trans "Enter the displayed code here:" %}
<form class="form form-inline" method="post" action=""> <form class="form form-inline" method="post" action="">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<input type="number" name="token" class="form-control" required="required"> <input type="number" name="token" class="form-control" required="required">
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
{% trans "Continue" %} {% trans "Continue" %}
</button><br> </button><br>
<label>
<input type="checkbox" name="activate" checked="checked" value="on">
{% trans "Require second factor for future logins" %}
</label>
</form> </form>
</li> </li>
</ol> </ol>
@@ -12,13 +12,9 @@
</p> </p>
<form class="form form-inline" method="post" action="" id="webauthn-form"> <form class="form form-inline" method="post" action="" id="webauthn-form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<input type="hidden" id="webauthn-response" name="token" class="form-control" required="required"> <input type="hidden" id="webauthn-response" name="token" class="form-control" required="required">
<p>
<label>
<input type="checkbox" name="activate" checked="checked" value="on">
{% trans "Require second factor for future logins" %}
</label>
</p>
<button class="btn btn-primary sr-only" type="submit"></button> <button class="btn btn-primary sr-only" type="submit"></button>
</form> </form>
@@ -6,6 +6,7 @@
<h1>{% trans "Delete a two-factor authentication device" %}</h1> <h1>{% trans "Delete a two-factor authentication device" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<p>{% blocktrans trimmed with device=device.name %} <p>{% blocktrans trimmed with device=device.name %}
Are you sure you want to delete the authentication device "{{ device }}"? Are you sure you want to delete the authentication device "{{ device }}"?
{% endblocktrans %}</p> {% endblocktrans %}</p>
@@ -6,6 +6,7 @@
<h1>{% trans "Disable two-factor authentication" %}</h1> <h1>{% trans "Disable two-factor authentication" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<p> <p>
{% trans "Do you really want to disable two-factor authentication?" %} {% trans "Do you really want to disable two-factor authentication?" %}
</p> </p>
@@ -1,23 +1,58 @@
{% extends "pretixcontrol/base.html" %} {% extends "pretixcontrol/base.html" %}
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% load icon %}
{% block title %}{% trans "Enable two-factor authentication" %}{% endblock %} {% block title %}{% trans "Enable two-factor authentication" %}{% endblock %}
{% block content %} {% block content %}
<h1>{% trans "Enable two-factor authentication" %}</h1> <h1>{% trans "Enable two-factor authentication" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<p> <p>
{% trans "Do you really want to enable two-factor authentication?" %} {% trans "Do you really want to enable two-factor authentication?" %}
</p> </p>
<p> <p>
{% trans "You will no longer be able to log in to pretix without one of your configured devices." %} {% trans "You will no longer be able to log in to pretix without one of your configured devices." %}
{% trans "Please make sure to print out or copy the emergency tokens and store them in a safe place." %}
</p> </p>
{% if new_emergency_tokens %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Your emergency codes" %}</h3>
</div>
<div class="panel-body">
<p>
{% blocktrans trimmed %}
If you lose access to your devices, you can use one of your emergency tokens to log in.
We recommend to store them in a safe place, e.g. printed out or in a password manager.
Every token can be used at most once.
{% endblocktrans %}
</p>
<ul>
{% for code in new_emergency_tokens %}
<li>{{ code }}</li>
{% endfor %}
</ul>
<p>
<label>
<input type="checkbox" required>
{% trans "I stored my emergency tokens in a safe place." %}
</label>
</p>
</div>
</div>
{% else %}
<p>
{% icon "info-circle" %}
{% blocktrans trimmed with generation_date_time=static_tokens_device.created_at %}
You generated your emergency tokens on {{ generation_date_time }}.
{% endblocktrans %}
</p>
{% endif %}
<div class="form-group submit-group"> <div class="form-group submit-group">
<a href="{% url "control:user.settings.2fa" %}" class="btn btn-default btn-cancel"> <a href="{% url "control:user.settings.2fa" %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %} {% trans "Cancel" %}
</a> </a>
<button type="submit" class="btn btn-danger btn-save"> <button type="submit" class="btn btn-primary btn-save">
{% trans "Enable" %} {% trans "Enable" %}
</button> </button>
</div> </div>
@@ -6,6 +6,7 @@
<h1>{% trans "Leave teams that require two-factor authentication" %}</h1> <h1>{% trans "Leave teams that require two-factor authentication" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<p> <p>
<strong>{% trans "Do you really want to leave the following teams?" %}</strong> <strong>{% trans "Do you really want to leave the following teams?" %}</strong>
</p> </p>
@@ -1,5 +1,6 @@
{% extends "pretixcontrol/base.html" %} {% extends "pretixcontrol/base.html" %}
{% load i18n %} {% load i18n %}
{% load icon %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% block title %}{% trans "Two-factor authentication" %}{% endblock %} {% block title %}{% trans "Two-factor authentication" %}{% endblock %}
{% block content %} {% block content %}
@@ -120,7 +121,7 @@
Delete Delete
</a> </a>
{% if d.devicetype == "totp" %} {% if d.devicetype == "totp" %}
<span class="fa fa-mobile"></span> <span class="fa fa-mobile fa-lg"></span>
{% elif d.devicetype == "webauthn" %} {% elif d.devicetype == "webauthn" %}
<span class="fa fa-usb"></span> <span class="fa fa-usb"></span>
{% elif d.devicetype == "u2f" %} {% elif d.devicetype == "u2f" %}
@@ -152,19 +153,30 @@
</p> </p>
{% if static_tokens_device %} {% if static_tokens_device %}
<p> <p>
{% icon "info-circle" %}
{% blocktrans trimmed with generation_date_time=static_tokens_device.created_at %} {% blocktrans trimmed with generation_date_time=static_tokens_device.created_at %}
You generated your emergency tokens on {{ generation_date_time }}. You generated your emergency tokens on {{ generation_date_time }}.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
{% else %} <a href="{% url "control:user.settings.2fa.regenemergency" %}" class="btn btn-default">
<span class="fa fa-refresh"></span>
{% trans "Generate new emergency tokens" %}
</a>
{% elif user.require_2fa %}
<p> <p>
{% trans "You don't have any emergency tokens yet." %} {% icon "warning" %}
<strong>{% trans "You don't have any emergency tokens yet." %}</strong>
</p>
<a href="{% url "control:user.settings.2fa.regenemergency" %}" class="btn btn-default">
<span class="fa fa-refresh"></span>
{% trans "Generate emergency tokens" %}
</a>
{% else %}
<p class="help-block">
{% icon "info-circle" %}
{% trans "Emergency tokens will be generated when you enable two-factor authentication." %}
</p> </p>
{% endif %} {% endif %}
<a href="{% url "control:user.settings.2fa.regenemergency" %}" class="btn btn-default">
<span class="fa fa-refresh"></span>
{% trans "Generate new emergency tokens" %}
</a>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@@ -6,6 +6,7 @@
<h1>{% trans "Regenerate emergency codes" %}</h1> <h1>{% trans "Regenerate emergency codes" %}</h1>
<form action="" method="post" class="form-horizontal"> <form action="" method="post" class="form-horizontal">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
<p> <p>
{% trans "Do you really want to regenerate your emergency codes?" %} {% trans "Do you really want to regenerate your emergency codes?" %}
</p> </p>
@@ -8,6 +8,7 @@
{% trans "Change login email address" %} {% trans "Change login email address" %}
</h1> </h1>
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
<p class="text-muted"> <p class="text-muted">
{% trans "This changes the email address used to login to your account, as well as where we send email notifications." %} {% trans "This changes the email address used to login to your account, as well as where we send email notifications." %}
@@ -9,6 +9,7 @@
</h1> </h1>
<br> <br>
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="flow_token" value="{{ flow_token }}">
{% bootstrap_form_errors form %} {% bootstrap_form_errors form %}
{% bootstrap_field form.email %} {% bootstrap_field form.email %}
{% bootstrap_field form.old_pw %} {% bootstrap_field form.old_pw %}
-2
View File
@@ -349,8 +349,6 @@ urlpatterns = [
name='event.items.questions.edit'), name='event.items.questions.edit'),
re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'), re_path(r'^questions/add$', item.QuestionCreate.as_view(), name='event.items.questions.add'),
re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'), re_path(r'^quotas/$', item.QuotaList.as_view(), name='event.items.quotas'),
re_path(r'^quotas/bulk_action$', item.QuotaBulkAction.as_view(), name='event.items.quotas.bulkaction'),
re_path(r'^quotas/bulk_edit$', item.QuotaBulkUpdateView.as_view(), name='event.items.quotas.bulkedit'),
re_path(r'^quotas/(?P<quota>\d+)/$', item.QuotaView.as_view(), name='event.items.quotas.show'), re_path(r'^quotas/(?P<quota>\d+)/$', item.QuotaView.as_view(), name='event.items.quotas.show'),
re_path(r'^quotas/select$', typeahead.quotas_select2, name='event.items.quotas.select2'), re_path(r'^quotas/select$', typeahead.quotas_select2, name='event.items.quotas.select2'),
re_path(r'^quotas/(?P<quota>\d+)/change$', item.QuotaUpdate.as_view(), name='event.items.quotas.edit'), re_path(r'^quotas/(?P<quota>\d+)/change$', item.QuotaUpdate.as_view(), name='event.items.quotas.edit'),
+1 -1
View File
@@ -641,7 +641,7 @@ def user_index(request):
ctx = { ctx = {
'widgets': rearrange(widgets), 'widgets': rearrange(widgets),
'can_create_event': request.user.teams.with_organizer_permission("organizer.events:create").exists() or request.user.is_staff, 'can_create_event': request.user.teams.with_organizer_permission("organizer.events:create").exists(),
'upcoming': widgets_for_event_qs( 'upcoming': widgets_for_event_qs(
request, request,
annotated_event_query(request, lazy=True).filter( annotated_event_query(request, lazy=True).filter(
+13 -16
View File
@@ -234,21 +234,13 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired
self.request.event.log_action('pretix.event.footerlinks.changed', user=self.request.user, data={ self.request.event.log_action('pretix.event.footerlinks.changed', user=self.request.user, data={
'data': self.footer_links_formset.cleaned_data 'data': self.footer_links_formset.cleaned_data
}) })
if form.has_changed():
change_data = { self.request.event.log_action('pretix.event.changed', user=self.request.user, data={
k: (form.cleaned_data.get(k).name k: (form.cleaned_data.get(k).name
if isinstance(form.cleaned_data.get(k), File) if isinstance(form.cleaned_data.get(k), File)
else form.cleaned_data.get(k)) else form.cleaned_data.get(k))
for k in form.changed_data for k in form.changed_data
} })
meta_changed = {}
for f in self.meta_forms:
if f.has_changed():
meta_changed[f.property.name] = f.cleaned_data["value"]
if meta_changed:
change_data['meta_data'] = meta_changed
if change_data:
self.request.event.log_action('pretix.event.changed', user=self.request.user, data=change_data)
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk}) tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk})
messages.success(self.request, _('Your changes have been saved.')) messages.success(self.request, _('Your changes have been saved.'))
@@ -771,7 +763,12 @@ class InvoicePreview(EventPermissionRequiredMixin, View):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
fname, ftype, fcontent = build_preview_invoice_pdf(request.event) fname, ftype, fcontent = build_preview_invoice_pdf(request.event)
resp = HttpResponse(fcontent, content_type=ftype) resp = HttpResponse(fcontent, content_type=ftype)
resp['Content-Disposition'] = 'inline; filename="{}"'.format(fname) 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)
return resp return resp
@@ -300,4 +300,5 @@ class SysReportView(AdministratorPermissionRequiredMixin, TemplateView):
resp = HttpResponse(data) resp = HttpResponse(data)
resp['Content-Type'] = mime resp['Content-Type'] = mime
resp['Content-Disposition'] = 'inline; filename="{}"'.format(name) resp['Content-Disposition'] = 'inline; filename="{}"'.format(name)
resp._csp_ignore = True
return resp return resp
+34 -201
View File
@@ -41,22 +41,21 @@ from json.decoder import JSONDecodeError
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.files import File from django.core.files import File
from django.db import models, transaction from django.db import transaction
from django.db.models import ( from django.db.models import (
Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q, Subquery, Value, Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q,
) )
from django.db.models.functions import Cast, Concat
from django.forms.models import inlineformset_factory from django.forms.models import inlineformset_factory
from django.http import ( from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
) )
from django.shortcuts import redirect, render from django.shortcuts import redirect
from django.urls import resolve, reverse from django.urls import resolve, reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from django.views.generic import FormView, ListView, View from django.views.generic import ListView
from django.views.generic.detail import DetailView, SingleObjectMixin from django.views.generic.detail import DetailView, SingleObjectMixin
from django_countries.fields import Country from django_countries.fields import Country
@@ -66,7 +65,7 @@ from pretix.api.serializers.item import (
) )
from pretix.base.forms import I18nFormSet from pretix.base.forms import I18nFormSet
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, LogEntry, CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota, OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
SeatCategoryMapping, Voucher, SeatCategoryMapping, Voucher,
) )
@@ -75,15 +74,12 @@ from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tickets import invalidate_cache from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability from pretix.base.signals import quota_availability
from pretix.control.forms.filter import ( from pretix.control.forms.filter import QuestionAnswerFilterForm
QuestionAnswerFilterForm, QuotaFilterForm,
)
from pretix.control.forms.item import ( from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm, CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm, ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm, ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaBulkEditForm, ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm,
QuotaForm,
) )
from pretix.control.permissions import ( from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required, EventPermissionRequiredMixin, event_permission_required,
@@ -91,7 +87,6 @@ from pretix.control.permissions import (
from pretix.control.signals import item_forms, item_formsets from pretix.control.signals import item_forms, item_formsets
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
from ...helpers import GroupConcat
from ...helpers.compat import CompatDeleteView from ...helpers.compat import CompatDeleteView
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
@@ -836,38 +831,13 @@ class QuestionCreate(EventPermissionRequiredMixin, QuestionMixin, CreateView):
return ret return ret
class QuotaQueryMixin: class QuotaList(PaginationMixin, ListView):
@cached_property
def request_data(self):
if self.request.method == "POST":
return self.request.POST
return self.request.GET
def get_queryset(self):
qs = self.request.event.quotas
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
if 'quota' in self.request_data and '__ALL' not in self.request_data:
qs = qs.filter(
id__in=self.request_data.getlist('quota')
)
return qs
@cached_property
def filter_form(self):
return QuotaFilterForm(data=self.request_data, prefix='filter', event=self.request.event)
class QuotaList(PaginationMixin, QuotaQueryMixin, ListView):
model = Quota model = Quota
context_object_name = 'quotas' context_object_name = 'quotas'
template_name = 'pretixcontrol/items/quotas.html' template_name = 'pretixcontrol/items/quotas.html'
def get_queryset(self): def get_queryset(self):
return super().get_queryset().prefetch_related( qs = self.request.event.quotas.prefetch_related(
Prefetch( Prefetch(
"items", "items",
queryset=Item.objects.annotate( queryset=Item.objects.annotate(
@@ -882,10 +852,28 @@ class QuotaList(PaginationMixin, QuotaQueryMixin, ListView):
queryset=self.request.event.subevents.all() queryset=self.request.event.subevents.all()
) )
) )
if self.request.GET.get("subevent", "") != "":
s = self.request.GET.get("subevent", "")
qs = qs.filter(subevent_id=s)
valid_orders = {
'-date': ('-subevent__date_from', 'name', 'pk'),
'date': ('subevent__date_from', '-name', '-pk'),
'size': ('size', 'name', 'pk'),
'-size': ('-size', '-name', '-pk'),
'name': ('name', 'pk'),
'-name': ('-name', '-pk'),
}
if self.request.GET.get("ordering", "-date") in valid_orders:
qs = qs.order_by(*valid_orders[self.request.GET.get("ordering", "-date")])
else:
qs = qs.order_by('name', 'subevent__date_from', 'pk')
return qs
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data() ctx = super().get_context_data()
ctx['filter_form'] = self.filter_form
qa = QuotaAvailability() qa = QuotaAvailability()
qa.queue(*ctx['quotas']) qa.queue(*ctx['quotas'])
@@ -896,165 +884,6 @@ class QuotaList(PaginationMixin, QuotaQueryMixin, ListView):
return ctx return ctx
class QuotaBulkAction(QuotaQueryMixin, EventPermissionRequiredMixin, View):
permission = 'event.items:write'
@transaction.atomic
def post(self, request, *args, **kwargs):
if request.POST.get('action') == 'delete':
return render(request, 'pretixcontrol/items/quota_delete_bulk.html', {
'allowed': self.get_queryset().select_related("subevent"),
})
elif request.POST.get('action') == 'delete_confirm':
log_entries = []
to_delete = []
for obj in self.get_queryset():
log_entries.append(obj.log_action('pretix.event.quota.deleted', user=self.request.user, save=False))
to_delete.append(obj.pk)
if to_delete:
LogEntry.bulk_create_and_postprocess(log_entries)
Quota.objects.filter(pk__in=to_delete).delete()
messages.success(request, _('The selected quotas have been deleted or disabled.'))
return redirect(self.get_success_url())
def get_success_url(self) -> str:
return reverse('control:event.items.quotas', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
class QuotaBulkUpdateView(QuotaQueryMixin, EventPermissionRequiredMixin, FormView):
template_name = 'pretixcontrol/items/quota_bulk_edit.html'
permission = 'event.items:write'
context_object_name = 'quota'
form_class = QuotaBulkEditForm
def get_queryset(self):
return super().get_queryset().prefetch_related(None).order_by()
def get(self, request, *args, **kwargs):
return HttpResponse(status=405)
@cached_property
def is_submitted(self):
# Usually, django considers a form "bound" / "submitted" on every POST request. However, this view is always
# called with POST method, even if just to pass the selection of objects to work on, so we want to modify
# that behaviour
return '_bulk' in self.request.POST
def get_form_kwargs(self):
initial = {}
mixed_values = set()
qs = self.get_queryset().annotate(
items_list=Subquery(
Quota.items.through.objects.filter(
quota_id=OuterRef('pk'),
item__variations__isnull=True,
).order_by().values('quota_id').annotate(
g=GroupConcat('item_id', separator=',', ordered=True)
).values('g')
),
vars_list=Subquery(
Quota.variations.through.objects.filter(
quota_id=OuterRef('pk')
).order_by().values('quota_id').annotate(
g=GroupConcat(
Concat(
Cast(F('itemvariation__item_id'), output_field=models.TextField()),
Value('-', output_field=models.TextField()),
Cast(F('itemvariation_id'), output_field=models.TextField()),
),
separator=',',
ordered=True
)
).values('g')
),
)
fields = {
'name': 'name',
'size': 'size',
'subevent': 'subevent',
'close_when_sold_out': 'close_when_sold_out',
'release_after_exit': 'release_after_exit',
'ignore_for_event_availability': 'ignore_for_event_availability',
}
for k, f in fields.items():
existing_values = list(qs.order_by(f).values(f).annotate(c=Count('*')))
if len(existing_values) == 1:
initial[k] = existing_values[0][f]
elif len(existing_values) > 1:
mixed_values.add(k)
initial[k] = None
item_values = list(qs.order_by("items_list").values("items_list").annotate(c=Count('*')))
var_values = list(qs.order_by("vars_list").values("vars_list").annotate(c=Count('*')))
if len(item_values) > 1 or len(var_values) > 1:
mixed_values.add("itemvars")
else:
initial["itemvars"] = [iv for iv in (item_values[0]["items_list"] or "").split(",") + (var_values[0]["vars_list"] or "").split(",") if iv]
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
kwargs['prefix'] = 'bulkedit'
kwargs['initial'] = initial
kwargs['queryset'] = self.get_queryset()
kwargs['mixed_values'] = mixed_values
if not self.is_submitted:
kwargs['data'] = None
kwargs['files'] = None
return kwargs
def get_success_url(self):
return reverse('control:event.items.quotas', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
@transaction.atomic()
def form_valid(self, form):
log_entries = []
# Main form
form.save()
data = {
k: v
for k, v in form.cleaned_data.items()
if k in form.changed_data
}
data['_raw_bulk_data'] = self.request.POST.dict()
for obj in self.get_queryset():
log_entries.append(
obj.log_action('pretix.event.quota.changed', data=data, user=self.request.user, save=False)
)
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['quotas'] = self.get_queryset()
ctx['bulk_selected'] = self.request.POST.getlist("_bulk")
return ctx
def post(self, request, *args, **kwargs):
form = self.get_form()
is_valid = (
self.is_submitted and
form.is_valid()
)
if is_valid:
return self.form_valid(form)
else:
if self.is_submitted:
messages.error(self.request, _('We could not save your changes. See below for details.'))
return self.form_invalid(form)
class QuotaCreate(EventPermissionRequiredMixin, CreateView): class QuotaCreate(EventPermissionRequiredMixin, CreateView):
model = Quota model = Quota
form_class = QuotaForm form_class = QuotaForm
@@ -1618,8 +1447,12 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
meta_changed = {} meta_changed = {}
for f in self.meta_forms: for f in self.meta_forms:
if f.has_changed(): meta_changed.update({
meta_changed[f.property.name] = f.cleaned_data["value"] k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.changed_data
})
if meta_changed: if meta_changed:
change_data['meta_data'] = meta_changed change_data['meta_data'] = meta_changed
+37 -25
View File
@@ -79,9 +79,9 @@ from pretix.base.email import get_email_context
from pretix.base.exporter import MultiSheetListExporter from pretix.base.exporter import MultiSheetListExporter
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedFile, CachedTicket, Checkin, Invoice, InvoiceAddress, Item, CachedCombinedTicket, CachedFile, CachedTicket, Checkin, Invoice,
ItemVariation, LogEntry, Order, QuestionAnswer, Quota, InvoiceAddress, Item, ItemVariation, LogEntry, Order, QuestionAnswer,
ScheduledEventExport, generate_secret, Quota, ScheduledEventExport, generate_secret,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund, CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund,
@@ -710,21 +710,34 @@ class OrderDownload(AsyncAction, OrderView):
resp = HttpResponseRedirect(value.file.file.read()) resp = HttpResponseRedirect(value.file.file.read())
return resp return resp
else: else:
return FileResponse( resp = FileResponse(value.file.file, content_type=value.type)
value.file.file, resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
filename='{}-{}-{}-{}{}'.format( self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.request.event.slug.upper(), self.order.code, self.order_position.positionid, self.output.identifier, value.extension
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 resp
else: else:
return redirect(self.get_self_url()) return redirect(self.get_self_url())
def get_last_ct(self): def get_last_ct(self):
ct = CachedTicket.objects.filter( if 'position' in self.kwargs:
order_position=self.order_position, provider=self.output.identifier, file__isnull=False ct = CachedTicket.objects.filter(
).last() order_position=self.order_position, provider=self.output.identifier, file__isnull=False
).last()
else:
ct = CachedCombinedTicket.objects.filter(
order=self.order, provider=self.output.identifier, file__isnull=False
).last()
if not ct or not ct.file: if not ct or not ct.file:
return None return None
return ct return ct
@@ -1818,15 +1831,15 @@ class InvoiceDownload(EventPermissionRequiredMixin, View):
return redirect(self.get_order_url()) return redirect(self.get_order_url())
try: try:
return FileResponse( resp = FileResponse(self.invoice.file.file, content_type='application/pdf')
self.invoice.file.file,
filename='{}.pdf'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", self.invoice.number)),
content_type='application/pdf'
)
except FileNotFoundError: except FileNotFoundError:
invoice_pdf_task.apply(args=(self.invoice.pk,)) invoice_pdf_task.apply(args=(self.invoice.pk,))
return self.get(request, *args, **kwargs) 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): class OrderExtend(OrderView):
permission = 'event.orders:write' permission = 'event.orders:write'
@@ -2046,13 +2059,12 @@ class OrderChange(OrderView):
else: else:
variation = None variation = None
try: try:
for i in range(f.cleaned_data.get("count", 1)): ocm.add_position(item, variation,
ocm.add_position(item, variation, f.cleaned_data['price'],
f.cleaned_data['price'], f.cleaned_data.get('addon_to'),
f.cleaned_data.get('addon_to'), f.cleaned_data.get('subevent'),
f.cleaned_data.get('subevent'), f.cleaned_data.get('seat'),
f.cleaned_data.get('seat'), f.cleaned_data.get('used_membership'))
f.cleaned_data.get('used_membership'))
except OrderError as e: except OrderError as e:
f.custom_error = str(e) f.custom_error = str(e)
return False return False
+21 -17
View File
@@ -102,7 +102,7 @@ from pretix.base.models.organizer import (
from pretix.base.payment import PaymentException from pretix.base.payment import PaymentException
from pretix.base.plugins import ( from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER, plugin_is_available, PLUGIN_LEVEL_ORGANIZER,
) )
from pretix.base.services.export import ( from pretix.base.services.export import (
init_organizer_exporters, multiexport, scheduled_organizer_export, init_organizer_exporters, multiexport, scheduled_organizer_export,
@@ -597,13 +597,6 @@ class OrganizerCreate(CreateView):
}) })
def available_plugins(organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, TemplateView, SingleObjectMixin): class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, TemplateView, SingleObjectMixin):
model = Organizer model = Organizer
context_object_name = 'organizer' context_object_name = 'organizer'
@@ -613,6 +606,12 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
def get_object(self, queryset=None) -> Organizer: def get_object(self, queryset=None) -> Organizer:
return self.request.organizer return self.request.organizer
def available_plugins(self, organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
def prepare_links(self, pluginmeta, key): def prepare_links(self, pluginmeta, key):
links = getattr(pluginmeta, key, []) links = getattr(pluginmeta, key, [])
try: try:
@@ -638,7 +637,7 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
from pretix.base.plugins import CATEGORY_LABELS, CATEGORY_ORDER from pretix.base.plugins import CATEGORY_LABELS, CATEGORY_ORDER
context = super().get_context_data(*args, **kwargs) context = super().get_context_data(*args, **kwargs)
plugins = list(available_plugins(self.object)) plugins = list(self.available_plugins(self.object))
active_counter = Counter() active_counter = Counter()
events_total = 0 events_total = 0
@@ -686,7 +685,7 @@ class OrganizerPlugins(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
self.object = self.get_object() self.object = self.get_object()
plugins_available = { plugins_available = {
p.module: p for p in available_plugins(self.object) p.module: p for p in self.available_plugins(self.object)
} }
choose_events_next = False choose_events_next = False
with transaction.atomic(): with transaction.atomic():
@@ -787,6 +786,12 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
} }
return kwargs return kwargs
def available_plugins(self, organizer):
from pretix.base.plugins import get_all_plugins
return (p for p in get_all_plugins(organizer=organizer) if not p.name.startswith('.')
and getattr(p, 'visible', True))
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
return super().get_context_data( return super().get_context_data(
plugin=self.plugin, plugin=self.plugin,
@@ -794,10 +799,12 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
) )
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
try: plugins_available = {
self.plugin = next(p for p in available_plugins(self.request.organizer) if p.module == kwargs["plugin"]) p.module: p for p in self.available_plugins(self.request.organizer)
except StopIteration: }
if kwargs["plugin"] not in plugins_available:
raise Http404(_("Unknown plugin.")) raise Http404(_("Unknown plugin."))
self.plugin = plugins_available[kwargs["plugin"]]
level = getattr(self.plugin, "level", PLUGIN_LEVEL_EVENT) level = getattr(self.plugin, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_ORGANIZER: if level == PLUGIN_LEVEL_ORGANIZER:
raise Http404(_("This plugin can only be enabled for the entire organizer account.")) raise Http404(_("This plugin can only be enabled for the entire organizer account."))
@@ -828,9 +835,6 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
logentries_to_save = [] logentries_to_save = []
for e in self.request.organizer.events.filter(pk__in=events_to_enable): for e in self.request.organizer.events.filter(pk__in=events_to_enable):
if not plugin_is_available(self.plugin, organizer=self.request.organizer, event=e):
messages.warning(self.request, _("This plugin cannot be activated for event {}.").format(e.name))
continue
logentries_to_save.append( logentries_to_save.append(
e.log_action('pretix.event.plugins.enabled', user=self.request.user, data={'plugin': self.plugin.module}, save=False) e.log_action('pretix.event.plugins.enabled', user=self.request.user, data={'plugin': self.plugin.module}, save=False)
) )
@@ -1318,7 +1322,7 @@ class DeviceUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixi
def form_valid(self, form): def form_valid(self, form):
if form.has_changed(): if form.has_changed():
self.object.log_action('pretix.device.changed', user=self.request.user, data={ self.object.log_action('pretix.device.changed', user=self.request.user, data={
k: form.cleaned_data[k] if k != 'limit_events' else [e.id for e in form.cleaned_data[k]] k: getattr(self.object, k) if k != 'limit_events' else [e.id for e in getattr(self.object, k).all()]
for k in form.changed_data for k in form.changed_data
}) })
+8 -2
View File
@@ -263,7 +263,12 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
resp = HttpResponse(data, content_type=mimet) resp = HttpResponse(data, content_type=mimet)
ftype = fname.split(".")[-1] ftype = fname.split(".")[-1]
resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(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="ticket-preview.{}"'.format(ftype)
resp._csp_ignore = True
else:
resp['Content-Disposition'] = 'attachment; filename="ticket-preview.{}"'.format(ftype)
return resp return resp
elif "data" in request.POST: elif "data" in request.POST:
if cf: if cf:
@@ -304,5 +309,6 @@ class FontsCSSView(TemplateView):
class PdfView(TemplateView): class PdfView(TemplateView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf") cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf")
resp = FileResponse(cf.file, filename=cf.filename, content_type='application/pdf') resp = FileResponse(cf.file, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
return resp return resp
+12 -60
View File
@@ -540,31 +540,20 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi
# TODO: LogEntry? # TODO: LogEntry?
messages.success(self.request, _('Your changes have been saved.')) messages.success(self.request, _('Your changes have been saved.'))
if form.has_changed() or any(f.has_changed() for f in self.plugin_forms):
change_data = { data = {
k: (form.cleaned_data.get(k).name k: form.cleaned_data.get(k) for k in form.changed_data
if isinstance(form.cleaned_data.get(k), File) }
else form.cleaned_data.get(k)) for f in self.plugin_forms:
for k in form.changed_data data.update({
} k: (f.cleaned_data.get(k).name
meta_changed = {} if isinstance(f.cleaned_data.get(k), File)
for f in self.meta_forms: else f.cleaned_data.get(k))
if f.has_changed(): for k in f.changed_data
meta_changed[f.property.name] = f.cleaned_data["value"] })
if meta_changed:
change_data['meta_data'] = meta_changed
for f in self.plugin_forms:
change_data.update({
k: (f.cleaned_data.get(k).name
if isinstance(f.cleaned_data.get(k), File)
else f.cleaned_data.get(k))
for k in f.changed_data
})
if change_data:
self.object.log_action( self.object.log_action(
'pretix.subevent.changed', user=self.request.user, data=change_data 'pretix.subevent.changed', user=self.request.user, data=data
) )
for f in self.plugin_forms: for f in self.plugin_forms:
f.subevent = self.object f.subevent = self.object
f.save() f.save()
@@ -639,14 +628,6 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi
else f.cleaned_data.get(k)) else f.cleaned_data.get(k))
for k in f.cleaned_data for k in f.cleaned_data
}) })
meta_changed = {}
for f in self.meta_forms:
if f.has_changed():
meta_changed[f.property.name] = f.cleaned_data["value"]
if meta_changed:
data['meta_data'] = meta_changed
form.instance.log_action('pretix.subevent.added', data=dict(data), user=self.request.user) form.instance.log_action('pretix.subevent.added', data=dict(data), user=self.request.user)
self.save_formset(form.instance) self.save_formset(form.instance)
@@ -936,35 +917,6 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Asyn
if len(subevents) > 100_000: if len(subevents) > 100_000:
raise ValidationError(_('Please do not create more than 100.000 dates at once.')) 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): for i, se in enumerate(subevents):
se.save(clear_cache=False) se.save(clear_cache=False)
if i % 100 == 0: if i % 100 == 0:
+3 -3
View File
@@ -316,7 +316,7 @@ def nav_context_list(request):
page = 1 page = 1
qs_events = request.user.get_events_with_any_permission(request).filter( qs_events = request.user.get_events_with_any_permission(request).filter(
Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query) | Q(domain__domainname__iexact=query) Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query)
).annotate( ).annotate(
min_from=Min('subevents__date_from'), min_from=Min('subevents__date_from'),
max_from=Max('subevents__date_from'), max_from=Max('subevents__date_from'),
@@ -331,7 +331,7 @@ def nav_context_list(request):
else: else:
qs_orga = Organizer.objects.filter(pk__in=request.user.teams.values_list('organizer', flat=True)) qs_orga = Organizer.objects.filter(pk__in=request.user.teams.values_list('organizer', flat=True))
if query: if query:
qs_orga = qs_orga.filter(Q(name__icontains=query) | Q(slug__icontains=query) | Q(domains__domainname__iexact=query)) qs_orga = qs_orga.filter(Q(name__icontains=query) | Q(slug__icontains=query))
qs_orga = qs_orga.annotate( qs_orga = qs_orga.annotate(
n_events=Count("events") n_events=Count("events")
).order_by("-n_events") ).order_by("-n_events")
@@ -619,7 +619,7 @@ def checkinlist_select2(request, **kwargs):
qs = request.event.checkin_lists.select_related('subevent').filter( qs = request.event.checkin_lists.select_related('subevent').filter(
qf qf
).order_by('subevent__date_from', 'name', 'pk') ).order_by('name')
total = qs.count() total = qs.count()
pagesize = 20 pagesize = 20
+62 -36
View File
@@ -89,13 +89,31 @@ logger = logging.getLogger(__name__)
class RecentAuthenticationRequiredMixin: class RecentAuthenticationRequiredMixin:
max_time = 900 max_time = 900
max_form_time = 900
@method_decorator(never_cache) @method_decorator(never_cache)
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
tdelta = time.time() - request.session.get('pretix_auth_login_time', 0) auth_is_recent = time.time() - request.session.get('pretix_auth_login_time', 0) < self.max_time
if tdelta > self.max_time: allowed_by_token = (
request.session.pop('pretix_reauthed_flow_token', None) == request.POST.get('flow_token', '')
and request.session.pop('pretix_reauthed_flow_allowed_url', None) == request.get_full_path()
and time.time() - request.session.pop('pretix_reauthed_flow_start_time', 0) < self.max_form_time
)
if auth_is_recent or allowed_by_token:
return super().dispatch(request, *args, **kwargs)
else:
return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path())) return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
return super().dispatch(request, *args, **kwargs)
def get_flow_token(self):
self.request.session['pretix_reauthed_flow_allowed_url'] = self.request.get_full_path()
self.request.session['pretix_reauthed_flow_token'] = get_random_string(22)
self.request.session['pretix_reauthed_flow_start_time'] = time.time()
return self.request.session['pretix_reauthed_flow_token']
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['flow_token'] = self.get_flow_token()
return ctx
class ReauthView(TemplateView): class ReauthView(TemplateView):
@@ -283,6 +301,7 @@ class UserHistoryView(ListView):
class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView): class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
max_time = 7200
template_name = 'pretixcontrol/user/2fa_main.html' template_name = 'pretixcontrol/user/2fa_main.html'
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@@ -465,25 +484,15 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
notices = [ notices = [
_('A new two-factor authentication device has been added to your account.') _('A new two-factor authentication device has been added to your account.')
] ]
activate = request.POST.get('activate', '')
if activate == 'on' and not self.request.user.require_2fa:
self.request.user.require_2fa = True
self.request.user.save()
self.request.user.log_action('pretix.user.settings.2fa.enabled', user=self.request.user)
notices.append(
_('Two-factor authentication has been enabled.')
)
self.request.user.send_security_notice(notices) self.request.user.send_security_notice(notices)
self.request.user.update_session_token() self.request.user.update_session_token()
update_session_auth_hash(self.request, self.request.user) update_session_auth_hash(self.request, self.request.user)
note = '' messages.success(request, str(_('The device has been verified and can now be used.')))
if not self.request.user.require_2fa: if self.request.user.require_2fa:
note = ' ' + str(_('Please note that you still need to enable two-factor authentication for your ' return redirect(reverse('control:user.settings.2fa'))
'account using the buttons below to make a second factor required for logging ' else:
'into your account.')) return redirect(reverse('control:user.settings.2fa.enable'))
messages.success(request, str(_('The device has been verified and can now be used.')) + note)
return redirect(reverse('control:user.settings.2fa'))
except Exception: except Exception:
messages.error(request, _('The registration could not be completed. Please try again.')) messages.error(request, _('The registration could not be completed. Please try again.'))
logger.exception('WebAuthn registration failed') logger.exception('WebAuthn registration failed')
@@ -494,6 +503,7 @@ class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, Templa
class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateView): class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_confirm_totp.html' template_name = 'pretixcontrol/user/2fa_confirm_totp.html'
max_form_time = 7200 # this should have effectively no timeout, as the user might need to download the 2fa app first
@cached_property @cached_property
def device(self): def device(self):
@@ -514,7 +524,6 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
token = request.POST.get('token', '') token = request.POST.get('token', '')
activate = request.POST.get('activate', '')
if self.device.verify_token(token): if self.device.verify_token(token):
self.device.confirmed = True self.device.confirmed = True
self.device.save() self.device.save()
@@ -526,24 +535,15 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
notices = [ notices = [
_('A new two-factor authentication device has been added to your account.') _('A new two-factor authentication device has been added to your account.')
] ]
if activate == 'on' and not self.request.user.require_2fa:
self.request.user.require_2fa = True
self.request.user.save()
self.request.user.log_action('pretix.user.settings.2fa.enabled', user=self.request.user)
notices.append(
_('Two-factor authentication has been enabled.')
)
self.request.user.send_security_notice(notices) self.request.user.send_security_notice(notices)
self.request.user.update_session_token() self.request.user.update_session_token()
update_session_auth_hash(self.request, self.request.user) update_session_auth_hash(self.request, self.request.user)
note = '' messages.success(request, str(_('The device has been verified and can now be used.')))
if not self.request.user.require_2fa: if self.request.user.require_2fa:
note = ' ' + str(_('Please note that you still need to enable two-factor authentication for your ' return redirect(reverse('control:user.settings.2fa'))
'account using the buttons below to make a second factor required for logging ' else:
'into your account.')) return redirect(reverse('control:user.settings.2fa.enable'))
messages.success(request, str(_('The device has been verified and can now be used.')) + note)
return redirect(reverse('control:user.settings.2fa'))
else: else:
messages.error(request, _('The code you entered was not valid. If this problem persists, please check ' messages.error(request, _('The code you entered was not valid. If this problem persists, please check '
'that the date and time of your phone are configured correctly.')) 'that the date and time of your phone are configured correctly.'))
@@ -576,6 +576,7 @@ class User2FALeaveTeamsView(RecentAuthenticationRequiredMixin, TemplateView):
class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView): class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
template_name = 'pretixcontrol/user/2fa_enable.html' template_name = 'pretixcontrol/user/2fa_enable.html'
max_form_time = 7200 # this should have effectively no timeout, as the user might take some time to print out their emergency codes, and they would become invalid in case of a timeout
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES): if not any(dt.objects.filter(user=self.request.user, confirmed=True) for dt in REAL_DEVICE_TYPES):
@@ -584,14 +585,39 @@ class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
return redirect(reverse('control:user.settings.2fa')) return redirect(reverse('control:user.settings.2fa'))
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
new_tokens = None
try:
static_tokens_device = StaticDevice.objects.get(user=self.request.user, name='emergency')
except StaticDevice.MultipleObjectsReturned:
static_tokens_device = StaticDevice.objects.filter(
user=self.request.user, name='emergency'
).first()
except StaticDevice.DoesNotExist:
static_tokens_device = None
new_tokens = [get_random_string(length=12, allowed_chars='1234567890') for _ in range(10)]
request.session['pretix_2fa_new_emergency_tokens'] = new_tokens
return super().get(request, *args, new_emergency_tokens=new_tokens, static_tokens_device=static_tokens_device, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
notices = [
_('Two-factor authentication has been enabled.')
]
if 'pretix_2fa_new_emergency_tokens' in request.session:
d = StaticDevice.objects.create(user=self.request.user, name='emergency')
for code in request.session['pretix_2fa_new_emergency_tokens']:
d.token_set.create(token=code)
self.request.user.log_action('pretix.user.settings.2fa.regenemergency', user=self.request.user)
notices += [
_('Your two-factor emergency codes have been regenerated.')
]
del request.session['pretix_2fa_new_emergency_tokens']
self.request.user.require_2fa = True self.request.user.require_2fa = True
self.request.user.save() self.request.user.save()
self.request.user.log_action('pretix.user.settings.2fa.enabled', user=self.request.user) self.request.user.log_action('pretix.user.settings.2fa.enabled', user=self.request.user)
messages.success(request, _('Two-factor authentication is now enabled for your account.')) messages.success(request, _('Two-factor authentication is now enabled for your account.'))
self.request.user.send_security_notice([ self.request.user.send_security_notice(notices)
_('Two-factor authentication has been enabled.')
])
self.request.user.update_session_token() self.request.user.update_session_token()
update_session_auth_hash(self.request, self.request.user) update_session_auth_hash(self.request, self.request.user)
return redirect(reverse('control:user.settings.2fa')) return redirect(reverse('control:user.settings.2fa'))
+1 -6
View File
@@ -117,17 +117,12 @@ class GroupConcat(Aggregate):
template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s' ORDER BY %(field)s::text ASC)" template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s' ORDER BY %(field)s::text ASC)"
else: else:
template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s')" template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s')"
return super().as_sql(
template, params = super().as_sql(
compiler, connection, compiler, connection,
function='string_agg', function='string_agg',
template=template, template=template,
**extra_context, **extra_context,
) )
if self.ordered:
# ordered statement requires field parameters twice
params = params + params
return template, params
class ReplicaRouter: class ReplicaRouter:
-132
View File
@@ -19,26 +19,12 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
import ipaddress
import socket
import sys
import types import types
from datetime import datetime from datetime import datetime
from http import cookies from http import cookies
from django.conf import settings
from PIL import Image from PIL import Image
from requests.adapters import HTTPAdapter 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(): def monkeypatch_vobject_performance():
@@ -103,123 +89,6 @@ def monkeypatch_requests_timeout():
HTTPAdapter.send = httpadapter_send 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(): def monkeypatch_cookie_morsel():
# See https://code.djangoproject.com/ticket/34613 # See https://code.djangoproject.com/ticket/34613
cookies.Morsel._flags.add("partitioned") cookies.Morsel._flags.add("partitioned")
@@ -230,5 +99,4 @@ def monkeypatch_all_at_ready():
monkeypatch_vobject_performance() monkeypatch_vobject_performance()
monkeypatch_pillow_safer() monkeypatch_pillow_safer()
monkeypatch_requests_timeout() monkeypatch_requests_timeout()
monkeypatch_urllib3_ssrf_protection()
monkeypatch_cookie_morsel() monkeypatch_cookie_morsel()
+4 -10
View File
@@ -25,7 +25,7 @@ import time
from django.conf import settings from django.conf import settings
from django.contrib.auth import login as auth_login from django.contrib.auth import login as auth_login
from django.contrib.gis import geoip2 from django.contrib.gis.geoip2 import GeoIP2
from django.core.cache import cache from django.core.cache import cache
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -63,20 +63,14 @@ def get_user_agent_hash(request):
_geoip = None _geoip = None
def get_geoip() -> geoip2.GeoIP2: def _get_country(request):
# See https://code.djangoproject.com/ticket/36988#ticket
global _geoip global _geoip
geoip2.SUPPORTED_DATABASE_TYPES.add("Geoacumen-Country")
if not _geoip: if not _geoip:
_geoip = geoip2.GeoIP2() _geoip = GeoIP2()
return _geoip
def _get_country(request):
try: try:
res = get_geoip().country(get_client_ip(request)) res = _geoip.country(get_client_ip(request))
except AddressNotFoundError: except AddressNotFoundError:
return None return None
return res['country_code'] return res['country_code']
+5 -5
View File
@@ -173,7 +173,6 @@ def create_thumbnail(source, size, formats=None):
# filesystem path, this only works because _open() uses safe_join, which accepts absolute paths if they match the # filesystem path, this only works because _open() uses safe_join, which accepts absolute paths if they match the
# expected base dir. For NanoCDN Files, this works because source.name is set to the storage path. # expected base dir. For NanoCDN Files, this works because source.name is set to the storage path.
source_rb = default_storage.open(source_name, mode='rb') source_rb = default_storage.open(source_name, mode='rb')
source_ext = os.path.splitext(source_name)[1].lower()
image = Image.open(BytesIO(source_rb.read()), formats=formats or settings.PILLOW_FORMATS_QUESTIONS_IMAGE) image = Image.open(BytesIO(source_rb.read()), formats=formats or settings.PILLOW_FORMATS_QUESTIONS_IMAGE)
try: try:
@@ -184,14 +183,11 @@ def create_thumbnail(source, size, formats=None):
frames = [] frames = []
durations = [] durations = []
for f in ImageSequence.Iterator(image): for f in ImageSequence.Iterator(image):
if f.mode in ("P", "PA") and source_ext == '.png':
f = f.convert('RGBA')
if f.mode not in ("1", "L", "RGB", "RGBA"):
f = f.convert('RGB')
durations.append(f.info.get("duration", 1000)) durations.append(f.info.get("duration", 1000))
frames.append(resize_image(f, size)) frames.append(resize_image(f, size))
image_out = frames[0] image_out = frames[0]
save_kwargs = {} save_kwargs = {}
source_ext = os.path.splitext(source_name)[1].lower()
if source_ext == '.jpg' or source_ext == '.jpeg': if source_ext == '.jpg' or source_ext == '.jpeg':
# Yields better file sizes for photos # Yields better file sizes for photos
@@ -215,6 +211,10 @@ def create_thumbnail(source, size, formats=None):
checksum = hashlib.md5(image.tobytes()).hexdigest() checksum = hashlib.md5(image.tobytes()).hexdigest()
name = checksum + '.' + size.replace('^', 'c') + '.' + target_ext name = checksum + '.' + size.replace('^', 'c') + '.' + target_ext
buffer = BytesIO() buffer = BytesIO()
if image_out.mode == "P" and source_ext == '.png':
image_out = image_out.convert('RGBA')
if image_out.mode not in ("1", "L", "RGB", "RGBA"):
image_out = image_out.convert('RGB')
image_out.save(fp=buffer, format=target_ext.upper(), quality=quality, **save_kwargs) image_out.save(fp=buffer, format=target_ext.upper(), quality=quality, **save_kwargs)
imgfile = ContentFile(buffer.getvalue()) imgfile = ContentFile(buffer.getvalue())
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:04+0000\n" "POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -632,56 +632,56 @@ msgstr ""
msgid "Unknown error." msgid "Unknown error."
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:310 #: pretix/static/pretixcontrol/js/ui/main.js:309
msgid "Your color has great contrast and will provide excellent accessibility." msgid "Your color has great contrast and will provide excellent accessibility."
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:314 #: pretix/static/pretixcontrol/js/ui/main.js:313
msgid "" msgid ""
"Your color has decent contrast and is sufficient for minimum accessibility " "Your color has decent contrast and is sufficient for minimum accessibility "
"requirements." "requirements."
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:318 #: pretix/static/pretixcontrol/js/ui/main.js:317
msgid "" msgid ""
"Your color has insufficient contrast to white. Accessibility of your site " "Your color has insufficient contrast to white. Accessibility of your site "
"will be impacted." "will be impacted."
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:446 #: pretix/static/pretixcontrol/js/ui/main.js:445
#: pretix/static/pretixcontrol/js/ui/main.js:466 #: pretix/static/pretixcontrol/js/ui/main.js:465
msgid "Search query" msgid "Search query"
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:464 #: pretix/static/pretixcontrol/js/ui/main.js:463
msgid "All" msgid "All"
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:465 #: pretix/static/pretixcontrol/js/ui/main.js:464
msgid "None" msgid "None"
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:469 #: pretix/static/pretixcontrol/js/ui/main.js:468
msgid "Selected only" msgid "Selected only"
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:842 #: pretix/static/pretixcontrol/js/ui/main.js:841
msgid "Enter page number between 1 and %(max)s." msgid "Enter page number between 1 and %(max)s."
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:845 #: pretix/static/pretixcontrol/js/ui/main.js:844
msgid "Invalid page number." msgid "Invalid page number."
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:1003 #: pretix/static/pretixcontrol/js/ui/main.js:1002
msgid "Use a different name internally" msgid "Use a different name internally"
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:1043 #: pretix/static/pretixcontrol/js/ui/main.js:1042
msgid "Click to close" msgid "Click to close"
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:1124 #: pretix/static/pretixcontrol/js/ui/main.js:1123
msgid "You have unsaved changes!" msgid "You have unsaved changes!"
msgstr "" msgstr ""
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-28 09:04+0000\n" "POT-Creation-Date: 2026-03-17 14:06+0000\n"
"PO-Revision-Date: 2021-09-15 11:22+0000\n" "PO-Revision-Date: 2021-09-15 11:22+0000\n"
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n" "Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/" "Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -666,13 +666,13 @@ msgstr "توليد الرسائل …"
msgid "Unknown error." msgid "Unknown error."
msgstr "خطأ غير معروف." msgstr "خطأ غير معروف."
#: pretix/static/pretixcontrol/js/ui/main.js:310 #: pretix/static/pretixcontrol/js/ui/main.js:309
#, fuzzy #, fuzzy
#| msgid "Your color has great contrast and is very easy to read!" #| msgid "Your color has great contrast and is very easy to read!"
msgid "Your color has great contrast and will provide excellent accessibility." msgid "Your color has great contrast and will provide excellent accessibility."
msgstr "اللون يتمتع بتباين كبير وتسهل قراءته!" msgstr "اللون يتمتع بتباين كبير وتسهل قراءته!"
#: pretix/static/pretixcontrol/js/ui/main.js:314 #: pretix/static/pretixcontrol/js/ui/main.js:313
#, fuzzy #, fuzzy
#| msgid "Your color has decent contrast and is probably good-enough to read!" #| msgid "Your color has decent contrast and is probably good-enough to read!"
msgid "" msgid ""
@@ -680,46 +680,46 @@ msgid ""
"requirements." "requirements."
msgstr "اللون يحظى بتباين معقول ويمكن أن يكون مناسب للقراءة!" msgstr "اللون يحظى بتباين معقول ويمكن أن يكون مناسب للقراءة!"
#: pretix/static/pretixcontrol/js/ui/main.js:318 #: pretix/static/pretixcontrol/js/ui/main.js:317
msgid "" msgid ""
"Your color has insufficient contrast to white. Accessibility of your site " "Your color has insufficient contrast to white. Accessibility of your site "
"will be impacted." "will be impacted."
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:446 #: pretix/static/pretixcontrol/js/ui/main.js:445
#: pretix/static/pretixcontrol/js/ui/main.js:466 #: pretix/static/pretixcontrol/js/ui/main.js:465
msgid "Search query" msgid "Search query"
msgstr "البحث في الاستفسارات" msgstr "البحث في الاستفسارات"
#: pretix/static/pretixcontrol/js/ui/main.js:464 #: pretix/static/pretixcontrol/js/ui/main.js:463
msgid "All" msgid "All"
msgstr "الكل" msgstr "الكل"
#: pretix/static/pretixcontrol/js/ui/main.js:465 #: pretix/static/pretixcontrol/js/ui/main.js:464
msgid "None" msgid "None"
msgstr "لا شيء" msgstr "لا شيء"
#: pretix/static/pretixcontrol/js/ui/main.js:469 #: pretix/static/pretixcontrol/js/ui/main.js:468
msgid "Selected only" msgid "Selected only"
msgstr "المختارة فقط" msgstr "المختارة فقط"
#: pretix/static/pretixcontrol/js/ui/main.js:842 #: pretix/static/pretixcontrol/js/ui/main.js:841
msgid "Enter page number between 1 and %(max)s." msgid "Enter page number between 1 and %(max)s."
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:845 #: pretix/static/pretixcontrol/js/ui/main.js:844
msgid "Invalid page number." msgid "Invalid page number."
msgstr "" msgstr ""
#: pretix/static/pretixcontrol/js/ui/main.js:1003 #: pretix/static/pretixcontrol/js/ui/main.js:1002
msgid "Use a different name internally" msgid "Use a different name internally"
msgstr "قم باستخدم اسم مختلف داخليا" msgstr "قم باستخدم اسم مختلف داخليا"
#: pretix/static/pretixcontrol/js/ui/main.js:1043 #: pretix/static/pretixcontrol/js/ui/main.js:1042
msgid "Click to close" msgid "Click to close"
msgstr "اضغط لاغلاق الصفحة" msgstr "اضغط لاغلاق الصفحة"
#: pretix/static/pretixcontrol/js/ui/main.js:1124 #: pretix/static/pretixcontrol/js/ui/main.js:1123
msgid "You have unsaved changes!" msgid "You have unsaved changes!"
msgstr "لم تقم بحفظ التعديلات!" msgstr "لم تقم بحفظ التعديلات!"

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