forked from CGM_Public/pretix_original
Compare commits
110 Commits
ppv2_apm_w
...
fix-countr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8d06e78f | ||
|
|
3bde9d8b0e | ||
|
|
11956a8f4d | ||
|
|
d0c58713c4 | ||
|
|
46da0bda61 | ||
|
|
009e3a6d36 | ||
|
|
c01855270b | ||
|
|
2df1585b71 | ||
|
|
bf48ae567f | ||
|
|
5a72c72d18 | ||
|
|
ac02f3b417 | ||
|
|
58add74b3a | ||
|
|
0067c3537d | ||
|
|
64ae1d08a6 | ||
|
|
ca25c3c81e | ||
|
|
abbe9ec897 | ||
|
|
a7735d5d9e | ||
|
|
174c81a22b | ||
|
|
38c6294ede | ||
|
|
217ae90642 | ||
|
|
0c998ca884 | ||
|
|
b1691f867d | ||
|
|
72e451b27b | ||
|
|
8124ced6c1 | ||
|
|
a3139944f6 | ||
|
|
48493c517b | ||
|
|
535a29bf4b | ||
|
|
9d415f5179 | ||
|
|
990e9da21d | ||
|
|
4afb7a4976 | ||
|
|
22e5579ed1 | ||
|
|
79cd84e243 | ||
|
|
fad4b8846c | ||
|
|
0f4790afd8 | ||
|
|
2068a5ac29 | ||
|
|
440c97061c | ||
|
|
3b6d0c4341 | ||
|
|
06ac4b0250 | ||
|
|
a233b92f6f | ||
|
|
4ea4189e6d | ||
|
|
50838b9cea | ||
|
|
c68ee56d51 | ||
|
|
5c0587c30e | ||
|
|
f3f42a8a42 | ||
|
|
20d0a9a0ed | ||
|
|
cda8144ff0 | ||
|
|
43e8875c1e | ||
|
|
28c142b2ed | ||
|
|
46203fd8ba | ||
|
|
52e45c37df | ||
|
|
d1580dca2c | ||
|
|
cd9e672871 | ||
|
|
427f508627 | ||
|
|
887d06a485 | ||
|
|
fb49046ac1 | ||
|
|
ce826e50f7 | ||
|
|
d866c6954d | ||
|
|
40c76dda74 | ||
|
|
f532853021 | ||
|
|
8cb187502d | ||
|
|
156037f2cd | ||
|
|
134d63fb3f | ||
|
|
816002fda0 | ||
|
|
3939bbc11c | ||
|
|
95d1603cc7 | ||
|
|
ada3ada699 | ||
|
|
97eaeac4f2 | ||
|
|
d67f5c650c | ||
|
|
273c1ae0a6 | ||
|
|
a946c10ab4 | ||
|
|
2d8fba7d7c | ||
|
|
e4e0bd7ca0 | ||
|
|
3651c88289 | ||
|
|
c92ca40026 | ||
|
|
4d00efb549 | ||
|
|
7e60d13910 | ||
|
|
35800e21c7 | ||
|
|
99b4c5bd36 | ||
|
|
f121205dd1 | ||
|
|
1ac54cd209 | ||
|
|
4694719a53 | ||
|
|
9513b6e8d7 | ||
|
|
4fd7d406a0 | ||
|
|
47cb5b207a | ||
|
|
7d2cf68727 | ||
|
|
459cb47ca8 | ||
|
|
39705556cd | ||
|
|
9f794290dc | ||
|
|
b6221ab6d9 | ||
|
|
483518bce9 | ||
|
|
d9019ae735 | ||
|
|
721fd3b998 | ||
|
|
ad0d3f5469 | ||
|
|
40b44f9272 | ||
|
|
304d290f22 | ||
|
|
7592a8a575 | ||
|
|
4f33159f93 | ||
|
|
819ce6abf7 | ||
|
|
760dfd22b8 | ||
|
|
f9eaa193c9 | ||
|
|
c7720a2553 | ||
|
|
7754f5420c | ||
|
|
2c7ada6e86 | ||
|
|
3f31843fd1 | ||
|
|
952b9bd9b9 | ||
|
|
3619a6bcd0 | ||
|
|
3e2c12cdb0 | ||
|
|
a3ce3b9af3 | ||
|
|
b6461e9303 | ||
|
|
f7dfd51c2c |
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
@@ -26,12 +26,12 @@ jobs:
|
||||
matrix:
|
||||
python-version: ["3.11"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
|
||||
6
.github/workflows/docs.yml
vendored
6
.github/workflows/docs.yml
vendored
@@ -25,12 +25,12 @@ jobs:
|
||||
name: Spellcheck
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
|
||||
12
.github/workflows/strings.yml
vendored
12
.github/workflows/strings.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
name: Check gettext syntax
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
@@ -48,12 +48,12 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
name: Spellcheck
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
|
||||
16
.github/workflows/style.yml
vendored
16
.github/workflows/style.yml
vendored
@@ -23,12 +23,12 @@ jobs:
|
||||
name: isort
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
@@ -43,12 +43,12 @@ jobs:
|
||||
name: flake8
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
@@ -63,9 +63,9 @@ jobs:
|
||||
name: licenseheaders
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.11
|
||||
- name: Install Dependencies
|
||||
|
||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
- database: sqlite
|
||||
python-version: "3.10"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: harmon758/postgresql-action@v1
|
||||
with:
|
||||
postgresql version: '15'
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
postgresql password: 'postgres'
|
||||
if: matrix.database == 'postgres'
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
|
||||
@@ -52,10 +52,18 @@ Example::
|
||||
``currency``
|
||||
The default currency as a three-letter code. Defaults to ``EUR``.
|
||||
|
||||
``cachedir``
|
||||
The local path to a directory where temporary files will be stored.
|
||||
Defaults to the ``cache`` directory below the ``datadir``.
|
||||
|
||||
``datadir``
|
||||
The local path to a data directory that will be used for storing user uploads and similar
|
||||
data. Defaults to the value of the environment variable ``DATA_DIR`` or ``data``.
|
||||
|
||||
``logdir``
|
||||
The local path to a directory where log files will be stored.
|
||||
Defaults to the ``logs`` directory below the ``datadir``.
|
||||
|
||||
``plugins_default``
|
||||
A comma-separated list of plugins that are enabled by default for all new events.
|
||||
Defaults to ``pretix.plugins.sendmail,pretix.plugins.statistics``.
|
||||
@@ -89,8 +97,9 @@ Example::
|
||||
Defaults to ``off``.
|
||||
|
||||
``obligatory_2fa``
|
||||
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
|
||||
Defaults to ``False``
|
||||
Enables or disables obligatory usage of two-factor authentication for users of the pretix backend.
|
||||
Can be ``True`` to make two-factor authentication obligatory for all users or ``staff`` to make it only
|
||||
obligatory to users with admin permissions. Defaults to ``False``.
|
||||
|
||||
``trust_x_forwarded_for``
|
||||
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
|
||||
@@ -149,6 +158,7 @@ Example::
|
||||
host=localhost
|
||||
port=3306
|
||||
advisory_lock_index=1
|
||||
disable_server_side_cursors=0
|
||||
sslmode=require
|
||||
sslrootcert=/etc/pretix/postgresql-ca.crt
|
||||
sslcert=/etc/pretix/postgresql-client-crt.crt
|
||||
@@ -169,6 +179,11 @@ Example::
|
||||
and are not scoped to a specific database. If you run multiple pretix applications with the same PostgreSQL server,
|
||||
you should set separate values for this setting (integers up to 256).
|
||||
|
||||
``disable_server_side_cursors``
|
||||
On PostgreSQL pretix might use server side cursors for certain operations. This is generally fine but will break in
|
||||
specific circumstances, for example when connecting to PostgreSQL through a PGBouncer configured with a transaction
|
||||
pool mode. Off by default (i.e. by default server side cursors will be used).
|
||||
|
||||
``sslmode``, ``sslrootcert``
|
||||
Connection TLS details for the PostgreSQL database connection. Possible values of ``sslmode`` are ``disable``, ``allow``, ``prefer``, ``require``, ``verify-ca``, and ``verify-full``. ``sslrootcert`` should be the accessible path of the ca certificate. Both values are empty by default.
|
||||
|
||||
|
||||
@@ -249,7 +249,10 @@ You can get three response codes:
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"event": "democon",
|
||||
"event": {
|
||||
"name": "Demo Conference",
|
||||
"slug": "democon"
|
||||
},
|
||||
"subevent": 23,
|
||||
"checkinlist": 5
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ external_identifier string External ID of
|
||||
the API, but is read-only for customers created through a
|
||||
SSO integration.
|
||||
email string Customer email address
|
||||
phone string Customer phone number
|
||||
name string Name of this customer (or ``null``)
|
||||
name_parts object of strings Decomposition of name (i.e. given name, family name)
|
||||
is_active boolean Whether this account is active
|
||||
@@ -39,6 +40,10 @@ password string Can only be set
|
||||
|
||||
Passwords can now be set through the API during customer creation.
|
||||
|
||||
.. versionchanged:: 2024.3
|
||||
|
||||
The attribute ``phone`` has been added.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -71,6 +76,7 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "customer@example.org",
|
||||
"phone": "+493012345678",
|
||||
"name": "John Doe",
|
||||
"name_parts": {
|
||||
"_scheme": "full",
|
||||
@@ -118,6 +124,7 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "customer@example.org",
|
||||
"phone": "+493012345678",
|
||||
"name": "John Doe",
|
||||
"name_parts": {
|
||||
"_scheme": "full",
|
||||
@@ -155,6 +162,7 @@ Endpoints
|
||||
|
||||
{
|
||||
"email": "test@example.org",
|
||||
"phone": "+493012345678",
|
||||
"password": "verysecret",
|
||||
"send_email": true
|
||||
}
|
||||
@@ -171,6 +179,7 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "test@example.org",
|
||||
"phone": "+493012345678",
|
||||
...
|
||||
}
|
||||
|
||||
@@ -215,6 +224,7 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": "test@example.org",
|
||||
"phone": "+493012345678",
|
||||
…
|
||||
}
|
||||
|
||||
@@ -249,6 +259,7 @@ Endpoints
|
||||
"identifier": "8WSAJCJ",
|
||||
"external_identifier": null,
|
||||
"email": null,
|
||||
"phone": null,
|
||||
…
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ id integer Internal ID of
|
||||
name string Team name
|
||||
all_events boolean Whether this team has access to all events
|
||||
limit_events list List of event slugs this team has access to
|
||||
require_2fa boolean Whether members of this team are required to use
|
||||
two-factor authentication
|
||||
can_create_events boolean
|
||||
can_change_teams boolean
|
||||
can_change_organizer_settings boolean
|
||||
@@ -122,6 +124,7 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -159,6 +162,7 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -186,6 +190,7 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -203,6 +208,7 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -246,6 +252,7 @@ Team endpoints
|
||||
"name": "Admin team",
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
|
||||
.. _`importcol`:
|
||||
|
||||
Extending the order import process
|
||||
==================================
|
||||
Extending the import process
|
||||
============================
|
||||
|
||||
It's possible through the backend to import orders into pretix, for example from a legacy ticketing system. If your
|
||||
plugins defines additional data structures around orders, it might be useful to make it possible to import them as well.
|
||||
It's possible through the backend to import objects into pretix, for example orders from a legacy ticketing system. If
|
||||
your plugin defines additional data structures around those objects, it might be useful to make it possible to import
|
||||
them as well.
|
||||
|
||||
Import process
|
||||
--------------
|
||||
@@ -40,7 +41,7 @@ Column registration
|
||||
|
||||
The import API does not make a lot of usage from signals, however, it
|
||||
does use a signal to get a list of all available import columns. Your plugin
|
||||
should listen for this signal and return the subclass of ``pretix.base.orderimport.ImportColumn``
|
||||
should listen for this signal and return the subclass of ``pretix.base.modelimport.ImportColumn``
|
||||
that we'll provide in this plugin:
|
||||
|
||||
.. sourcecode:: python
|
||||
@@ -56,10 +57,16 @@ that we'll provide in this plugin:
|
||||
EmailColumn(sender),
|
||||
]
|
||||
|
||||
Similar signals exist for other objects:
|
||||
|
||||
.. automodule:: pretix.base.signals
|
||||
:members: voucher_import_columns
|
||||
|
||||
|
||||
The column class API
|
||||
--------------------
|
||||
|
||||
.. class:: pretix.base.orderimport.ImportColumn
|
||||
.. class:: pretix.base.modelimport.ImportColumn
|
||||
|
||||
The central object of each import extension is the subclass of ``ImportColumn``.
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ convenient to you:
|
||||
|
||||
.. automethod:: _register_fonts
|
||||
|
||||
.. automethod:: _register_event_fonts
|
||||
|
||||
.. automethod:: _on_first_page
|
||||
|
||||
.. automethod:: _on_other_page
|
||||
|
||||
@@ -33,14 +33,14 @@ dependencies = [
|
||||
"celery==5.3.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=3.4.2",
|
||||
"css-inline==0.13.*",
|
||||
"css-inline==0.14.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"dj-static",
|
||||
"Django==4.2.*",
|
||||
"django-bootstrap3==23.6.*",
|
||||
"django-compressor==4.4",
|
||||
"django-countries==7.5.*",
|
||||
"django-filter==23.5",
|
||||
"django-countries==7.6.*",
|
||||
"django-filter==24.2",
|
||||
"django-formset-js-improved==0.5.0.3",
|
||||
"django-formtools==2.5.1",
|
||||
"django-hierarkey==1.1.*",
|
||||
@@ -50,13 +50,13 @@ dependencies = [
|
||||
"django-localflavor==4.0",
|
||||
"django-markup",
|
||||
"django-oauth-toolkit==2.3.*",
|
||||
"django-otp==1.3.*",
|
||||
"django-otp==1.4.*",
|
||||
"django-phonenumber-field==7.3.*",
|
||||
"django-redis==5.4.*",
|
||||
"django-scopes==2.0.*",
|
||||
"django-statici18n==2.4.*",
|
||||
"djangorestframework==3.14.*",
|
||||
"dnspython==2.5.*",
|
||||
"dnspython==2.6.*",
|
||||
"drf_ujson2==1.7.*",
|
||||
"geoip2==4.*",
|
||||
"importlib_metadata==7.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||
@@ -65,7 +65,7 @@ dependencies = [
|
||||
"kombu==5.3.*",
|
||||
"libsass==0.23.*",
|
||||
"lxml",
|
||||
"markdown==3.5.2", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
"markdown==3.6", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
|
||||
"mt-940==4.30.*",
|
||||
"oauthlib==3.2.*",
|
||||
@@ -75,12 +75,12 @@ dependencies = [
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.8.*",
|
||||
"phonenumberslite==8.13.*",
|
||||
"Pillow==10.2.*",
|
||||
"Pillow==10.3.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==4.25.*",
|
||||
"protobuf==5.26.*",
|
||||
"psycopg2-binary",
|
||||
"pycountry",
|
||||
"pycparser==2.21",
|
||||
"pycparser==2.22",
|
||||
"pycryptodome==3.20.*",
|
||||
"pypdf==3.9.*",
|
||||
"python-bidi==0.4.*", # Support for Arabic in reportlab
|
||||
@@ -92,7 +92,7 @@ dependencies = [
|
||||
"redis==5.0.*",
|
||||
"reportlab==4.1.*",
|
||||
"requests==2.31.*",
|
||||
"sentry-sdk==1.40.*",
|
||||
"sentry-sdk==1.45.*",
|
||||
"sepaxml==2.6.*",
|
||||
"slimit",
|
||||
"static3==0.7.*",
|
||||
@@ -100,9 +100,10 @@ dependencies = [
|
||||
"text-unidecode==1.*",
|
||||
"tlds>=2020041600",
|
||||
"tqdm==4.*",
|
||||
"ua-parser==0.18.*",
|
||||
"vat_moss_forked==2020.3.20.0.11.0",
|
||||
"vobject==0.9.*",
|
||||
"webauthn==2.0.*",
|
||||
"webauthn==2.1.*",
|
||||
"zeep==4.2.*"
|
||||
]
|
||||
|
||||
@@ -122,11 +123,11 @@ dev = [
|
||||
"pytest-cache",
|
||||
"pytest-cov",
|
||||
"pytest-django==4.*",
|
||||
"pytest-mock==3.12.*",
|
||||
"pytest-rerunfailures==13.*",
|
||||
"pytest-mock==3.14.*",
|
||||
"pytest-rerunfailures==14.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.5.*",
|
||||
"pytest==8.0.*",
|
||||
"pytest==8.1.*",
|
||||
"responses",
|
||||
]
|
||||
|
||||
|
||||
4
src/.watchmanconfig
Normal file
4
src/.watchmanconfig
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ignore_dirs": ["node_modules", "data", "pretix/static", "pretix/locale", "pretix/static.dist"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ localecompile:
|
||||
./manage.py compilemessages
|
||||
|
||||
localegen:
|
||||
./manage.py makemessages --keep-pot --ignore "pretix/helpers/*" --ignore "pretix/static/npm_dir/*" $(LNGS)
|
||||
./manage.py makemessages --keep-pot --ignore "pretix/static/npm_dir/*" $(LNGS)
|
||||
./manage.py makemessages --keep-pot -d djangojs --ignore "pretix/static/npm_dir/*" --ignore "pretix/helpers/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static/jsi18n/*" --ignore "pretix/static.dist/*" --ignore "data/*" --ignore "pretix/static/rrule/*" --ignore "build/*" $(LNGS)
|
||||
|
||||
staticfiles: jsi18n
|
||||
|
||||
@@ -19,4 +19,4 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "2024.3.0.dev0"
|
||||
__version__ = "2024.4.0.dev0"
|
||||
|
||||
@@ -111,6 +111,7 @@ LANGUAGES_RTL = {
|
||||
LANGUAGES_INCUBATING = {
|
||||
'fi', 'pt-br', 'gl',
|
||||
}
|
||||
LANGUAGES = ALL_LANGUAGES
|
||||
LOCALE_PATHS = [
|
||||
os.path.join(os.path.dirname(__file__), 'locale'),
|
||||
]
|
||||
|
||||
@@ -39,7 +39,8 @@ from pretix.base.models import Device, Event, User
|
||||
from pretix.base.models.auth import SuperuserPermissionSet
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.helpers.security import (
|
||||
SessionInvalid, SessionReauthRequired, assert_session_valid,
|
||||
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
|
||||
SessionReauthRequired, assert_session_valid,
|
||||
)
|
||||
|
||||
|
||||
@@ -66,6 +67,10 @@ class EventPermission(BasePermission):
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
except Session2FASetupRequired:
|
||||
return False
|
||||
except SessionPasswordChangeRequired:
|
||||
return False
|
||||
|
||||
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
|
||||
else request.user)
|
||||
@@ -144,6 +149,10 @@ class ProfilePermission(BasePermission):
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
except Session2FASetupRequired:
|
||||
return False
|
||||
except SessionPasswordChangeRequired:
|
||||
return False
|
||||
|
||||
if isinstance(request.auth, OAuthAccessToken):
|
||||
if not (request.auth.allow_scopes(['read']) or request.auth.allow_scopes(['profile'])) and request.method in SAFE_METHODS:
|
||||
@@ -166,5 +175,9 @@ class AnyAuthenticatedClientPermission(BasePermission):
|
||||
return False
|
||||
except SessionReauthRequired:
|
||||
return False
|
||||
except Session2FASetupRequired:
|
||||
return False
|
||||
except SessionPasswordChangeRequired:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -687,6 +687,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'allow_modifications_after_checkin',
|
||||
'show_quota_left',
|
||||
'waiting_list_enabled',
|
||||
'waiting_list_auto_disable',
|
||||
'waiting_list_hours',
|
||||
'waiting_list_auto',
|
||||
'waiting_list_names_asked',
|
||||
|
||||
@@ -1315,7 +1315,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if 'valid_from' not in pos_data and 'valid_until' not in pos_data:
|
||||
valid_from, valid_until = pos_data['item'].compute_validity(
|
||||
requested_start=(
|
||||
max(requested_valid_from, now())
|
||||
requested_valid_from
|
||||
if requested_valid_from and pos_data['item'].validity_dynamic_start_choice
|
||||
else now()
|
||||
),
|
||||
@@ -1439,6 +1439,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if not pos.item.tax_rule or pos.item.tax_rule.price_includes_tax:
|
||||
price_after_voucher = max(pos.price, pos.voucher.calculate_price(listed_price))
|
||||
else:
|
||||
pos._calculate_tax(invoice_address=ia)
|
||||
price_after_voucher = max(pos.price - pos.tax_value, pos.voucher.calculate_price(listed_price))
|
||||
else:
|
||||
price_after_voucher = listed_price
|
||||
@@ -1466,7 +1467,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
answers_data = pos_data.pop('answers', [])
|
||||
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
|
||||
pos = pos_data['__instance']
|
||||
pos._calculate_tax()
|
||||
pos._calculate_tax(invoice_address=ia)
|
||||
|
||||
if simulate:
|
||||
pos = WrappedModel(pos)
|
||||
@@ -1585,7 +1586,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') == Order.STATUS_PAID and not payment_provider:
|
||||
payment_provider = 'free'
|
||||
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
|
||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID and not validated_data.get('require_approval'):
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
order.payments.create(
|
||||
@@ -1597,6 +1598,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
elif validated_data.get('status') == Order.STATUS_PAID:
|
||||
if not payment_provider:
|
||||
raise ValidationError('You cannot create a paid order without a payment provider.')
|
||||
if validated_data.get('require_approval'):
|
||||
raise ValidationError('You cannot create a paid order that requires approval.')
|
||||
order.payments.create(
|
||||
amount=order.total,
|
||||
provider=payment_provider,
|
||||
|
||||
@@ -79,8 +79,8 @@ class CustomerSerializer(I18nAwareModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Customer
|
||||
fields = ('identifier', 'external_identifier', 'email', 'name', 'name_parts', 'is_active', 'is_verified', 'last_login', 'date_joined',
|
||||
'locale', 'last_modified', 'notes')
|
||||
fields = ('identifier', 'external_identifier', 'email', 'phone', 'name', 'name_parts', 'is_active',
|
||||
'is_verified', 'last_login', 'date_joined', 'locale', 'last_modified', 'notes')
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
if instance and instance.provider_id:
|
||||
@@ -239,7 +239,7 @@ class TeamSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = (
|
||||
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
|
||||
'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
|
||||
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
|
||||
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
|
||||
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import datetime
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
from decimal import Decimal
|
||||
@@ -27,7 +28,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import django_filters
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db.models import (
|
||||
Exists, F, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects,
|
||||
)
|
||||
@@ -97,6 +98,8 @@ from pretix.base.signals import (
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.control.signals import order_search_filter_q
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
with scopes_disabled():
|
||||
class OrderFilter(FilterSet):
|
||||
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
|
||||
@@ -900,7 +903,11 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
order_modified.send(sender=serializer.instance.event, order=serializer.instance)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
try:
|
||||
serializer.save()
|
||||
except IntegrityError:
|
||||
logger.exception("Integrity error while saving order")
|
||||
raise ValidationError("Integrity error, possibly duplicate submission of same order.")
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if not instance.testmode:
|
||||
|
||||
@@ -176,7 +176,7 @@ class ParametrizedItemWebhookEvent(ParametrizedWebhookEvent):
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
class ParametrizedOrderPositionCheckinWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
d = super().build_payload(logentry)
|
||||
@@ -185,6 +185,7 @@ class ParametrizedOrderPositionWebhookEvent(ParametrizedOrderWebhookEvent):
|
||||
d['orderposition_id'] = logentry.parsed_data.get('position')
|
||||
d['orderposition_positionid'] = logentry.parsed_data.get('positionid')
|
||||
d['checkin_list'] = logentry.parsed_data.get('list')
|
||||
d['type'] = logentry.parsed_data.get('type')
|
||||
d['first_checkin'] = logentry.parsed_data.get('first_checkin')
|
||||
return d
|
||||
|
||||
@@ -296,11 +297,11 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
'pretix.event.order.denied',
|
||||
_('Order denied'),
|
||||
),
|
||||
ParametrizedOrderPositionWebhookEvent(
|
||||
ParametrizedOrderPositionCheckinWebhookEvent(
|
||||
'pretix.event.checkin',
|
||||
_('Ticket checked in'),
|
||||
),
|
||||
ParametrizedOrderPositionWebhookEvent(
|
||||
ParametrizedOrderPositionCheckinWebhookEvent(
|
||||
'pretix.event.checkin.reverted',
|
||||
_('Ticket check-in reverted'),
|
||||
),
|
||||
|
||||
@@ -46,7 +46,7 @@ class PretixBaseConfig(AppConfig):
|
||||
from . import invoice # NOQA
|
||||
from . import notifications # NOQA
|
||||
from . import email # NOQA
|
||||
from .services import auth, checkin, currencies, export, mail, tickets, cart, orderimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .services import auth, checkin, currencies, export, mail, tickets, cart, modelimport, orders, invoices, cleanup, update_check, quotas, notifications, vouchers # NOQA
|
||||
from .models import _transactions # NOQA
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -116,15 +116,29 @@ class DekodiNREIExporter(BaseExporter):
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
elif p.provider and p.provider.startswith('stripe'):
|
||||
src = p.info_data.get("source", p.info_data)
|
||||
pi = p.info_data or {}
|
||||
try:
|
||||
if "latest_charge" in pi and isinstance(pi.get("latest_charge"), dict):
|
||||
details = pi["latest_charge"]["payment_method_details"]
|
||||
card = details.get("card", {})
|
||||
elif pi.get("charges") and pi["charges"]["data"]:
|
||||
details = pi["charges"]["data"][0].get("payment_method_details", {})
|
||||
card = details.get("card", {})
|
||||
else:
|
||||
details = pi["source"]
|
||||
card = pi["source"]["card"]
|
||||
except:
|
||||
details = {}
|
||||
card = {}
|
||||
|
||||
payments.append({
|
||||
'PTID': '81',
|
||||
'PTN': 'Stripe',
|
||||
'PTNo1': p.info_data.get("id") or '',
|
||||
'PTNo5': src.get("card", {}).get("last4") or '',
|
||||
'PTNo1': pi.get("id") or '',
|
||||
'PTNo5': card.get("last4", ""),
|
||||
'PTNo7': round(float(p.amount), 2) or '',
|
||||
'PTNo8': str(self.event.currency) or '',
|
||||
'PTNo10': src.get('owner', {}).get('verified_name') or src.get('owner', {}).get('name') or '',
|
||||
'PTNo10': details.get('owner', {}).get('verified_name') or details.get('owner', {}).get('name') or '',
|
||||
'PTNo15': p.full_id or '',
|
||||
})
|
||||
else:
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
@@ -44,10 +45,13 @@ from django.contrib.auth.password_validation import (
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.metrics import pretix_failed_logins
|
||||
from pretix.base.models import User
|
||||
from pretix.helpers.dicts import move_to_end
|
||||
from pretix.helpers.http import get_client_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginForm(forms.Form):
|
||||
"""
|
||||
@@ -55,6 +59,7 @@ class LoginForm(forms.Form):
|
||||
username/password logins.
|
||||
"""
|
||||
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)
|
||||
origin = forms.CharField(widget=forms.HiddenInput, required=False)
|
||||
|
||||
error_messages = {
|
||||
'invalid_login': _("This combination of credentials is not known to our system."),
|
||||
@@ -104,12 +109,16 @@ class LoginForm(forms.Form):
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.get(self.ratelimit_key)
|
||||
if cnt and int(cnt) > 10:
|
||||
pretix_failed_logins.inc(1, reason="ratelimit")
|
||||
logger.info("Backend login rejected due to rate limit.")
|
||||
raise forms.ValidationError(self.error_messages['rate_limit'], code='rate_limit')
|
||||
self.user_cache = self.backend.form_authenticate(self.request, self.cleaned_data)
|
||||
if self.user_cache is None:
|
||||
if self.ratelimit_key:
|
||||
rc.incr(self.ratelimit_key)
|
||||
rc.expire(self.ratelimit_key, 300)
|
||||
logger.info("Backend login invalid.")
|
||||
pretix_failed_logins.inc(1, reason="invalid")
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['invalid_login'],
|
||||
code='invalid_login'
|
||||
@@ -131,6 +140,8 @@ class LoginForm(forms.Form):
|
||||
If the given user may log in, this method should return None.
|
||||
"""
|
||||
if not user.is_active:
|
||||
logger.info("Backend login rejected due to user inactive.")
|
||||
pretix_failed_logins.inc(1, reason="inactive")
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['inactive'],
|
||||
code='inactive',
|
||||
|
||||
@@ -609,27 +609,38 @@ class BaseQuestionsForm(forms.Form):
|
||||
max_date = now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
|
||||
else:
|
||||
max_date = None
|
||||
min_date = now()
|
||||
initial = None
|
||||
if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership:
|
||||
if pos.used_membership.date_start >= now():
|
||||
initial = min_date = pos.used_membership.date_start
|
||||
max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end
|
||||
if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:
|
||||
attrs = {}
|
||||
if max_date:
|
||||
attrs['data-max'] = max_date.date().isoformat()
|
||||
if min_date:
|
||||
attrs['data-min'] = min_date.date().isoformat()
|
||||
self.fields['requested_valid_from'] = forms.DateField(
|
||||
label=_('Start date'),
|
||||
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=False,
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
widget=DatePickerWidget(attrs),
|
||||
validators=[MaxDateValidator(max_date.date())] if max_date else []
|
||||
validators=([MaxDateValidator(max_date.date())] if max_date else []) + [MinDateValidator(min_date.date())]
|
||||
)
|
||||
else:
|
||||
self.fields['requested_valid_from'] = forms.SplitDateTimeField(
|
||||
label=_('Start date'),
|
||||
help_text=_('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=False,
|
||||
help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'),
|
||||
required=bool(initial),
|
||||
initial=pos.requested_valid_from or initial,
|
||||
widget=SplitDateTimePickerWidget(
|
||||
time_format=get_format_without_seconds('TIME_INPUT_FORMATS'),
|
||||
min_date=min_date,
|
||||
max_date=max_date
|
||||
),
|
||||
validators=[MaxDateTimeValidator(max_date)] if max_date else []
|
||||
validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)]
|
||||
)
|
||||
|
||||
add_fields = {}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import os
|
||||
from datetime import date
|
||||
from datetime import datetime
|
||||
|
||||
from django import forms
|
||||
from django.utils.formats import get_format
|
||||
@@ -188,11 +188,11 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
|
||||
time_attrs['autocomplete'] = 'off'
|
||||
if min_date:
|
||||
date_attrs['data-min'] = (
|
||||
min_date if isinstance(min_date, date) else min_date.astimezone(get_current_timezone()).date()
|
||||
min_date if not isinstance(min_date, datetime) else min_date.astimezone(get_current_timezone()).date()
|
||||
).isoformat()
|
||||
if max_date:
|
||||
date_attrs['data-max'] = (
|
||||
max_date if isinstance(max_date, date) else max_date.astimezone(get_current_timezone()).date()
|
||||
max_date if not isinstance(max_date, datetime) else max_date.astimezone(get_current_timezone()).date()
|
||||
).isoformat()
|
||||
|
||||
def date_placeholder():
|
||||
|
||||
@@ -182,7 +182,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
|
||||
italic='OpenSansIt', boldItalic='OpenSansBI')
|
||||
|
||||
for family, styles in get_fonts().items():
|
||||
for family, styles in get_fonts(event=self.event, pdf_support_required=True).items():
|
||||
if family == self.event.settings.invoice_renderer_font:
|
||||
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
||||
self.font_regular = family
|
||||
@@ -625,7 +625,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
)]
|
||||
else:
|
||||
tdata = [(
|
||||
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['BoldRight']),
|
||||
Paragraph(self._normalize(pgettext('invoice', 'Description')), self.stylesheet['Bold']),
|
||||
Paragraph(self._normalize(pgettext('invoice', 'Qty')), self.stylesheet['BoldRightNoSplit']),
|
||||
Paragraph(self._normalize(pgettext('invoice', 'Amount')), self.stylesheet['BoldRightNoSplit']),
|
||||
)]
|
||||
|
||||
@@ -268,7 +268,10 @@ def metric_values():
|
||||
dkey = key.decode("utf-8")
|
||||
splitted = dkey.split("{", 2)
|
||||
value = float(value.decode("utf-8"))
|
||||
metrics[splitted[0]]["{" + splitted[1]] = value
|
||||
if len(splitted) == 1:
|
||||
metrics[splitted[0]][""] = value
|
||||
else:
|
||||
metrics[splitted[0]]["{" + splitted[1]] = value
|
||||
|
||||
# Aliases
|
||||
aliases = {
|
||||
@@ -314,3 +317,5 @@ pretix_task_runs_total = Counter("pretix_task_runs_total", "Total calls to a cel
|
||||
["task_name", "status"])
|
||||
pretix_task_duration_seconds = Histogram("pretix_task_duration_seconds", "Call time of a celery task",
|
||||
["task_name"])
|
||||
pretix_successful_logins = Counter("pretix_logins_successful", "Successful logins", [])
|
||||
pretix_failed_logins = Counter("pretix_logins_failed", "Failed logins", ["reason"])
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from collections import OrderedDict
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import urlparse, urlsplit
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from django.conf import settings
|
||||
@@ -40,6 +40,7 @@ from pretix.base.settings import global_settings_object
|
||||
from pretix.multidomain.urlreverse import (
|
||||
get_event_domain, get_organizer_domain,
|
||||
)
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
_supported = None
|
||||
|
||||
@@ -240,6 +241,14 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
)
|
||||
|
||||
def process_response(self, request, resp):
|
||||
def nested_dict_values(d):
|
||||
for v in d.values():
|
||||
if isinstance(v, dict):
|
||||
yield from nested_dict_values(v)
|
||||
else:
|
||||
if isinstance(v, str):
|
||||
yield v
|
||||
|
||||
url = resolve(request.path_info)
|
||||
|
||||
if settings.DEBUG and resp.status_code >= 400:
|
||||
@@ -259,6 +268,14 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
if gs.settings.leaflet_tiles:
|
||||
img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*"))
|
||||
|
||||
font_src = set()
|
||||
if hasattr(request, 'event'):
|
||||
for font in get_fonts(request.event, pdf_support_required=False).values():
|
||||
for path in list(nested_dict_values(font)):
|
||||
font_location = urlparse(path)
|
||||
if font_location.scheme and font_location.netloc:
|
||||
font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc))
|
||||
|
||||
h = {
|
||||
'default-src': ["{static}"],
|
||||
'script-src': ['{static}'],
|
||||
@@ -267,7 +284,7 @@ class SecurityMiddleware(MiddlewareMixin):
|
||||
'style-src': ["{static}", "{media}"],
|
||||
'connect-src': ["{dynamic}", "{media}"],
|
||||
'img-src': ["{static}", "{media}", "data:"] + img_src,
|
||||
'font-src': ["{static}"],
|
||||
'font-src': ["{static}"] + list(font_src),
|
||||
'media-src': ["{static}", "data:"],
|
||||
# form-action is not only used to match on form actions, but also on URLs
|
||||
# form-actions redirect to. In the context of e.g. payment providers or
|
||||
|
||||
48
src/pretix/base/migrations/0258_uniq_indx.py
Normal file
48
src/pretix/base/migrations/0258_uniq_indx.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 4.2.10 on 2024-03-15 09:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0257_item_default_price_not_null"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="order",
|
||||
name="organizer",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="orders",
|
||||
to="pretixbase.organizer",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="orderposition",
|
||||
name="organizer",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="order_positions",
|
||||
to="pretixbase.organizer",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="order",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("organizer", "code"), name="order_organizer_code_uniq"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="orderposition",
|
||||
constraint=models.UniqueConstraint(
|
||||
models.F("organizer"),
|
||||
models.F("secret"),
|
||||
name="orderposition_organizer_secret_uniq",
|
||||
),
|
||||
),
|
||||
]
|
||||
18
src/pretix/base/migrations/0259_team_require_2fa.py
Normal file
18
src/pretix/base/migrations/0259_team_require_2fa.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.10 on 2024-04-02 11:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0258_uniq_indx"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="team",
|
||||
name="require_2fa",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 4.2.10 on 2024-04-02 15:16
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0259_team_require_2fa"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterIndexTogether(
|
||||
name="reusablemedium",
|
||||
index_together=set(),
|
||||
),
|
||||
]
|
||||
48
src/pretix/base/migrations/0261_userknownloginsource.py
Normal file
48
src/pretix/base/migrations/0261_userknownloginsource.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 4.2.10 on 2024-04-02 15:37
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.helpers.countries
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0260_alter_reusablemedium_index_together"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserKnownLoginSource",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("agent_type", models.CharField(max_length=255, null=True)),
|
||||
("device_type", models.CharField(max_length=255, null=True)),
|
||||
("os_type", models.CharField(max_length=255, null=True)),
|
||||
(
|
||||
"country",
|
||||
pretix.helpers.countries.FastCountryField(
|
||||
countries=pretix.helpers.countries.CachedCountries,
|
||||
max_length=2,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("last_seen", models.DateTimeField()),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="known_login_sources",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
287
src/pretix/base/modelimport.py
Normal file
287
src/pretix/base/modelimport.py
Normal file
@@ -0,0 +1,287 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import csv
|
||||
import datetime
|
||||
import io
|
||||
import re
|
||||
from decimal import Decimal, DecimalException
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_integer
|
||||
from django.utils import formats
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext
|
||||
|
||||
from pretix.base.i18n import LazyLocaleException
|
||||
from pretix.base.models import SubEvent
|
||||
|
||||
|
||||
class DataImportError(LazyLocaleException):
|
||||
def __init__(self, *args):
|
||||
msg = args[0]
|
||||
msgargs = args[1] if len(args) > 1 else None
|
||||
self.args = args
|
||||
if msgargs:
|
||||
msg = _(msg) % msgargs
|
||||
else:
|
||||
msg = _(msg)
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
def parse_csv(file, length=None, mode="strict", charset=None):
|
||||
file.seek(0)
|
||||
data = file.read(length)
|
||||
if not charset:
|
||||
try:
|
||||
import chardet
|
||||
charset = chardet.detect(data)['encoding']
|
||||
except ImportError:
|
||||
charset = file.charset
|
||||
data = data.decode(charset or "utf-8", mode)
|
||||
# If the file was modified on a Mac, it only contains \r as line breaks
|
||||
if '\r' in data and '\n' not in data:
|
||||
data = data.replace('\r', '\n')
|
||||
|
||||
try:
|
||||
dialect = csv.Sniffer().sniff(data.split("\n")[0], delimiters=";,.#:")
|
||||
except csv.Error:
|
||||
return None
|
||||
|
||||
if dialect is None:
|
||||
return None
|
||||
|
||||
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
|
||||
return reader
|
||||
|
||||
|
||||
class ImportColumn:
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
Unique, internal name of the column.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
"""
|
||||
Human-readable description of the column
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
"""
|
||||
Initial value for the form component
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def default_value(self):
|
||||
"""
|
||||
Internal default value for the assignment of this column. Defaults to ``empty``. Return ``None`` to disable this
|
||||
option.
|
||||
"""
|
||||
return 'empty'
|
||||
|
||||
@property
|
||||
def default_label(self):
|
||||
"""
|
||||
Human-readable description of the default assignment of this column, defaults to "Keep empty".
|
||||
"""
|
||||
return gettext_lazy('Keep empty')
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
|
||||
def static_choices(self):
|
||||
"""
|
||||
This will be called when rendering the form component and allows you to return a list of values that can be
|
||||
selected by the user statically during import.
|
||||
|
||||
:return: list of 2-tuples of strings
|
||||
"""
|
||||
return []
|
||||
|
||||
def resolve(self, settings, record):
|
||||
"""
|
||||
This method will be called to get the raw value for this field, usually by either using a static value or
|
||||
inspecting the CSV file for the assigned header. You usually do not need to implement this on your own,
|
||||
the default should be fine.
|
||||
"""
|
||||
k = settings.get(self.identifier, self.default_value)
|
||||
if k == self.default_value:
|
||||
return None
|
||||
elif k.startswith('csv:'):
|
||||
return record.get(k[4:], None) or None
|
||||
elif k.startswith('static:'):
|
||||
return k[7:]
|
||||
raise ValidationError(_('Invalid setting for column "{header}".').format(header=self.verbose_name))
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
"""
|
||||
Allows you to validate the raw input value for your column. Raise ``ValidationError`` if the value is invalid.
|
||||
You do not need to include the column or row name or value in the error message as it will automatically be
|
||||
included.
|
||||
|
||||
:param value: Contains the raw value of your column as returned by ``resolve``. This can usually be ``None``,
|
||||
e.g. if the column is empty or does not exist in this row.
|
||||
:param previous_values: Dictionary containing the validated values of all columns that have already been validated.
|
||||
"""
|
||||
return value
|
||||
|
||||
def assign(self, value, obj, **kwargs):
|
||||
"""
|
||||
This will be called to perform the actual import. You are supposed to set attributes on the ``obj`` or other
|
||||
related objects that get passed in based on the input ``value``. This is called *before* the actual database
|
||||
transaction, so the input objects do not yet have a primary key. If you want to create related objects, you
|
||||
need to place them into some sort of internal queue and persist them when ``save`` is called.
|
||||
"""
|
||||
pass
|
||||
|
||||
def save(self, obj):
|
||||
"""
|
||||
This will be called to perform the actual import. This is called inside the actual database transaction and the
|
||||
input object ``obj`` has already been saved to the database.
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def timezone(self):
|
||||
return self.event.timezone
|
||||
|
||||
|
||||
def i18n_flat(l):
|
||||
if isinstance(l.data, dict):
|
||||
return l.data.values()
|
||||
return [l.data]
|
||||
|
||||
|
||||
class BooleanColumnMixin:
|
||||
default_value = None
|
||||
initial = "static:false"
|
||||
|
||||
def static_choices(self):
|
||||
return (
|
||||
("false", _("No")),
|
||||
("true", _("Yes")),
|
||||
)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
return False
|
||||
|
||||
if value.lower() in ("true", "1", "yes", _("Yes").lower()):
|
||||
return True
|
||||
elif value.lower() in ("false", "0", "no", _("No").lower()):
|
||||
return False
|
||||
else:
|
||||
raise ValidationError(_("Could not parse {value} as a yes/no value.").format(value=value))
|
||||
|
||||
|
||||
class DatetimeColumnMixin:
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
return
|
||||
|
||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||
for format in input_formats:
|
||||
try:
|
||||
d = datetime.datetime.strptime(value, format)
|
||||
d = d.replace(tzinfo=self.timezone)
|
||||
return d
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
|
||||
|
||||
|
||||
class DecimalColumnMixin:
|
||||
def clean(self, value, previous_values):
|
||||
if value not in (None, ''):
|
||||
value = formats.sanitize_separators(re.sub(r'[^0-9.,-]', '', value))
|
||||
try:
|
||||
value = Decimal(value)
|
||||
except (DecimalException, TypeError):
|
||||
raise ValidationError(_('You entered an invalid number.'))
|
||||
return value
|
||||
|
||||
|
||||
class IntegerColumnMixin:
|
||||
def clean(self, value, previous_values):
|
||||
if value is not None:
|
||||
validate_integer(value)
|
||||
return int(value)
|
||||
|
||||
|
||||
class SubeventColumnMixin:
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._subevent_cache = {}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@cached_property
|
||||
def subevents(self):
|
||||
return list(self.event.subevents.filter(active=True).order_by('date_from'))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(p.pk), str(p)) for p in self.subevents
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value in self._subevent_cache:
|
||||
return self._subevent_cache[value]
|
||||
|
||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||
for format in input_formats:
|
||||
try:
|
||||
d = datetime.datetime.strptime(value, format)
|
||||
d = d.replace(tzinfo=self.event.timezone)
|
||||
try:
|
||||
se = self.event.subevents.get(
|
||||
active=True,
|
||||
date_from__gt=d - datetime.timedelta(seconds=1),
|
||||
date_from__lt=d + datetime.timedelta(seconds=1),
|
||||
)
|
||||
self._subevent_cache[value] = se
|
||||
return se
|
||||
except SubEvent.DoesNotExist:
|
||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||
except SubEvent.MultipleObjectsReturned:
|
||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
matches = [
|
||||
p for p in self.subevents
|
||||
if str(p.pk) == value or any(
|
||||
(v and v == value) for v in i18n_flat(p.name)) or p.date_from.isoformat() == value
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||
|
||||
self._subevent_cache[value] = matches[0]
|
||||
return matches[0]
|
||||
@@ -19,17 +19,13 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import datetime
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal, DecimalException
|
||||
|
||||
import pycountry
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import EmailValidator
|
||||
from django.db.models import Q
|
||||
from django.utils import formats
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import (
|
||||
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
|
||||
@@ -42,9 +38,13 @@ from phonenumbers import SUPPORTED_REGIONS
|
||||
|
||||
from pretix.base.channels import get_all_sales_channels
|
||||
from pretix.base.forms.questions import guess_country
|
||||
from pretix.base.modelimport import (
|
||||
DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, SubeventColumnMixin,
|
||||
i18n_flat,
|
||||
)
|
||||
from pretix.base.models import (
|
||||
Customer, ItemVariation, OrderPosition, Question, QuestionAnswer,
|
||||
QuestionOption, Seat, SubEvent,
|
||||
QuestionOption, Seat,
|
||||
)
|
||||
from pretix.base.services.pricing import get_price
|
||||
from pretix.base.settings import (
|
||||
@@ -53,99 +53,6 @@ from pretix.base.settings import (
|
||||
from pretix.base.signals import order_import_columns
|
||||
|
||||
|
||||
class ImportColumn:
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
Unique, internal name of the column.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
"""
|
||||
Human-readable description of the column
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def initial(self):
|
||||
"""
|
||||
Initial value for the form component
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def default_value(self):
|
||||
"""
|
||||
Internal default value for the assignment of this column. Defaults to ``empty``. Return ``None`` to disable this
|
||||
option.
|
||||
"""
|
||||
return 'empty'
|
||||
|
||||
@property
|
||||
def default_label(self):
|
||||
"""
|
||||
Human-readable description of the default assignment of this column, defaults to "Keep empty".
|
||||
"""
|
||||
return gettext_lazy('Keep empty')
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
|
||||
def static_choices(self):
|
||||
"""
|
||||
This will be called when rendering the form component and allows you to return a list of values that can be
|
||||
selected by the user statically during import.
|
||||
|
||||
:return: list of 2-tuples of strings
|
||||
"""
|
||||
return []
|
||||
|
||||
def resolve(self, settings, record):
|
||||
"""
|
||||
This method will be called to get the raw value for this field, usually by either using a static value or
|
||||
inspecting the CSV file for the assigned header. You usually do not need to implement this on your own,
|
||||
the default should be fine.
|
||||
"""
|
||||
k = settings.get(self.identifier, self.default_value)
|
||||
if k == self.default_value:
|
||||
return None
|
||||
elif k.startswith('csv:'):
|
||||
return record.get(k[4:], None) or None
|
||||
elif k.startswith('static:'):
|
||||
return k[7:]
|
||||
raise ValidationError(_('Invalid setting for column "{header}".').format(header=self.verbose_name))
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
"""
|
||||
Allows you to validate the raw input value for your column. Raise ``ValidationError`` if the value is invalid.
|
||||
You do not need to include the column or row name or value in the error message as it will automatically be
|
||||
included.
|
||||
|
||||
:param value: Contains the raw value of your column as returned by ``resolve``. This can usually be ``None``,
|
||||
e.g. if the column is empty or does not exist in this row.
|
||||
:param previous_values: Dictionary containing the validated values of all columns that have already been validated.
|
||||
"""
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
"""
|
||||
This will be called to perform the actual import. You are supposed to set attributes on the ``order``, ``position``,
|
||||
or ``invoice_address`` objects based on the input ``value``. This is called *before* the actual database
|
||||
transaction, so these three objects do not yet have a primary key. If you want to create related objects, you
|
||||
need to place them into some sort of internal queue and persist them when ``save`` is called.
|
||||
"""
|
||||
pass
|
||||
|
||||
def save(self, order):
|
||||
"""
|
||||
This will be called to perform the actual import. This is called inside the actual database transaction and the
|
||||
input object ``order`` has already been saved to the database.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class EmailColumn(ImportColumn):
|
||||
identifier = 'email'
|
||||
verbose_name = gettext_lazy('E-mail address')
|
||||
@@ -182,74 +89,20 @@ class PhoneColumn(ImportColumn):
|
||||
order.phone = value
|
||||
|
||||
|
||||
class SubeventColumn(ImportColumn):
|
||||
class SubeventColumn(SubeventColumnMixin, ImportColumn):
|
||||
identifier = 'subevent'
|
||||
verbose_name = pgettext_lazy('subevents', 'Date')
|
||||
default_value = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._subevent_cache = {}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@cached_property
|
||||
def subevents(self):
|
||||
return list(self.event.subevents.filter(active=True).order_by('date_from'))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(p.pk), str(p)) for p in self.subevents
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
raise ValidationError(pgettext("subevent", "You need to select a date."))
|
||||
|
||||
if value in self._subevent_cache:
|
||||
return self._subevent_cache[value]
|
||||
|
||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||
for format in input_formats:
|
||||
try:
|
||||
d = datetime.datetime.strptime(value, format)
|
||||
d = d.replace(tzinfo=self.event.timezone)
|
||||
try:
|
||||
se = self.event.subevents.get(
|
||||
active=True,
|
||||
date_from__gt=d - datetime.timedelta(seconds=1),
|
||||
date_from__lt=d + datetime.timedelta(seconds=1),
|
||||
)
|
||||
self._subevent_cache[value] = se
|
||||
return se
|
||||
except SubEvent.DoesNotExist:
|
||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||
except SubEvent.MultipleObjectsReturned:
|
||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
matches = [
|
||||
p for p in self.subevents
|
||||
if str(p.pk) == value or any(
|
||||
(v and v == value) for v in i18n_flat(p.name)) or p.date_from.isoformat() == value
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(pgettext("subevent", "No matching date was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(pgettext("subevent", "Multiple matching dates were found."))
|
||||
|
||||
self._subevent_cache[value] = matches[0]
|
||||
return matches[0]
|
||||
return super().clean(value, previous_values)
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.subevent = value
|
||||
|
||||
|
||||
def i18n_flat(l):
|
||||
if isinstance(l.data, dict):
|
||||
return l.data.values()
|
||||
return [l.data]
|
||||
|
||||
|
||||
class ItemColumn(ImportColumn):
|
||||
identifier = 'item'
|
||||
verbose_name = gettext_lazy('Product')
|
||||
@@ -572,20 +425,11 @@ class AttendeeState(ImportColumn):
|
||||
position.state = value or ''
|
||||
|
||||
|
||||
class Price(ImportColumn):
|
||||
class Price(DecimalColumnMixin, ImportColumn):
|
||||
identifier = 'price'
|
||||
verbose_name = gettext_lazy('Price')
|
||||
default_label = gettext_lazy('Calculate from product')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value not in (None, ''):
|
||||
value = formats.sanitize_separators(re.sub(r'[^0-9.,-]', '', value))
|
||||
try:
|
||||
value = Decimal(value)
|
||||
except (DecimalException, TypeError):
|
||||
raise ValidationError(_('You entered an invalid number.'))
|
||||
return value
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
if value is None:
|
||||
p = get_price(position.item, position.variation, position.voucher, subevent=position.subevent,
|
||||
@@ -649,48 +493,18 @@ class Locale(ImportColumn):
|
||||
order.locale = value
|
||||
|
||||
|
||||
class ValidFrom(ImportColumn):
|
||||
class ValidFrom(DatetimeColumnMixin, ImportColumn):
|
||||
identifier = 'valid_from'
|
||||
verbose_name = gettext_lazy('Valid from')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
return
|
||||
|
||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||
for format in input_formats:
|
||||
try:
|
||||
d = datetime.datetime.strptime(value, format)
|
||||
d = d.replace(tzinfo=self.event.timezone)
|
||||
return d
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.valid_from = value
|
||||
|
||||
|
||||
class ValidUntil(ImportColumn):
|
||||
class ValidUntil(DatetimeColumnMixin, ImportColumn):
|
||||
identifier = 'valid_until'
|
||||
verbose_name = gettext_lazy('Valid until')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
return
|
||||
|
||||
input_formats = formats.get_format('DATETIME_INPUT_FORMATS', use_l10n=True)
|
||||
for format in input_formats:
|
||||
try:
|
||||
d = datetime.datetime.strptime(value, format)
|
||||
d = d.replace(tzinfo=self.event.timezone)
|
||||
return d
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
else:
|
||||
raise ValidationError(_("Could not parse {value} as a date and time.").format(value=value))
|
||||
|
||||
def assign(self, value, order, position, invoice_address, **kwargs):
|
||||
position.valid_until = value
|
||||
|
||||
@@ -849,7 +663,7 @@ class CustomerColumn(ImportColumn):
|
||||
order.customer = value
|
||||
|
||||
|
||||
def get_all_columns(event):
|
||||
def get_order_import_columns(event):
|
||||
default = []
|
||||
if event.has_subevents:
|
||||
default.append(SubeventColumn(event))
|
||||
378
src/pretix/base/modelimport_vouchers.py
Normal file
378
src/pretix/base/modelimport_vouchers.py
Normal file
@@ -0,0 +1,378 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-2021 rami.io GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from decimal import Decimal
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
|
||||
|
||||
from pretix.base.modelimport import (
|
||||
BooleanColumnMixin, DatetimeColumnMixin, DecimalColumnMixin, ImportColumn,
|
||||
IntegerColumnMixin, i18n_flat,
|
||||
)
|
||||
from pretix.base.models import ItemVariation, Quota, Seat, Voucher
|
||||
from pretix.base.signals import voucher_import_columns
|
||||
|
||||
|
||||
class CodeColumn(ImportColumn):
|
||||
identifier = 'code'
|
||||
verbose_name = gettext_lazy('Voucher code')
|
||||
default_value = None
|
||||
|
||||
def __init__(self, *args):
|
||||
self._cached = set()
|
||||
super().__init__(*args)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
MinLengthValidator(5)(value)
|
||||
if value and (value in self._cached or Voucher.objects.filter(event=self.event, code=value).exists()):
|
||||
raise ValidationError(_('A voucher with this code already exists.'))
|
||||
self._cached.add(value)
|
||||
return value
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.code = value
|
||||
|
||||
|
||||
class SubeventColumn(ImportColumn):
|
||||
identifier = 'subevent'
|
||||
verbose_name = pgettext_lazy('subevents', 'Date')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.subevent = value
|
||||
|
||||
|
||||
class MaxUsagesColumn(IntegerColumnMixin, ImportColumn):
|
||||
identifier = 'max_usages'
|
||||
verbose_name = gettext_lazy('Maximum usages')
|
||||
initial = "static:1"
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
("1", "1")
|
||||
]
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.max_usages = value if value is not None else 1
|
||||
|
||||
|
||||
class MinUsagesColumn(IntegerColumnMixin, ImportColumn):
|
||||
identifier = 'min_usages'
|
||||
verbose_name = gettext_lazy('Minimum usages')
|
||||
initial = "static:1"
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
("1", "1")
|
||||
]
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.min_usages = value if value is not None else 1
|
||||
|
||||
|
||||
class BudgetColumn(DecimalColumnMixin, ImportColumn):
|
||||
identifier = 'budget'
|
||||
verbose_name = gettext_lazy('Maximum discount budget')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.budget = value
|
||||
|
||||
|
||||
class ValidUntilColumn(DatetimeColumnMixin, ImportColumn):
|
||||
identifier = 'valid_until'
|
||||
verbose_name = gettext_lazy('Valid until')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.valid_until = value
|
||||
|
||||
|
||||
class BlockQuotaColumn(BooleanColumnMixin, ImportColumn):
|
||||
identifier = 'block_quota'
|
||||
verbose_name = gettext_lazy('Reserve ticket from quota')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.block_quota = value
|
||||
|
||||
|
||||
class AllowIgnoreQuotaColumn(BooleanColumnMixin, ImportColumn):
|
||||
identifier = 'allow_ignore_quota'
|
||||
verbose_name = gettext_lazy('Allow to bypass quota')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.allow_ignore_quota = value
|
||||
|
||||
|
||||
class PriceModeColumn(ImportColumn):
|
||||
identifier = 'price_mode'
|
||||
verbose_name = gettext_lazy('Price mode')
|
||||
default_value = None
|
||||
initial = 'static:none'
|
||||
|
||||
def static_choices(self):
|
||||
return Voucher.PRICE_MODES
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
d = dict(Voucher.PRICE_MODES)
|
||||
reverse = {v: k for k, v in Voucher.PRICE_MODES}
|
||||
if value in d:
|
||||
return value
|
||||
elif value in reverse:
|
||||
return reverse[value]
|
||||
else:
|
||||
raise ValidationError(_("Could not parse {value} as a price mode, use one of {options}.").format(
|
||||
value=value, options=', '.join(d.keys())
|
||||
))
|
||||
|
||||
def assign(self, value, voucher: Voucher, **kwargs):
|
||||
voucher.price_mode = value
|
||||
|
||||
|
||||
class ValueColumn(DecimalColumnMixin, ImportColumn):
|
||||
identifier = 'value'
|
||||
verbose_name = gettext_lazy('Voucher value')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
value = super().clean(value, previous_values)
|
||||
if value and previous_values.get("price_mode") == "none":
|
||||
raise ValidationError(_("It is pointless to set a value without a price mode."))
|
||||
return value
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.value = value or Decimal("0.00")
|
||||
|
||||
|
||||
class ItemColumn(ImportColumn):
|
||||
identifier = 'item'
|
||||
verbose_name = gettext_lazy('Product')
|
||||
|
||||
@cached_property
|
||||
def items(self):
|
||||
return list(self.event.items.filter(active=True))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(p.pk), str(p)) for p in self.items
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if not value:
|
||||
return
|
||||
matches = [
|
||||
p for p in self.items
|
||||
if str(p.pk) == value or (p.internal_name and p.internal_name == value) or any(
|
||||
(v and v == value) for v in i18n_flat(p.name))
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(_("No matching product was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(_("Multiple matching products were found."))
|
||||
return matches[0]
|
||||
|
||||
def assign(self, value, voucher, **kwargs):
|
||||
voucher.item = value
|
||||
|
||||
|
||||
class VariationColumn(ImportColumn):
|
||||
identifier = 'variation'
|
||||
verbose_name = gettext_lazy('Product variation')
|
||||
|
||||
@cached_property
|
||||
def items(self):
|
||||
return list(ItemVariation.objects.filter(
|
||||
active=True, item__active=True, item__event=self.event
|
||||
).select_related('item'))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(p.pk), '{} – {}'.format(p.item, p.value)) for p in self.items
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
matches = [
|
||||
p for p in self.items
|
||||
if (str(p.pk) == value or any((v and v == value) for v in i18n_flat(p.value))) and p.item_id == previous_values['item'].pk
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(_("No matching variation was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(_("Multiple matching variations were found."))
|
||||
return matches[0]
|
||||
return value
|
||||
|
||||
def assign(self, value, voucher: Voucher, **kwargs):
|
||||
voucher.variation = value
|
||||
|
||||
|
||||
class QuotaColumn(ImportColumn):
|
||||
identifier = 'quota'
|
||||
verbose_name = gettext_lazy('Quota')
|
||||
|
||||
@cached_property
|
||||
def quotas(self):
|
||||
return list(Quota.objects.filter(
|
||||
event=self.event
|
||||
))
|
||||
|
||||
def static_choices(self):
|
||||
return [
|
||||
(str(q.pk), q.name) for q in self.quotas
|
||||
]
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
if previous_values.get('item'):
|
||||
raise ValidationError(_("You cannot specify a quota if you specified a product."))
|
||||
matches = [
|
||||
q for q in self.quotas
|
||||
if str(q.pk) == value or any((v and v == value) for v in i18n_flat(q.name))
|
||||
]
|
||||
if len(matches) == 0:
|
||||
raise ValidationError(_("No matching variation was found."))
|
||||
if len(matches) > 1:
|
||||
raise ValidationError(_("Multiple matching variations were found."))
|
||||
|
||||
return matches[0]
|
||||
return value
|
||||
|
||||
def assign(self, value, voucher: Voucher, **kwargs):
|
||||
voucher.quota = value
|
||||
|
||||
|
||||
class SeatColumn(ImportColumn):
|
||||
identifier = 'seat'
|
||||
verbose_name = gettext_lazy('Seat ID')
|
||||
|
||||
def __init__(self, *args):
|
||||
self._cached = set()
|
||||
super().__init__(*args)
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
if self.event.has_subevents:
|
||||
if not previous_values.get('subevent'):
|
||||
raise ValidationError(_('You need to choose a date if you select a seat.'))
|
||||
|
||||
try:
|
||||
value = Seat.objects.get(
|
||||
event=self.event,
|
||||
seat_guid=value,
|
||||
subevent=previous_values.get('subevent')
|
||||
)
|
||||
except Seat.MultipleObjectsReturned:
|
||||
raise ValidationError(_('Multiple matching seats were found.'))
|
||||
except Seat.DoesNotExist:
|
||||
raise ValidationError(_('No matching seat was found.'))
|
||||
if not value.is_available() or value in self._cached:
|
||||
raise ValidationError(
|
||||
_('The seat you selected has already been taken. Please select a different seat.'))
|
||||
|
||||
if previous_values.get("quota"):
|
||||
raise ValidationError(_('You need to choose a specific product if you select a seat.'))
|
||||
|
||||
if previous_values.get('max_usages', 1) > 1 or previous_values.get('min_usages', 1) > 1:
|
||||
raise ValidationError(_('Seat-specific vouchers can only be used once.'))
|
||||
|
||||
if previous_values.get("item") and value.product != previous_values.get("item"):
|
||||
raise ValidationError(
|
||||
_('You need to choose the product "{prod}" for this seat.').format(prod=value.product)
|
||||
)
|
||||
|
||||
self._cached.add(value)
|
||||
return value
|
||||
|
||||
def assign(self, value, voucher: Voucher, **kwargs):
|
||||
voucher.seat = value
|
||||
|
||||
|
||||
class TagColumn(ImportColumn):
|
||||
identifier = 'tag'
|
||||
verbose_name = gettext_lazy('Tag')
|
||||
|
||||
def assign(self, value, voucher: Voucher, **kwargs):
|
||||
voucher.tag = value or ''
|
||||
|
||||
|
||||
class CommentColumn(ImportColumn):
|
||||
identifier = 'comment'
|
||||
verbose_name = gettext_lazy('Comment')
|
||||
|
||||
def assign(self, value, voucher: Voucher, **kwargs):
|
||||
voucher.comment = value or ''
|
||||
|
||||
|
||||
class ShowHiddenItemsColumn(BooleanColumnMixin, ImportColumn):
|
||||
identifier = 'show_hidden_items'
|
||||
verbose_name = gettext_lazy('Shows hidden products that match this voucher')
|
||||
initial = "static:true"
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.show_hidden_items = value
|
||||
|
||||
|
||||
class AllAddonsIncludedColumn(BooleanColumnMixin, ImportColumn):
|
||||
identifier = 'all_addons_included'
|
||||
verbose_name = gettext_lazy('Offer all add-on products for free when redeeming this voucher')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.all_addons_included = value
|
||||
|
||||
|
||||
class AllBundlesIncludedColumn(BooleanColumnMixin, ImportColumn):
|
||||
identifier = 'all_bundles_included'
|
||||
verbose_name = gettext_lazy('Include all bundled products without a designated price when redeeming this voucher')
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
obj.all_bundles_included = value
|
||||
|
||||
|
||||
def get_voucher_import_columns(event):
|
||||
default = []
|
||||
if event.has_subevents:
|
||||
default.append(SubeventColumn(event))
|
||||
default += [
|
||||
CodeColumn(event),
|
||||
MaxUsagesColumn(event),
|
||||
MinUsagesColumn(event),
|
||||
BudgetColumn(event),
|
||||
ValidUntilColumn(event),
|
||||
BlockQuotaColumn(event),
|
||||
AllowIgnoreQuotaColumn(event),
|
||||
PriceModeColumn(event),
|
||||
ValueColumn(event),
|
||||
ItemColumn(event),
|
||||
VariationColumn(event),
|
||||
QuotaColumn(event),
|
||||
SeatColumn(event),
|
||||
TagColumn(event),
|
||||
CommentColumn(event),
|
||||
ShowHiddenItemsColumn(event),
|
||||
AllAddonsIncludedColumn(event),
|
||||
AllBundlesIncludedColumn(event),
|
||||
]
|
||||
|
||||
for recv, resp in voucher_import_columns.send(sender=event):
|
||||
default += resp
|
||||
|
||||
return default
|
||||
@@ -56,6 +56,7 @@ from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
from pretix.base.i18n import language
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
from ...helpers.countries import FastCountryField
|
||||
from ...helpers.u2f import pub_key_from_der, websafe_decode
|
||||
from .base import LoggingMixin
|
||||
|
||||
@@ -579,6 +580,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
self.save(update_fields=['session_token'])
|
||||
|
||||
|
||||
class UserKnownLoginSource(models.Model):
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
|
||||
agent_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
device_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
os_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
country = FastCountryField(null=True, blank=True)
|
||||
last_seen = models.DateTimeField()
|
||||
|
||||
|
||||
class StaffSession(models.Model):
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
date_start = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -229,6 +229,14 @@ class EventMixin:
|
||||
else:
|
||||
return self.presale_end
|
||||
|
||||
@property
|
||||
def waiting_list_active(self):
|
||||
if not self.settings.waiting_list_enabled:
|
||||
return False
|
||||
if self.settings.waiting_list_auto_disable:
|
||||
return self.settings.waiting_list_auto_disable.datetime(self) > now()
|
||||
return True
|
||||
|
||||
@property
|
||||
def presale_has_ended(self):
|
||||
"""
|
||||
|
||||
@@ -122,7 +122,6 @@ class ReusableMedium(LoggedModel):
|
||||
class Meta:
|
||||
unique_together = (("identifier", "type", "organizer"),)
|
||||
indexes = [
|
||||
models.Index(fields=("identifier", "type", "organizer")),
|
||||
models.Index(fields=("updated", "id")),
|
||||
]
|
||||
ordering = "identifier", "type", "organizer"
|
||||
|
||||
@@ -49,7 +49,8 @@ class MembershipType(LoggedModel):
|
||||
allow_parallel_usage = models.BooleanField(
|
||||
verbose_name=_('Parallel usage is allowed'),
|
||||
help_text=_('If this is selected, the membership can be used to purchase tickets for events happening at the same time. Note '
|
||||
'that this will only check for an identical start time of the events, not for any overlap between events.'),
|
||||
'that this will only check for an identical start time of the events, not for any overlap between events. An overlap '
|
||||
'check will be performed if there is a product-level validity of the ticket.'),
|
||||
default=False
|
||||
)
|
||||
max_usages = models.PositiveIntegerField(
|
||||
@@ -162,8 +163,12 @@ class Membership(models.Model):
|
||||
def attendee_name(self):
|
||||
return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme)
|
||||
|
||||
def is_valid(self, ev=None):
|
||||
if ev:
|
||||
def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False):
|
||||
if valid_from_not_chosen:
|
||||
return not self.canceled and self.date_end >= now()
|
||||
elif ticket_valid_from:
|
||||
dt = ticket_valid_from
|
||||
elif ev:
|
||||
dt = ev.date_from
|
||||
else:
|
||||
dt = now()
|
||||
|
||||
@@ -188,6 +188,14 @@ class Order(LockModel, LoggedModel):
|
||||
default=False,
|
||||
)
|
||||
testmode = models.BooleanField(default=False)
|
||||
organizer = models.ForeignKey(
|
||||
# Redundant foreign key, but is required for a uniqueness constraint
|
||||
"Organizer",
|
||||
related_name="orders",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
event = models.ForeignKey(
|
||||
Event,
|
||||
verbose_name=_("Event"),
|
||||
@@ -286,6 +294,9 @@ class Order(LockModel, LoggedModel):
|
||||
models.Index(fields=["datetime", "id"]),
|
||||
models.Index(fields=["last_modified", "id"]),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.full_code
|
||||
@@ -451,9 +462,9 @@ class Order(LockModel, LoggedModel):
|
||||
if results:
|
||||
qs = qs.annotate(
|
||||
is_overpaid=Case(
|
||||
When(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=-1e-8),
|
||||
When(~Q(status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)) & Q(pending_sum_t__lt=-1e-8),
|
||||
then=Value(1)),
|
||||
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=-1e-8),
|
||||
When(Q(status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)) & Q(pending_sum_rc__lt=-1e-8),
|
||||
then=Value(1)),
|
||||
default=Value(0),
|
||||
output_field=models.IntegerField()
|
||||
@@ -468,7 +479,7 @@ class Order(LockModel, LoggedModel):
|
||||
is_underpaid=Case(
|
||||
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=1e-8),
|
||||
then=Value(1)),
|
||||
When(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__gt=1e-8),
|
||||
When(Q(status__in=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)) & Q(pending_sum_rc__gt=1e-8),
|
||||
then=Value(1)),
|
||||
default=Value(0),
|
||||
output_field=models.IntegerField()
|
||||
@@ -499,6 +510,10 @@ class Order(LockModel, LoggedModel):
|
||||
self.set_expires()
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'expires'}.union(kwargs['update_fields'])
|
||||
if not self.organizer_id:
|
||||
self.organizer_id = self.event.organizer_id
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'organizer'}.union(kwargs['update_fields'])
|
||||
|
||||
is_new = not self.pk
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
@@ -2356,6 +2371,14 @@ class OrderPosition(AbstractPosition):
|
||||
"""
|
||||
positionid = models.PositiveIntegerField(default=1)
|
||||
|
||||
organizer = models.ForeignKey(
|
||||
# Redundant foreign key, but is required for a uniqueness constraint
|
||||
"Organizer",
|
||||
related_name="order_positions",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
verbose_name=_("Order"),
|
||||
@@ -2429,6 +2452,9 @@ class OrderPosition(AbstractPosition):
|
||||
verbose_name = _("Order position")
|
||||
verbose_name_plural = _("Order positions")
|
||||
ordering = ("positionid", "id")
|
||||
constraints = [
|
||||
models.UniqueConstraint("organizer", "secret", name="orderposition_organizer_secret_uniq")
|
||||
]
|
||||
|
||||
@cached_property
|
||||
def sort_key(self):
|
||||
@@ -2498,7 +2524,8 @@ class OrderPosition(AbstractPosition):
|
||||
op = OrderPosition(order=order)
|
||||
for f in AbstractPosition._meta.fields:
|
||||
if f.name == 'addon_to':
|
||||
setattr(op, f.name, cp_mapping.get(cartpos.addon_to_id))
|
||||
if cartpos.addon_to_id:
|
||||
setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
|
||||
else:
|
||||
setattr(op, f.name, getattr(cartpos, f.name))
|
||||
op._calculate_tax()
|
||||
@@ -2518,6 +2545,9 @@ class OrderPosition(AbstractPosition):
|
||||
op.valid_from = valid_from
|
||||
op.valid_until = valid_until
|
||||
|
||||
if op.is_bundled and not op.addon_to_id:
|
||||
raise ValueError("Bundled cart position without parent does not make sense.")
|
||||
|
||||
op.positionid = i + 1
|
||||
op.save()
|
||||
ops.append(op)
|
||||
@@ -2552,10 +2582,10 @@ class OrderPosition(AbstractPosition):
|
||||
self.item.id, self.variation.id if self.variation else 0, self.order_id
|
||||
)
|
||||
|
||||
def _calculate_tax(self, tax_rule=None):
|
||||
def _calculate_tax(self, tax_rule=None, invoice_address=None):
|
||||
self.tax_rule = tax_rule or self.item.tax_rule
|
||||
try:
|
||||
ia = self.order.invoice_address
|
||||
ia = invoice_address or self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
if self.tax_rule:
|
||||
@@ -2585,6 +2615,10 @@ class OrderPosition(AbstractPosition):
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'secret'}.union(kwargs['update_fields'])
|
||||
|
||||
if not self.organizer_id:
|
||||
self.organizer_id = self.order.event.organizer_id
|
||||
if 'update_fields' in kwargs:
|
||||
kwargs['update_fields'] = {'organizer'}.union(kwargs['update_fields'])
|
||||
if not self.blocked and self.blocked is not None:
|
||||
self.blocked = None
|
||||
if 'update_fields' in kwargs:
|
||||
@@ -2932,6 +2966,14 @@ class CartPosition(AbstractPosition):
|
||||
self.item.id, self.variation.id if self.variation else 0, self.cart_id
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
# invalidate cached values of cached properties that likely have changed
|
||||
try:
|
||||
del self.sort_key
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def tax_value(self):
|
||||
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
|
||||
|
||||
@@ -263,6 +263,12 @@ class Team(LoggedModel):
|
||||
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members"))
|
||||
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
|
||||
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
|
||||
require_2fa = models.BooleanField(
|
||||
default=False, verbose_name=_("Require all members of this team to use two-factor authentication"),
|
||||
help_text=_("If you turn this on, all members of the team will be required to either set up two-factor "
|
||||
"authentication or leave the team. The setting may take a few minutes to become effective for "
|
||||
"all users.")
|
||||
)
|
||||
|
||||
can_create_events = models.BooleanField(
|
||||
default=False,
|
||||
|
||||
@@ -517,9 +517,6 @@ class Voucher(LoggedModel):
|
||||
if item and seat.product != item:
|
||||
raise ValidationError(_('You need to choose the product "{prod}" for this seat.').format(prod=seat.product))
|
||||
|
||||
if not seat.is_available(ignore_voucher_id=pk):
|
||||
raise ValidationError(_('The seat "{id}" is already sold or currently blocked.').format(id=seat.seat_guid))
|
||||
|
||||
return seat
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@@ -1311,9 +1311,7 @@ class GiftCardPayment(BasePaymentProvider):
|
||||
|
||||
@property
|
||||
def public_name(self) -> str:
|
||||
return str(self.settings.get("public_name", as_type=LazyI18nString)) or _(
|
||||
"Gift card"
|
||||
)
|
||||
return str(self.settings.get("public_name", as_type=LazyI18nString) or _("Gift card"))
|
||||
|
||||
@property
|
||||
def settings_form_fields(self):
|
||||
|
||||
@@ -78,7 +78,7 @@ from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.platypus import Paragraph
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import Order, OrderPosition, Question
|
||||
from pretix.base.models import Event, Order, OrderPosition, Question
|
||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
||||
from pretix.base.signals import layout_image_variables, layout_text_variables
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
@@ -738,9 +738,10 @@ class Renderer:
|
||||
else:
|
||||
self.bg_bytes = None
|
||||
self.bg_pdf = None
|
||||
self.event_fonts = list(get_fonts(event, pdf_support_required=True).keys()) + ['Open Sans']
|
||||
|
||||
@classmethod
|
||||
def _register_fonts(cls):
|
||||
def _register_fonts(cls, event: Event = None):
|
||||
if hasattr(cls, '_fonts_registered'):
|
||||
return
|
||||
pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
|
||||
@@ -748,7 +749,7 @@ class Renderer:
|
||||
pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')))
|
||||
|
||||
for family, styles in get_fonts().items():
|
||||
for family, styles in get_fonts(event, pdf_support_required=True).items():
|
||||
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
||||
if 'italic' in styles:
|
||||
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
|
||||
@@ -934,6 +935,13 @@ class Renderer:
|
||||
|
||||
def _draw_textarea(self, canvas: Canvas, op: OrderPosition, order: Order, o: dict):
|
||||
font = o['fontfamily']
|
||||
|
||||
# Since pdfmetrics.registerFont is global, we want to make sure that no one tries to sneak in a font, they
|
||||
# should not have access to.
|
||||
if font not in self.event_fonts:
|
||||
logger.warning(f'Unauthorized use of font "{font}"')
|
||||
font = 'Open Sans'
|
||||
|
||||
if o['bold']:
|
||||
font += ' B'
|
||||
if o['italic']:
|
||||
|
||||
@@ -203,7 +203,7 @@ error_messages = {
|
||||
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'min'
|
||||
),
|
||||
'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'addon_no_multi': gettext_lazy('You can select every add-on from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'addon_only': gettext_lazy('One of the products you selected can only be bought as an add-on to another product.'),
|
||||
'bundled_only': gettext_lazy('One of the products you selected can only be bought part of a bundle.'),
|
||||
'seat_required': gettext_lazy('You need to select a specific seat.'),
|
||||
@@ -1358,7 +1358,7 @@ class CartManager:
|
||||
return err
|
||||
|
||||
def recompute_final_prices_and_taxes(self):
|
||||
positions = sorted(list(self.positions), key=lambda op: -(op.addon_to_id or 0))
|
||||
positions = sorted(list(self.positions), key=lambda cp: (-(cp.addon_to_id or 0), cp.pk))
|
||||
diff = Decimal('0.00')
|
||||
for cp in positions:
|
||||
if cp.listed_price is None:
|
||||
|
||||
@@ -31,6 +31,7 @@ from pretix.base.models import CachedCombinedTicket, CachedTicket
|
||||
from pretix.base.models.customers import CustomerSSOGrant
|
||||
|
||||
from ..models import CachedFile, CartPosition, InvoiceAddress
|
||||
from ..models.auth import UserKnownLoginSource
|
||||
from ..signals import periodic_task
|
||||
|
||||
|
||||
@@ -75,3 +76,9 @@ def clearsessions(sender, **kwargs):
|
||||
@scopes_disabled()
|
||||
def clear_oidc_data(sender, **kwargs):
|
||||
CustomerSSOGrant.objects.filter(expires__lt=now() - timedelta(days=14)).delete()
|
||||
|
||||
|
||||
@receiver(signal=periodic_task)
|
||||
@scopes_disabled()
|
||||
def clear_old_login_sources(sender, **kwargs):
|
||||
UserKnownLoginSource.objects.filter(last_seen__lt=now() - timedelta(days=365)).delete()
|
||||
|
||||
@@ -29,8 +29,8 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.models import (
|
||||
AbstractPosition, Customer, Event, Item, Membership, Order, OrderPosition,
|
||||
SubEvent,
|
||||
AbstractPosition, CartPosition, Customer, Event, Item, Membership, Order,
|
||||
OrderPosition, SubEvent,
|
||||
)
|
||||
from pretix.helpers import OF_SELF
|
||||
|
||||
@@ -82,7 +82,8 @@ def create_membership(customer: Customer, position: OrderPosition):
|
||||
)
|
||||
|
||||
|
||||
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False):
|
||||
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False,
|
||||
valid_from_not_chosen=False):
|
||||
"""
|
||||
Validate that a set of cart or order positions. This currently does not validate
|
||||
|
||||
@@ -92,6 +93,8 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
||||
:param lock: Whether to place a SELECT FOR UPDATE lock on the selected memberships
|
||||
:param ignored_order: An order that should be ignored for usage counting
|
||||
:param testmode: If ``True``, only test mode memberships are allowed. If ``False``, test mode memberships are not allowed.
|
||||
:param valid_from_not_chosen: Set to ``True`` to indicate that the customer is in an early step of the checkout flow
|
||||
where the valid_from date is not selected yet. In this case, the valid_from date is not checked.
|
||||
"""
|
||||
tz = event.timezone
|
||||
applicable_positions = [
|
||||
@@ -132,7 +135,11 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
||||
qs = qs.exclude(order_id=ignored_order.pk)
|
||||
m._used_at_dates = [
|
||||
(op.subevent or op.order.event).date_from
|
||||
for op in qs
|
||||
for op in qs if not op.valid_from or not op.valid_until
|
||||
]
|
||||
m._used_for_ranges = [
|
||||
(op.valid_from, op.valid_until)
|
||||
for op in qs if op.valid_from or op.valid_until
|
||||
]
|
||||
|
||||
for p in applicable_positions:
|
||||
@@ -147,22 +154,44 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
||||
_('You selected membership that has been canceled.')
|
||||
)
|
||||
|
||||
if m.testmode != testmode:
|
||||
if m.testmode and not testmode:
|
||||
raise ValidationError(
|
||||
_('You can only use a test mode membership for test mode tickets.')
|
||||
_('You can not use a test mode membership for tickets that are not in test mode.')
|
||||
)
|
||||
elif not m.testmode and testmode:
|
||||
raise ValidationError(
|
||||
_('You need to add a test mode membership to the customer account to use it in test mode.')
|
||||
)
|
||||
|
||||
ev = p.subevent or event
|
||||
|
||||
if not m.is_valid(ev):
|
||||
raise ValidationError(
|
||||
_('You selected a membership that is valid from {start} to {end}, but selected an event '
|
||||
'taking place at {date}.').format(
|
||||
start=date_format(m.date_start.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||
end=date_format(m.date_end.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||
if isinstance(p, (OrderPosition, CartPosition)):
|
||||
# override_ variants are for usage of fake cart in OrderChangeManager
|
||||
valid_from = getattr(p, 'override_valid_from', p.valid_from)
|
||||
valid_until = getattr(p, 'override_valid_until', p.valid_until)
|
||||
else: # future safety, not technically defined on AbstractPosition
|
||||
valid_from = None
|
||||
valid_until = None
|
||||
|
||||
if not m.is_valid(ev, valid_from, valid_from_not_chosen=p.item.validity_dynamic_start_choice and valid_from_not_chosen):
|
||||
if valid_from:
|
||||
raise ValidationError(
|
||||
_('You selected a membership that is valid from {start} to {end}, but selected a ticket that '
|
||||
'starts to be valid on {date}.').format(
|
||||
start=date_format(m.date_start.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||
end=date_format(m.date_end.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||
date=date_format(valid_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValidationError(
|
||||
_('You selected a membership that is valid from {start} to {end}, but selected an event '
|
||||
'taking place at {date}.').format(
|
||||
start=date_format(m.date_start.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||
end=date_format(m.date_end.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if p.variation and p.variation.require_membership:
|
||||
types = p.variation.require_membership_types.all()
|
||||
@@ -188,13 +217,34 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
|
||||
m.usages += 1
|
||||
|
||||
if not m.membership_type.allow_parallel_usage:
|
||||
df = ev.date_from
|
||||
if any(abs(df - d) < timedelta(minutes=1) for d in m._used_at_dates):
|
||||
raise ValidationError(
|
||||
_('You are trying to use a membership of type "{type}" for an event taking place at {date}, '
|
||||
'however you already used the same membership for a different ticket at the same time.').format(
|
||||
type=m.membership_type.name,
|
||||
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||
if (valid_from or valid_until) and not (p.item.validity_dynamic_start_choice and valid_from_not_chosen):
|
||||
for used_range in m._used_for_ranges:
|
||||
if valid_from and valid_from > used_range[1]:
|
||||
continue
|
||||
if valid_until and valid_until < used_range[0]:
|
||||
continue
|
||||
raise ValidationError(
|
||||
_('You are trying to use a membership of type "{type}" for a ticket valid from {valid_from} '
|
||||
'until {valid_until}, however you already used the same membership for a different ticket '
|
||||
'that overlaps with this time frame ({conflict_from} – {conflict_until}).').format(
|
||||
type=m.membership_type.name,
|
||||
valid_from=date_format(valid_from.astimezone(tz), 'SHORT_DATETIME_FORMAT') if valid_from else _('start'),
|
||||
valid_until=date_format(valid_until.astimezone(tz), 'SHORT_DATETIME_FORMAT') if valid_until else _('open end'),
|
||||
conflict_from=date_format(used_range[0].astimezone(tz), 'SHORT_DATETIME_FORMAT') if used_range[0] else _('start'),
|
||||
conflict_until=date_format(used_range[1].astimezone(tz), 'SHORT_DATETIME_FORMAT') if used_range[1] else _('open end'),
|
||||
)
|
||||
)
|
||||
)
|
||||
m._used_at_dates.append(ev.date_from)
|
||||
|
||||
m._used_for_ranges.append((p.valid_from, p.valid_until))
|
||||
|
||||
if not valid_from or not valid_until:
|
||||
df = ev.date_from
|
||||
if any(abs(df - d) < timedelta(minutes=1) for d in m._used_at_dates):
|
||||
raise ValidationError(
|
||||
_('You are trying to use a membership of type "{type}" for an event taking place at {date}, '
|
||||
'however you already used the same membership for a different ticket at the same time.').format(
|
||||
type=m.membership_type.name,
|
||||
date=date_format(ev.date_from.astimezone(tz), 'SHORT_DATETIME_FORMAT'),
|
||||
)
|
||||
)
|
||||
m._used_at_dates.append(ev.date_from)
|
||||
|
||||
@@ -19,9 +19,8 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import csv
|
||||
import io
|
||||
from decimal import Decimal
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -29,13 +28,15 @@ from django.db import transaction
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from pretix.base.i18n import LazyLocaleException, language
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.modelimport import DataImportError, ImportColumn, parse_csv
|
||||
from pretix.base.modelimport_orders import get_order_import_columns
|
||||
from pretix.base.modelimport_vouchers import get_voucher_import_columns
|
||||
from pretix.base.models import (
|
||||
CachedFile, Event, InvoiceAddress, Order, OrderPayment, OrderPosition,
|
||||
User,
|
||||
User, Voucher,
|
||||
)
|
||||
from pretix.base.models.orders import Transaction
|
||||
from pretix.base.orderimport import get_all_columns
|
||||
from pretix.base.services.invoices import generate_invoice, invoice_qualified
|
||||
from pretix.base.services.locking import lock_objects
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
@@ -43,47 +44,36 @@ from pretix.base.signals import order_paid, order_placed
|
||||
from pretix.celery_app import app
|
||||
|
||||
|
||||
class DataImportError(LazyLocaleException):
|
||||
def __init__(self, *args):
|
||||
msg = args[0]
|
||||
msgargs = args[1] if len(args) > 1 else None
|
||||
self.args = args
|
||||
if msgargs:
|
||||
msg = _(msg) % msgargs
|
||||
else:
|
||||
msg = _(msg)
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
def parse_csv(file, length=None, mode="strict", charset=None):
|
||||
file.seek(0)
|
||||
data = file.read(length)
|
||||
if not charset:
|
||||
try:
|
||||
import chardet
|
||||
charset = chardet.detect(data)['encoding']
|
||||
except ImportError:
|
||||
charset = file.charset
|
||||
data = data.decode(charset or "utf-8", mode)
|
||||
# If the file was modified on a Mac, it only contains \r as line breaks
|
||||
if '\r' in data and '\n' not in data:
|
||||
data = data.replace('\r', '\n')
|
||||
|
||||
def _validate(cf: CachedFile, charset: str, cols: List[ImportColumn], settings: dict):
|
||||
try:
|
||||
dialect = csv.Sniffer().sniff(data.split("\n")[0], delimiters=";,.#:")
|
||||
except csv.Error:
|
||||
return None
|
||||
|
||||
if dialect is None:
|
||||
return None
|
||||
|
||||
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
|
||||
return reader
|
||||
|
||||
|
||||
def setif(record, obj, attr, setting):
|
||||
if setting.startswith('csv:'):
|
||||
setattr(obj, attr, record[setting[4:]] or '')
|
||||
parsed = parse_csv(cf.file, charset=charset)
|
||||
except UnicodeDecodeError as e:
|
||||
raise DataImportError(
|
||||
_(
|
||||
'Error decoding special characters in your file: {message}').format(
|
||||
message=str(e)
|
||||
)
|
||||
)
|
||||
data = []
|
||||
for i, record in enumerate(parsed):
|
||||
if not any(record.values()):
|
||||
continue
|
||||
values = {}
|
||||
for c in cols:
|
||||
val = c.resolve(settings, record)
|
||||
if isinstance(val, str):
|
||||
val = val.strip()
|
||||
try:
|
||||
values[c.identifier] = c.clean(val, values)
|
||||
except ValidationError as e:
|
||||
raise DataImportError(
|
||||
_(
|
||||
'Error while importing value "{value}" for column "{column}" in line "{line}": {message}').format(
|
||||
value=val if val is not None else '', column=c.verbose_name, line=i + 1, message=e.message
|
||||
)
|
||||
)
|
||||
data.append(values)
|
||||
return data
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, throws=(DataImportError,))
|
||||
@@ -91,45 +81,17 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
||||
cf = CachedFile.objects.get(id=fileid)
|
||||
user = User.objects.get(pk=user)
|
||||
with language(locale, event.settings.region):
|
||||
cols = get_all_columns(event)
|
||||
try:
|
||||
parsed = parse_csv(cf.file, charset=charset)
|
||||
except UnicodeDecodeError as e:
|
||||
raise DataImportError(
|
||||
_(
|
||||
'Error decoding special characters in your file: {message}').format(
|
||||
message=str(e)
|
||||
)
|
||||
)
|
||||
orders = []
|
||||
order = None
|
||||
data = []
|
||||
|
||||
# Run validation
|
||||
for i, record in enumerate(parsed):
|
||||
if not any(record.values()):
|
||||
continue
|
||||
values = {}
|
||||
for c in cols:
|
||||
val = c.resolve(settings, record)
|
||||
if isinstance(val, str):
|
||||
val = val.strip()
|
||||
try:
|
||||
values[c.identifier] = c.clean(val, values)
|
||||
except ValidationError as e:
|
||||
raise DataImportError(
|
||||
_(
|
||||
'Error while importing value "{value}" for column "{column}" in line "{line}": {message}').format(
|
||||
value=val if val is not None else '', column=c.verbose_name, line=i + 1, message=e.message
|
||||
)
|
||||
)
|
||||
data.append(values)
|
||||
cols = get_order_import_columns(event)
|
||||
data = _validate(cf, charset, cols, settings)
|
||||
|
||||
if settings['orders'] == 'one' and len(data) > django_settings.PRETIX_MAX_ORDER_SIZE:
|
||||
raise DataImportError(
|
||||
_('Orders cannot have more than %(max)s positions.') % {'max': django_settings.PRETIX_MAX_ORDER_SIZE}
|
||||
)
|
||||
|
||||
orders = []
|
||||
order = None
|
||||
|
||||
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
|
||||
# shorter. We'll see what works better in reality…
|
||||
lock_seats = []
|
||||
@@ -149,16 +111,16 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
||||
position = OrderPosition(positionid=len(order._positions) + 1)
|
||||
position.attendee_name_parts = {'_scheme': event.settings.name_scheme}
|
||||
position.meta_info = {}
|
||||
if position.seat is not None:
|
||||
lock_seats.append(position.seat)
|
||||
order._positions.append(position)
|
||||
position.assign_pseudonymization_id()
|
||||
|
||||
for c in cols:
|
||||
c.assign(record.get(c.identifier), order, position, order._address)
|
||||
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
if position.seat is not None:
|
||||
lock_seats.append(position.seat)
|
||||
except (ValidationError, ImportError) as e:
|
||||
raise DataImportError(
|
||||
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
|
||||
)
|
||||
|
||||
@@ -169,7 +131,7 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
||||
lock_objects(lock_seats, shared_lock_objects=[event])
|
||||
for s in lock_seats:
|
||||
if not s.is_available():
|
||||
raise ImportError(_('The seat you selected has already been taken. Please select a different seat.'))
|
||||
raise DataImportError(_('The seat you selected has already been taken. Please select a different seat.'))
|
||||
|
||||
save_transactions = []
|
||||
for o in orders:
|
||||
@@ -232,3 +194,62 @@ def import_orders(event: Event, fileid: str, settings: dict, locale: str, user,
|
||||
raise ValidationError(_('We were not able to process your request completely as the server was too busy. '
|
||||
'Please try again.'))
|
||||
cf.delete()
|
||||
|
||||
|
||||
@app.task(base=ProfiledEventTask, throws=(DataImportError,))
|
||||
def import_vouchers(event: Event, fileid: str, settings: dict, locale: str, user, charset=None) -> None:
|
||||
cf = CachedFile.objects.get(id=fileid)
|
||||
user = User.objects.get(pk=user)
|
||||
with language(locale, event.settings.region):
|
||||
cols = get_voucher_import_columns(event)
|
||||
data = _validate(cf, charset, cols, settings)
|
||||
|
||||
# Prepare model objects. Yes, this might consume lots of RAM, but allows us to make the actual SQL transaction
|
||||
# shorter. We'll see what works better in reality…
|
||||
vouchers = []
|
||||
lock_seats = []
|
||||
for i, record in enumerate(data):
|
||||
try:
|
||||
voucher = Voucher(event=event)
|
||||
vouchers.append(voucher)
|
||||
|
||||
Voucher.clean_item_properties(
|
||||
record,
|
||||
event,
|
||||
record.get('quota'),
|
||||
record.get('item'),
|
||||
record.get('variation'),
|
||||
block_quota=record.get('block_quota')
|
||||
)
|
||||
Voucher.clean_subevent(record, event)
|
||||
Voucher.clean_max_usages(record, 0)
|
||||
|
||||
for c in cols:
|
||||
c.assign(record.get(c.identifier), voucher)
|
||||
|
||||
if voucher.seat is not None:
|
||||
lock_seats.append(voucher.seat)
|
||||
except (ValidationError, ImportError) as e:
|
||||
raise DataImportError(
|
||||
_('Invalid data in row {row}: {message}').format(row=i, message=str(e))
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
# We don't support quotas here, so we only need to lock if seats are in use
|
||||
if lock_seats:
|
||||
lock_objects(lock_seats, shared_lock_objects=[event])
|
||||
for s in lock_seats:
|
||||
if not s.is_available():
|
||||
raise DataImportError(
|
||||
_('The seat you selected has already been taken. Please select a different seat.'))
|
||||
|
||||
for v in vouchers:
|
||||
v.save()
|
||||
v.log_action(
|
||||
'pretix.voucher.added',
|
||||
user=user,
|
||||
data={'source': 'import'}
|
||||
)
|
||||
for c in cols:
|
||||
c.save(v)
|
||||
cf.delete()
|
||||
@@ -197,7 +197,7 @@ error_messages = {
|
||||
'You need to select at least %(min)s add-ons from the category %(cat)s for the product %(base)s.',
|
||||
'min'
|
||||
),
|
||||
'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'addon_no_multi': gettext_lazy('You can select every add-on from the category %(cat)s for the product %(base)s at most once.'),
|
||||
'addon_already_checked_in': gettext_lazy('You cannot remove the position %(addon)s since it has already been checked in.'),
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
|
||||
is_available = order._is_still_available(now(), count_waitinglist=False, check_voucher_usage=True,
|
||||
check_memberships=True, lock=True, force=force)
|
||||
if is_available is True:
|
||||
if order.payment_refund_sum >= order.total:
|
||||
if order.payment_refund_sum >= order.total and not order.require_approval:
|
||||
order.status = Order.STATUS_PAID
|
||||
else:
|
||||
order.status = Order.STATUS_PENDING
|
||||
@@ -412,6 +412,11 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
|
||||
email_subject, email_template, email_context,
|
||||
'pretix.event.order.email.order_approved', user,
|
||||
attach_tickets=True,
|
||||
attach_ical=order.event.settings.mail_attach_ical and (
|
||||
not order.event.settings.mail_attach_ical_paid_only or
|
||||
order.total == Decimal('0.00') or
|
||||
order.valid_if_pending
|
||||
),
|
||||
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
|
||||
)
|
||||
except SendMailException:
|
||||
@@ -665,7 +670,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
deleted_positions.add(cp.pk)
|
||||
cp.delete()
|
||||
|
||||
sorted_positions = sorted(positions, key=lambda s: -int(s.is_bundled))
|
||||
sorted_positions = sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk))
|
||||
|
||||
for cp in sorted_positions:
|
||||
cp._cached_quotas = list(cp.quotas)
|
||||
@@ -876,6 +881,13 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
cp.discount = discount
|
||||
cp.save(update_fields=['price', 'discount'])
|
||||
|
||||
# After applying discounts, add-on positions might still have a reference to the *old* version of the
|
||||
# parent position, which can screw up ordering later since the system sees inconsistent data.
|
||||
by_id = {cp.pk: cp for cp in sorted_positions}
|
||||
for cp in sorted_positions:
|
||||
if cp.addon_to_id:
|
||||
cp.addon_to = by_id[cp.addon_to_id]
|
||||
|
||||
new_total = sum(cp.price for cp in sorted_positions)
|
||||
if old_total != new_total:
|
||||
err = err or error_messages['price_changed']
|
||||
@@ -884,7 +896,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
|
||||
for cp in sorted_positions:
|
||||
cp.expires = now_dt + timedelta(
|
||||
minutes=event.settings.get('reservation_time', as_type=int))
|
||||
cp.save()
|
||||
cp.save(update_fields=['expires'])
|
||||
|
||||
if err:
|
||||
raise OrderError(err)
|
||||
@@ -1045,7 +1057,11 @@ def _order_placed_email(event: Event, order: Order, email_template, subject_temp
|
||||
log_entry,
|
||||
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
|
||||
attach_tickets=True,
|
||||
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
|
||||
attach_ical=event.settings.mail_attach_ical and (
|
||||
not event.settings.mail_attach_ical_paid_only or
|
||||
is_free or
|
||||
order.valid_if_pending
|
||||
),
|
||||
attach_other_files=[a for a in [
|
||||
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||
] if a],
|
||||
@@ -1064,7 +1080,11 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
|
||||
log_entry,
|
||||
invoices=[],
|
||||
attach_tickets=True,
|
||||
attach_ical=event.settings.mail_attach_ical and (not event.settings.mail_attach_ical_paid_only or is_free),
|
||||
attach_ical=event.settings.mail_attach_ical and (
|
||||
not event.settings.mail_attach_ical_paid_only or
|
||||
is_free or
|
||||
order.valid_if_pending
|
||||
),
|
||||
attach_other_files=[a for a in [
|
||||
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
|
||||
] if a],
|
||||
@@ -1144,7 +1164,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
|
||||
positions = list(
|
||||
positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')
|
||||
)
|
||||
positions.sort(key=lambda k: position_ids.index(k.pk))
|
||||
positions.sort(key=lambda c: c.sort_key)
|
||||
if len(positions) == 0:
|
||||
raise OrderError(error_messages['empty'])
|
||||
if len(position_ids) != len(positions):
|
||||
@@ -2500,7 +2520,7 @@ class OrderChangeManager:
|
||||
|
||||
remaining_total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
|
||||
offset_amount = min(max(0, self.completed_payment_sum - remaining_total), split_order.total)
|
||||
if offset_amount >= split_order.total:
|
||||
if offset_amount >= split_order.total and not split_order.require_approval:
|
||||
split_order.status = Order.STATUS_PAID
|
||||
else:
|
||||
split_order.status = Order.STATUS_PENDING
|
||||
@@ -2671,6 +2691,7 @@ class OrderChangeManager:
|
||||
|
||||
for p in self.order.positions.all():
|
||||
cp = CartPosition(
|
||||
event=self.event,
|
||||
item=p.item,
|
||||
variation=p.variation,
|
||||
attendee_name_parts=p.attendee_name_parts,
|
||||
@@ -2691,16 +2712,23 @@ class OrderChangeManager:
|
||||
positions_to_fake_cart[op.position].seat = op.seat
|
||||
elif isinstance(op, self.MembershipOperation):
|
||||
positions_to_fake_cart[op.position].used_membership = op.membership
|
||||
elif isinstance(op, self.ChangeValidFromOperation):
|
||||
positions_to_fake_cart[op.position].override_valid_from = op.valid_from
|
||||
elif isinstance(op, self.ChangeValidUntilOperation):
|
||||
positions_to_fake_cart[op.position].override_valid_until = op.valid_until
|
||||
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
|
||||
fake_cart.remove(positions_to_fake_cart[op.position])
|
||||
elif isinstance(op, self.AddOperation):
|
||||
cp = CartPosition(
|
||||
event=self.event,
|
||||
item=op.item,
|
||||
variation=op.variation,
|
||||
used_membership=op.membership,
|
||||
subevent=op.subevent,
|
||||
seat=op.seat,
|
||||
)
|
||||
cp.override_valid_from = op.valid_from
|
||||
cp.override_valid_until = op.valid_until
|
||||
fake_cart.append(cp)
|
||||
try:
|
||||
validate_memberships_in_order(self.order.customer, fake_cart, self.event, lock=True, ignored_order=self.order, testmode=self.order.testmode)
|
||||
|
||||
@@ -514,7 +514,7 @@ def base_placeholders(sender, **kwargs):
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
"name_for_salutation", ["waiting_list_entry"],
|
||||
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
|
||||
_("Mr Doe"),
|
||||
lambda event: concatenation_for_salutation(name_scheme['sample']),
|
||||
))
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
"name", ["waiting_list_entry"],
|
||||
@@ -524,7 +524,7 @@ def base_placeholders(sender, **kwargs):
|
||||
ph.append(SimpleFunctionalTextPlaceholder(
|
||||
"name_for_salutation", ["position_or_address"],
|
||||
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
|
||||
_("Mr Doe"),
|
||||
lambda event: concatenation_for_salutation(name_scheme['sample']),
|
||||
))
|
||||
|
||||
for f, l, w in name_scheme['fields']:
|
||||
|
||||
@@ -446,6 +446,11 @@ class QuotaAvailability:
|
||||
self.results[q] = Quota.AVAILABILITY_RESERVED, 0
|
||||
|
||||
def _compute_waitinglist(self, quotas, q_items, q_vars, size_left):
|
||||
quotas = [
|
||||
q for q in quotas
|
||||
if not q.event.settings.waiting_list_auto_disable or q.event.settings.waiting_list_auto_disable.datetime(q.subevent or q.event) > now()
|
||||
]
|
||||
|
||||
events = {q.event_id for q in quotas}
|
||||
subevents = {q.subevent_id for q in quotas}
|
||||
quota_ids = {q.pk for q in quotas}
|
||||
|
||||
@@ -110,6 +110,9 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
||||
continue
|
||||
if wle.subevent and not wle.subevent.presale_is_running:
|
||||
continue
|
||||
if event.settings.waiting_list_auto_disable and event.settings.waiting_list_auto_disable.datetime(wle.subevent or event) <= now():
|
||||
gone.add((wle.item, wle.variation, wle.subevent))
|
||||
continue
|
||||
if not wle.item.is_available():
|
||||
gone.add((wle.item, wle.variation, wle.subevent))
|
||||
continue
|
||||
|
||||
@@ -89,7 +89,7 @@ def primary_font_kwargs():
|
||||
|
||||
choices = [('Open Sans', 'Open Sans')]
|
||||
choices += sorted([
|
||||
(a, {"title": a, "data": v}) for a, v in get_fonts().items() if not v.get('pdf_only', False)
|
||||
(a, {"title": a, "data": v}) for a, v in get_fonts(pdf_support_required=False).items()
|
||||
], key=lambda a: a[0])
|
||||
return {
|
||||
'choices': choices,
|
||||
@@ -1397,6 +1397,19 @@ DEFAULTS = {
|
||||
widget=forms.NumberInput(),
|
||||
)
|
||||
},
|
||||
'waiting_list_auto_disable': {
|
||||
'default': None,
|
||||
'type': RelativeDateWrapper,
|
||||
'form_class': RelativeDateTimeField,
|
||||
'serializer_class': SerializerRelativeDateTimeField,
|
||||
'form_kwargs': dict(
|
||||
label=_("Disable waiting list"),
|
||||
help_text=_("The waiting list will be fully disabled after this date. This means that nobody can add "
|
||||
"themselves to the waiting list any more, but also that tickets will be available for sale "
|
||||
"again if quota permits, even if there are still people on the waiting list. Vouchers that "
|
||||
"have already been sent remain active."),
|
||||
)
|
||||
},
|
||||
'waiting_list_names_asked': {
|
||||
'default': 'False',
|
||||
'type': bool,
|
||||
|
||||
@@ -785,6 +785,15 @@ to define additional columns that can be read during import. You are expected to
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
voucher_import_columns = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out if the user performs an import of vouchers from an external source. You can use this
|
||||
to define additional columns that can be read during import. You are expected to return a list of instances of
|
||||
``ImportColumn`` subclasses.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
validate_event_settings = EventPluginSignal()
|
||||
"""
|
||||
Arguments: ``settings_dict``
|
||||
|
||||
@@ -159,6 +159,18 @@ def timeline_for_event(event, subevent=None):
|
||||
})
|
||||
))
|
||||
|
||||
rd = event.settings.get('waiting_list_auto_disable', as_type=RelativeDateWrapper)
|
||||
if rd and event.settings.waiting_list_enabled:
|
||||
tl.append(TimelineEvent(
|
||||
event=event, subevent=subevent,
|
||||
datetime=rd.datetime(ev),
|
||||
description=pgettext_lazy('timeline', 'Waiting list is disabled'),
|
||||
edit_url=reverse('control:event.settings', kwargs={
|
||||
'event': event.slug,
|
||||
'organizer': event.organizer.slug
|
||||
}) + '#waiting-list-open'
|
||||
))
|
||||
|
||||
if not event.has_subevents:
|
||||
days = event.settings.get('mail_days_download_reminder', as_type=int)
|
||||
if days is not None and event.settings.ticket_download:
|
||||
|
||||
@@ -79,6 +79,7 @@ from pretix.helpers.countries import CachedCountries
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
from pretix.plugins.banktransfer.payment import BankTransfer
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
|
||||
class EventWizardFoundationForm(forms.Form):
|
||||
@@ -538,6 +539,7 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
|
||||
'region',
|
||||
'show_quota_left',
|
||||
'waiting_list_enabled',
|
||||
'waiting_list_auto_disable',
|
||||
'waiting_list_hours',
|
||||
'waiting_list_auto',
|
||||
'waiting_list_names_asked',
|
||||
@@ -651,6 +653,9 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
|
||||
del self.fields['event_list_available_only']
|
||||
del self.fields['event_list_filters']
|
||||
del self.fields['event_calendar_future_only']
|
||||
self.fields['primary_font'].choices += [
|
||||
(a, {"title": a, "data": v}) for a, v in get_fonts(self.event, pdf_support_required=False).items()
|
||||
]
|
||||
|
||||
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields
|
||||
self.virtual_keys = []
|
||||
@@ -931,6 +936,9 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
)
|
||||
)
|
||||
self.fields['invoice_generate'].choices = generate_choices
|
||||
self.fields['invoice_renderer_font'].choices += [
|
||||
(a, a) for a in get_fonts(event, pdf_support_required=True).keys()
|
||||
]
|
||||
|
||||
|
||||
def contains_web_channel_validate(val):
|
||||
|
||||
@@ -309,11 +309,8 @@ class OrderFilterForm(FilterForm):
|
||||
elif s in ('p', 'n', 'e', 'c', 'r'):
|
||||
qs = qs.filter(status=s)
|
||||
elif s == 'overpaid':
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=False, sums=True)
|
||||
qs = qs.filter(
|
||||
Q(~Q(status=Order.STATUS_CANCELED) & Q(pending_sum_t__lt=0))
|
||||
| Q(Q(status=Order.STATUS_CANCELED) & Q(pending_sum_rc__lt=0))
|
||||
)
|
||||
qs = Order.annotate_overpayments(qs, refunds=False, results=True, sums=False)
|
||||
qs = qs.filter(is_overpaid=True)
|
||||
elif s == 'rc':
|
||||
qs = qs.filter(
|
||||
cancellation_requests__isnull=False
|
||||
|
||||
@@ -22,10 +22,54 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.services.orderimport import get_all_columns
|
||||
from pretix.base.modelimport_orders import get_order_import_columns
|
||||
from pretix.base.modelimport_vouchers import get_voucher_import_columns
|
||||
|
||||
|
||||
class ProcessForm(forms.Form):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
headers = kwargs.pop('headers')
|
||||
initital = kwargs.pop('initial', {}) or {}
|
||||
kwargs['initial'] = initital
|
||||
columns = self.get_columns()
|
||||
column_keys = {c.identifier for c in columns}
|
||||
|
||||
if not initital or all(k not in column_keys for k in initital.keys()):
|
||||
for c in columns:
|
||||
initital.setdefault(c.identifier, c.initial)
|
||||
for h in headers:
|
||||
if h == c.identifier or h == str(c.verbose_name):
|
||||
initital[c.identifier] = 'csv:{}'.format(h)
|
||||
break
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
header_choices = [
|
||||
('csv:{}'.format(h), _('CSV column: "{name}"').format(name=h)) for h in headers
|
||||
]
|
||||
|
||||
for c in columns:
|
||||
choices = []
|
||||
if c.default_value:
|
||||
choices.append((c.default_value, c.default_label))
|
||||
choices += header_choices
|
||||
for k, v in c.static_choices():
|
||||
choices.append(('static:{}'.format(k), v))
|
||||
|
||||
self.fields[c.identifier] = forms.ChoiceField(
|
||||
label=str(c.verbose_name),
|
||||
choices=choices,
|
||||
widget=forms.Select(
|
||||
attrs={'data-static': 'true'}
|
||||
)
|
||||
)
|
||||
|
||||
def get_columns(self):
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
|
||||
class OrdersProcessForm(ProcessForm):
|
||||
orders = forms.ChoiceField(
|
||||
label=_('Import mode'),
|
||||
choices=(
|
||||
@@ -46,29 +90,21 @@ class ProcessForm(forms.Form):
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
headers = kwargs.pop('headers')
|
||||
initital = kwargs.pop('initial', {})
|
||||
self.event = kwargs.pop('event')
|
||||
initital = kwargs.pop('initial', {})
|
||||
initital['testmode'] = self.event.testmode
|
||||
kwargs['initial'] = initital
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
header_choices = [
|
||||
('csv:{}'.format(h), _('CSV column: "{name}"').format(name=h)) for h in headers
|
||||
]
|
||||
def get_columns(self):
|
||||
return get_order_import_columns(self.event)
|
||||
|
||||
for c in get_all_columns(self.event):
|
||||
choices = []
|
||||
if c.default_value:
|
||||
choices.append((c.default_value, c.default_label))
|
||||
choices += header_choices
|
||||
for k, v in c.static_choices():
|
||||
choices.append(('static:{}'.format(k), v))
|
||||
|
||||
self.fields[c.identifier] = forms.ChoiceField(
|
||||
label=str(c.verbose_name),
|
||||
choices=choices,
|
||||
widget=forms.Select(
|
||||
attrs={'data-static': 'true'}
|
||||
)
|
||||
)
|
||||
class VouchersProcessForm(ProcessForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_columns(self):
|
||||
return get_voucher_import_columns(self.event)
|
||||
@@ -257,7 +257,7 @@ class TeamForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
|
||||
fields = ['name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events',
|
||||
'can_change_teams', 'can_change_organizer_settings',
|
||||
'can_manage_gift_cards', 'can_manage_customers',
|
||||
'can_manage_reusable_media',
|
||||
|
||||
@@ -455,9 +455,12 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
|
||||
'pretix.event.export.schedule.executed': _('A scheduled export has been executed.'),
|
||||
'pretix.event.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
|
||||
'pretix.control.auth.user.created': _('The user has been created.'),
|
||||
'pretix.control.auth.user.new_source': _('A first login using {agent_type} on {os_type} from {country} has '
|
||||
'been detected.'),
|
||||
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
|
||||
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
|
||||
'pretix.user.settings.2fa.regenemergency': _('Your two-factor emergency codes have been regenerated.'),
|
||||
'pretix.user.settings.2fa.emergency': _('A two-factor emergency code has been generated.'),
|
||||
'pretix.user.settings.2fa.device.added': _('A new two-factor authentication device "{name}" has been added to '
|
||||
'your account.'),
|
||||
'pretix.user.settings.2fa.device.deleted': _('The two-factor authentication device "{name}" has been removed '
|
||||
|
||||
@@ -48,7 +48,8 @@ from pretix.base.models import Event, Organizer
|
||||
from pretix.base.models.auth import SuperuserPermissionSet, User
|
||||
from pretix.helpers.http import redirect_to_url
|
||||
from pretix.helpers.security import (
|
||||
SessionInvalid, SessionReauthRequired, assert_session_valid,
|
||||
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
|
||||
SessionReauthRequired, assert_session_valid,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +68,7 @@ class PermissionMiddleware:
|
||||
"auth.forgot.recover",
|
||||
"auth.invite",
|
||||
"user.settings.notifications.off",
|
||||
"auth.bad_origin_report",
|
||||
)
|
||||
|
||||
EXCEPTIONS_FORCED_PW_CHANGE = (
|
||||
@@ -83,6 +85,7 @@ class PermissionMiddleware:
|
||||
"user.settings.2fa.confirm.totp",
|
||||
"user.settings.2fa.confirm.webauthn",
|
||||
"user.settings.2fa.delete",
|
||||
"user.settings.2fa.leaveteams",
|
||||
"auth.logout",
|
||||
"user.reauth"
|
||||
)
|
||||
@@ -134,13 +137,12 @@ class PermissionMiddleware:
|
||||
except SessionReauthRequired:
|
||||
if url_name not in ('user.reauth', 'auth.logout'):
|
||||
return redirect_to_url(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
|
||||
|
||||
if request.user.needs_password_change and url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
|
||||
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
|
||||
|
||||
if not request.user.require_2fa and settings.PRETIX_OBLIGATORY_2FA \
|
||||
and url_name not in self.EXCEPTIONS_2FA and not request.user.needs_password_change:
|
||||
return redirect_to_url(reverse('control:user.settings.2fa'))
|
||||
except SessionPasswordChangeRequired:
|
||||
if url_name not in self.EXCEPTIONS_FORCED_PW_CHANGE:
|
||||
return redirect_to_url(reverse('control:user.settings') + '?next=' + quote(request.get_full_path()))
|
||||
except Session2FASetupRequired:
|
||||
if url_name not in self.EXCEPTIONS_2FA:
|
||||
return redirect_to_url(reverse('control:user.settings.2fa'))
|
||||
|
||||
if 'event' in url.kwargs and 'organizer' in url.kwargs:
|
||||
if url.kwargs['organizer'] == '-' and url.kwargs['event'] == '-':
|
||||
|
||||
@@ -58,5 +58,6 @@
|
||||
{{ poweredby }} {# removing or hiding this might be in violation of pretix' license #}
|
||||
</footer>
|
||||
</div>
|
||||
<script type="text/javascript" src="{% static "pretixcontrol/js/auth.js" %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -46,5 +46,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
<script type="text/plain" id="good_origin">{{ good_origin }}</script>
|
||||
<script type="text/plain" id="bad_origin_report_url">{{ bad_origin_report_url }}</script>
|
||||
<!-- pretix-login-marker -->{# marker required for ajax calls to detect that user session is over #}
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{% load i18n %}{% blocktrans with url=url|safe os=source.os_type agent=source.agent_type %}Hello,
|
||||
|
||||
a login to your {{ instance }} account from an unusual or new location was detected. The login was performed using {{ agent }} on {{ os }} from {{ country }}.
|
||||
|
||||
If this was you, you can safely ignore this email.
|
||||
|
||||
If this was not you, we recommend that you change your password in your account settings:
|
||||
|
||||
{{ url }}
|
||||
|
||||
Best regards,
|
||||
Your {{ instance }} team
|
||||
{% endblocktrans %}
|
||||
@@ -7,7 +7,7 @@
|
||||
{% block title %}{% trans "General settings" %}{% endblock %}
|
||||
{% block custom_header %}
|
||||
{{ block.super }}
|
||||
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
|
||||
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" organizer=request.organizer.slug event=request.event.slug %}">
|
||||
{% endblock %}
|
||||
{% block inside %}
|
||||
<h1>{% trans "General settings" %}</h1>
|
||||
@@ -335,7 +335,7 @@
|
||||
{% bootstrap_field sform.max_items_per_order layout="control" %}
|
||||
{% bootstrap_field sform.redirect_to_checkout_directly layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<fieldset id="waiting-list">
|
||||
<legend>{% trans "Waiting list" %}</legend>
|
||||
<div class="alert alert-info">
|
||||
{% blocktrans trimmed %}
|
||||
@@ -361,6 +361,7 @@
|
||||
{% bootstrap_field sform.waiting_list_enabled layout="control" %}
|
||||
{% bootstrap_field sform.waiting_list_auto layout="control" %}
|
||||
{% bootstrap_field sform.waiting_list_hours layout="control" %}
|
||||
{% bootstrap_field sform.waiting_list_auto_disable layout="control" %}
|
||||
{% bootstrap_field sform.waiting_list_names_asked_required layout="control" %}
|
||||
{% bootstrap_field sform.waiting_list_phones_asked_required layout="control" %}
|
||||
{% bootstrap_field sform.waiting_list_phones_explanation_text layout="control" %}
|
||||
|
||||
@@ -267,6 +267,7 @@
|
||||
{% bootstrap_field form.show_quota_left layout="control" %}
|
||||
{% for f in plugin_forms %}
|
||||
{% if not f.is_layouts and not f.title %}
|
||||
<hr />
|
||||
{% if f.template and not "template" in f.fields %}
|
||||
{% include f.template with form=f %}
|
||||
{% else %}
|
||||
|
||||
@@ -124,6 +124,13 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% elif order.status == "e" and order.payment_refund_sum != 0 %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed with amount=order.payment_refund_sum|money:request.event.currency %}
|
||||
This order is expired even though it received payments of {{ amount }}. You can choose to refund
|
||||
the money below or reactivate it by extending the payment deadline.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "General information" %}</legend>
|
||||
{% bootstrap_field form.name layout="control" %}
|
||||
{% bootstrap_field form.require_2fa layout="control" %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{% trans "Organizer permissions" %}</legend>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% compress css %}
|
||||
<link type="text/css" rel="stylesheet" href="{% static "pretixcontrol/scss/pdfeditor.css" %}">
|
||||
{% endcompress %}
|
||||
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
|
||||
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" organizer=request.organizer.slug event=request.event.slug %}">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<h1>
|
||||
@@ -183,6 +183,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row control-group pdf-info">
|
||||
<hr/>
|
||||
<div class="col-sm-6">
|
||||
<label>{% trans "Width (mm)" %}</label><br>
|
||||
<input type="number" id="pdf-info-width" class="input-block-level form-control">
|
||||
@@ -224,6 +225,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row control-group pdf-info">
|
||||
<hr/>
|
||||
<div class="col-sm-12">
|
||||
<label>{% trans "Preferred language" %}</label><br>
|
||||
<select class="form-control" id="pdf-info-locale">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% load static %}
|
||||
|
||||
@font-face {
|
||||
font-family: 'AND';
|
||||
font-style: normal;
|
||||
@@ -14,7 +15,7 @@
|
||||
|
||||
{% for family, styles in fonts.items %}
|
||||
{% for style, formats in styles.items %}
|
||||
{% if "sample" not in style %}
|
||||
{% if "sample" not in style and "pdf_only" not in style %}
|
||||
@font-face {
|
||||
font-family: '{{ family }}';
|
||||
{% if style == "italic" or style == "bolditalic" %}
|
||||
@@ -27,9 +28,9 @@
|
||||
{% else %}
|
||||
font-weight: normal;
|
||||
{% endif %}
|
||||
src: {% if "woff2" in formats %}url('{% static formats.woff2 %}') format('woff2'),{% endif %}
|
||||
{% if "woff" in formats %}url('{% static formats.woff %}') format('woff'),{% endif %}
|
||||
{% if "truetype" in formats %}url('{% static formats.truetype %}') format('truetype'){% endif %};
|
||||
src: {% if "woff2" in formats %}{% if '//' in formats.woff2 %}url('{{ formats.woff2 }}'){% else %}url('{% static formats.woff2 %}'){% endif %} format('woff2'),{% endif %}
|
||||
{% if "woff" in formats %}{% if '//' in formats.woff %}url('{{ formats.woff }}'){% else %}url('{% static formats.woff %}'){% endif %} format('woff'),{% endif %}
|
||||
{% if "truetype" in formats %}{% if '//' in formats.truetype %}url('{{ formats.truetype }}'){% else %}url('{% static formats.truetype %}'){% endif %} format('truetype'){% endif %};
|
||||
}
|
||||
.preload-font[data-family="{{family}}"][data-style="{{style}}"] {
|
||||
font-family: '{{ family }}', 'AND';
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends "pretixcontrol/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Two-factor authentication" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Leave teams that require two-factor authentication" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
<strong>{% trans "Do you really want to leave the following teams?" %}</strong>
|
||||
</p>
|
||||
<ul>
|
||||
{% for t in obligatory_teams %}
|
||||
<li>
|
||||
{% blocktrans trimmed with team=t.name organizer=t.organizer.name %}
|
||||
Team "{{ team }}" of organizer "{{ organizer }}"
|
||||
{% endblocktrans %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="form-group submit-group">
|
||||
<a href="{% url "control:user.settings.2fa" %}" class="btn btn-default btn-cancel">
|
||||
{% trans "Cancel" %}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger btn-save">
|
||||
{% trans "Leave" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -11,23 +11,55 @@
|
||||
smartphone or a hardware token generator and that changes on a regular basis.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% if settings.PRETIX_OBLIGATORY_2FA %}
|
||||
{% if obligatory and not user.require_2fa %}
|
||||
<div class="panel panel-warning">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Obligatory usage of two-factor authentication" %}</h3>
|
||||
<h3 class="panel-title">
|
||||
<span class="fa fa-warning"></span>
|
||||
{% trans "Obligatory usage of two-factor authentication" %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if obligatory == "system" %}
|
||||
<p>
|
||||
<strong>{% trans "This system enforces the usage of two-factor authentication!" %}</strong>
|
||||
</p>
|
||||
{% elif obligatory == "staff" %}
|
||||
<p>
|
||||
<strong>{% trans "As an administrator, you need to use two-factor authentication." %}</strong>
|
||||
</p>
|
||||
{% elif obligatory == "team" %}
|
||||
<p>
|
||||
<strong>{% trans "You are part of one or more organizer teams that require you to use two-factor authentication." %}</strong>
|
||||
</p>
|
||||
<ul>
|
||||
{% for t in obligatory_teams %}
|
||||
<li>
|
||||
{% blocktrans trimmed with team=t.name organizer=t.organizer.name %}
|
||||
Team "{{ team }}" of organizer "{{ organizer }}"
|
||||
{% endblocktrans %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<p>
|
||||
<strong>{% trans "This system enforces the usage of two-factor authentication!" %}</strong>
|
||||
{% if not devices %}
|
||||
{% trans "Please set up at least one device below." %}
|
||||
{% elif not user.require_2fa %}
|
||||
{% trans "Please activate two-factor authentication using the button below." %}
|
||||
{% endif %}
|
||||
{% if obligatory == "team" %}
|
||||
<a href="{% url "control:user.settings.2fa.leaveteams" %}">
|
||||
{% blocktrans trimmed count count=obligatory_teams|length %}
|
||||
Leave team instead
|
||||
{% plural %}
|
||||
Leave {{ count }} teams instead
|
||||
{% endblocktrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if not devices %}
|
||||
<p>{% trans "Please set up at least one device below." %}</p>
|
||||
{% elif not user.require_2fa %}
|
||||
<p>{% trans "Please activate two-factor authentication using the button below." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
{% if user.require_2fa %}
|
||||
<div class="panel panel-success">
|
||||
@@ -35,7 +67,18 @@
|
||||
<h3 class="panel-title">{% trans "Two-factor status" %}</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if not settings.PRETIX_OBLIGATORY_2FA %}
|
||||
{% if obligatory %}
|
||||
<button disabled class="btn btn-primary pull-right flip" data-toggle="tooltip"
|
||||
title="{% spaceless %}{% if obligatory == "system" %}
|
||||
{% trans "This system enforces the usage of two-factor authentication!" %}
|
||||
{% elif obligatory == "staff" %}
|
||||
{% trans "As an administrator, you need to use two-factor authentication." %}
|
||||
{% elif obligatory == "team" %}
|
||||
{% trans "You are part of one or more organizer teams that require you to use two-factor authentication." %}
|
||||
{% endif %}{% endspaceless %}">
|
||||
{% trans "Disable" %}
|
||||
</button>
|
||||
{% else %}
|
||||
<a href="{% url "control:user.settings.2fa.disable" %}" class="btn btn-primary pull-right flip">
|
||||
{% trans "Disable" %}
|
||||
</a>
|
||||
@@ -73,7 +116,7 @@
|
||||
{% for d in devices %}
|
||||
<li class="list-group-item">
|
||||
<a class="btn btn-danger btn-xs pull-right flip"
|
||||
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
|
||||
href="{% url "control:user.settings.2fa.delete" devicetype=d.devicetype device=d.pk %}">
|
||||
Delete
|
||||
</a>
|
||||
{% if d.devicetype == "totp" %}
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
<button class="btn btn-default">{% trans "Send password reset email" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if user.require_2fa %}
|
||||
<form action="{% url "control:users.emergencytoken" id=user.pk %}" method="post" class="form-inline helper-display-inline">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default">{% trans "Generate 2FA emergency token" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form action="{% url "control:users.impersonate" id=user.pk %}" method="post" class="form-inline helper-display-inline">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default">{% trans "Impersonate user" %}</button>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load getitem %}
|
||||
{% load bootstrap3 %}
|
||||
{% block title %}{% trans "Import vouchers" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Import vouchers" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal" data-asynctask data-asynctask-long>
|
||||
{% csrf_token %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Data preview" %}</h3>
|
||||
</div>
|
||||
<div class="table-responsive panel-body">
|
||||
<table class="table table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for fn in parsed.fieldnames %}
|
||||
<th>{{ fn }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in sample_rows %}
|
||||
<tr>
|
||||
{% for fn in parsed.fieldnames %}
|
||||
<td>{{ r|getitem:fn }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<td class="text-center" colspan="{{ parsed.fieldnames|length }}">
|
||||
…
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Import settings" %}</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% bootstrap_form_errors form %}
|
||||
{% bootstrap_form form layout="horizontal" %}
|
||||
<div class="alert alert-warning">
|
||||
{% blocktrans trimmed %}
|
||||
The import will be performed regardless of your quotas, so it will be possible to overbook your event using this option.
|
||||
{% endblocktrans %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Perform import" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% block title %}{% trans "Import vouchers" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Import vouchers" %}</h1>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">{% trans "Upload a new file" %}</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form action="" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
The uploaded file should be a CSV file with a header row. You will be able to assign the
|
||||
meanings of the different columns in the next step.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="file">{% trans "Import file" %}: </label> <input id="file" type="file" name="file"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="file">{% trans "Character set" %}: </label>
|
||||
<select name="charset" class="form-control">
|
||||
<option>{% trans "Detect automatically" %}</option>
|
||||
{% for e in encodings %}
|
||||
<option value="{{ e }}">{{ e }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<button class="btn btn-primary pull-right flip" type="submit">
|
||||
<span class="icon icon-upload"></span> {% trans "Start import" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -77,6 +77,8 @@
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new voucher" %}</a>
|
||||
<a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create multiple new vouchers" %}</a>
|
||||
<a href="{% url "control:event.vouchers.import" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-default btn-lg"><i class="fa fa-upload"></i> {% trans "Import vouchers" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
@@ -87,6 +89,9 @@
|
||||
<a href="{% url "control:event.vouchers.bulk" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-default"><i class="fa fa-plus"></i>
|
||||
{% trans "Create multiple new vouchers" %}</a>
|
||||
<a href="{% url "control:event.vouchers.import" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-default"><i class="fa fa-upload"></i>
|
||||
{% trans "Import vouchers" %}</a>
|
||||
{% endif %}
|
||||
<a href="?{% url_replace request "download" "yes" %}"
|
||||
class="btn btn-default"><i class="fa fa-download"></i>
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
<div class="alert alert-danger">
|
||||
{% trans "The waiting list is disabled, so if the event is sold out, people cannot add themselves to this list. If you want to enable it, go to the event settings." %}
|
||||
</div>
|
||||
{% elif not request.event.waiting_list_active and not request.event.has_subevents %}
|
||||
<div class="alert alert-danger">
|
||||
{% trans "The waiting list is no longer active for this event. The waiting list no longer affects quotas and no longer notifies waiting users." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
{% if 'can_change_orders' in request.eventpermset %}
|
||||
|
||||
@@ -38,13 +38,14 @@ from django.views.generic.base import RedirectView
|
||||
|
||||
from pretix.control.views import (
|
||||
auth, checkin, dashboards, discounts, event, geo, global_settings, item,
|
||||
main, oauth, orderimport, orders, organizer, pdf, search, shredder,
|
||||
main, modelimport, oauth, orders, organizer, pdf, search, shredder,
|
||||
subevents, typeahead, user, users, vouchers, waitinglist,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^logout$', auth.logout, name='auth.logout'),
|
||||
re_path(r'^login$', auth.login, name='auth.login'),
|
||||
re_path(r'^login/bad_origin$', auth.bad_origin_report, name='auth.bad_origin_report'),
|
||||
re_path(r'^login/2fa$', auth.Login2FAView.as_view(), name='auth.login.2fa'),
|
||||
re_path(r'^register$', auth.register, name='auth.register'),
|
||||
re_path(r'^invite/(?P<token>[a-zA-Z0-9]+)$', auth.invite, name='auth.invite'),
|
||||
@@ -71,8 +72,9 @@ urlpatterns = [
|
||||
re_path(r'^users/impersonate/stop', users.UserImpersonateStopView.as_view(), name='users.impersonate.stop'),
|
||||
re_path(r'^users/(?P<id>\d+)/$', users.UserEditView.as_view(), name='users.edit'),
|
||||
re_path(r'^users/(?P<id>\d+)/reset$', users.UserResetView.as_view(), name='users.reset'),
|
||||
re_path(r'^users/(?P<id>\d+)/impersonate', users.UserImpersonateView.as_view(), name='users.impersonate'),
|
||||
re_path(r'^users/(?P<id>\d+)/anonymize', users.UserAnonymizeView.as_view(), name='users.anonymize'),
|
||||
re_path(r'^users/(?P<id>\d+)/impersonate$', users.UserImpersonateView.as_view(), name='users.impersonate'),
|
||||
re_path(r'^users/(?P<id>\d+)/anonymize$', users.UserAnonymizeView.as_view(), name='users.anonymize'),
|
||||
re_path(r'^users/(?P<id>\d+)/emergencytoken$', users.UserEmergencyTokenView.as_view(), name='users.emergencytoken'),
|
||||
re_path(r'^pdf/editor/webfonts.css', pdf.FontsCSSView.as_view(), name='pdf.css'),
|
||||
re_path(r'^settings/?$', user.UserSettings.as_view(), name='user.settings'),
|
||||
re_path(r'^settings/history/$', user.UserHistoryView.as_view(), name='user.settings.history'),
|
||||
@@ -94,6 +96,7 @@ urlpatterns = [
|
||||
re_path(r'^settings/oauth/apps/(?P<pk>\d+)/roll$', oauth.OAuthApplicationRollView.as_view(),
|
||||
name='user.settings.oauth.app.roll'),
|
||||
re_path(r'^settings/2fa/$', user.User2FAMainView.as_view(), name='user.settings.2fa'),
|
||||
re_path(r'^settings/2fa/leaveteams$', user.User2FALeaveTeamsView.as_view(), name='user.settings.2fa.leaveteams'),
|
||||
re_path(r'^settings/2fa/add$', user.User2FADeviceAddView.as_view(), name='user.settings.2fa.add'),
|
||||
re_path(r'^settings/2fa/enable', user.User2FAEnableView.as_view(), name='user.settings.2fa.enable'),
|
||||
re_path(r'^settings/2fa/disable', user.User2FADisableView.as_view(), name='user.settings.2fa.disable'),
|
||||
@@ -346,6 +349,8 @@ urlpatterns = [
|
||||
re_path(r'^vouchers/bulk_add$', vouchers.VoucherBulkCreate.as_view(), name='event.vouchers.bulk'),
|
||||
re_path(r'^vouchers/bulk_add/mail_preview$', vouchers.VoucherBulkMailPreview.as_view(), name='event.vouchers.bulk.mail_preview'),
|
||||
re_path(r'^vouchers/bulk_action$', vouchers.VoucherBulkAction.as_view(), name='event.vouchers.bulkaction'),
|
||||
re_path(r'^vouchers/import/$', modelimport.VoucherImportView.as_view(), name='event.vouchers.import'),
|
||||
re_path(r'^vouchers/import/(?P<file>[^/]+)/$', modelimport.VoucherProcessView.as_view(), name='event.vouchers.import.process'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transition$', orders.OrderTransition.as_view(),
|
||||
name='event.order.transition'),
|
||||
re_path(r'^orders/(?P<code>[0-9A-Z]+)/resend$', orders.OrderResendLink.as_view(),
|
||||
@@ -412,8 +417,8 @@ urlpatterns = [
|
||||
re_path(r'^invoice/(?P<invoice>[^/]+)$', orders.InvoiceDownload.as_view(),
|
||||
name='event.invoice.download'),
|
||||
re_path(r'^orders/overview/$', orders.OverView.as_view(), name='event.orders.overview'),
|
||||
re_path(r'^orders/import/$', orderimport.ImportView.as_view(), name='event.orders.import'),
|
||||
re_path(r'^orders/import/(?P<file>[^/]+)/$', orderimport.ProcessView.as_view(), name='event.orders.import.process'),
|
||||
re_path(r'^orders/import/$', modelimport.OrderImportView.as_view(), name='event.orders.import'),
|
||||
re_path(r'^orders/import/(?P<file>[^/]+)/$', modelimport.OrderProcessView.as_view(), name='event.orders.import.process'),
|
||||
re_path(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
|
||||
re_path(r'^orders/export/(?P<pk>[^/]+)/run$', orders.RunScheduledExportView.as_view(), name='event.orders.export.scheduled.run'),
|
||||
re_path(r'^orders/export/(?P<pk>[^/]+)/delete$', orders.DeleteScheduledExportView.as_view(), name='event.orders.export.scheduled.delete'),
|
||||
|
||||
@@ -36,7 +36,7 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from urllib.parse import quote, urlparse
|
||||
from urllib.parse import quote, urljoin, urlparse
|
||||
|
||||
import webauthn
|
||||
from django.conf import settings
|
||||
@@ -47,11 +47,14 @@ from django.contrib.auth import (
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.generic import TemplateView
|
||||
from django_otp import match_token
|
||||
from webauthn.helpers import generate_challenge
|
||||
@@ -60,9 +63,11 @@ from pretix.base.auth import get_auth_backends
|
||||
from pretix.base.forms.auth import (
|
||||
LoginForm, PasswordForgotForm, PasswordRecoverForm, RegistrationForm,
|
||||
)
|
||||
from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins
|
||||
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.helpers.http import redirect_to_url
|
||||
from pretix.helpers.http import get_client_ip, redirect_to_url
|
||||
from pretix.helpers.security import handle_login_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -77,6 +82,7 @@ def process_login(request, user, keep_logged_in):
|
||||
request.session['pretix_auth_long_session'] = settings.PRETIX_LONG_SESSIONS and keep_logged_in
|
||||
next_url = get_auth_backends()[user.auth_backend].get_next_url(request)
|
||||
if user.require_2fa:
|
||||
logger.info(f"Backend login redirected to 2FA for user {user.pk}.")
|
||||
request.session['pretix_auth_2fa_user'] = user.pk
|
||||
request.session['pretix_auth_2fa_time'] = str(int(time.time()))
|
||||
twofa_url = reverse('control:auth.login.2fa')
|
||||
@@ -84,6 +90,9 @@ def process_login(request, user, keep_logged_in):
|
||||
twofa_url += '?next=' + quote(next_url)
|
||||
return redirect_to_url(twofa_url)
|
||||
else:
|
||||
logger.info(f"Backend login successful for user {user.pk}.")
|
||||
pretix_successful_logins.inc(1)
|
||||
handle_login_source(user, request)
|
||||
auth_login(request, user)
|
||||
request.session['pretix_auth_login_time'] = int(time.time())
|
||||
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts=None):
|
||||
@@ -105,6 +114,9 @@ def login(request):
|
||||
return process_login(request, u, False)
|
||||
b.url = b.authentication_url(request)
|
||||
|
||||
# Login should only happen on configured main domain
|
||||
good_origin = urlparse(settings.SITE_URL).scheme + '://' + urlparse(settings.SITE_URL).hostname
|
||||
|
||||
backend = backenddict.get(request.GET.get('backend', 'native'), backends[0])
|
||||
if not backend.visible:
|
||||
backend = [b for b in backends if b.visible][0]
|
||||
@@ -115,7 +127,23 @@ def login(request):
|
||||
return redirect(reverse('control:index'))
|
||||
if request.method == 'POST':
|
||||
form = LoginForm(backend=backend, data=request.POST, request=request)
|
||||
if form.is_valid() and form.user_cache and form.user_cache.auth_backend == backend.identifier:
|
||||
is_valid = form.is_valid() and form.user_cache and form.user_cache.auth_backend == backend.identifier
|
||||
|
||||
if form.cleaned_data.get("origin"):
|
||||
form_origin = form.cleaned_data.get("origin")
|
||||
if good_origin != form_origin:
|
||||
logger.warning(
|
||||
f"Received login form submission with unexpected origin value. "
|
||||
f"Origin sent from JavaScript: {form_origin} / "
|
||||
f"Expected origin from configuration: {good_origin} / "
|
||||
f"HTTP Host header: {request.headers.get('Host')} / "
|
||||
f"HTTP origin header: {request.headers.get('Origin')} / "
|
||||
f"HTTP referer header: {request.headers.get('Referer')} / "
|
||||
f"IP address: {get_client_ip(request)} / "
|
||||
f"Login result: {is_valid}"
|
||||
)
|
||||
|
||||
if is_valid:
|
||||
return process_login(request, form.user_cache, form.cleaned_data.get('keep_logged_in', False))
|
||||
else:
|
||||
form = LoginForm(backend=backend, request=request)
|
||||
@@ -124,9 +152,35 @@ def login(request):
|
||||
ctx['can_reset'] = settings.PRETIX_PASSWORD_RESET
|
||||
ctx['backends'] = backends
|
||||
ctx['backend'] = backend
|
||||
ctx['good_origin'] = good_origin[::-1] # minimal obfuscation against standard link rewriting
|
||||
ctx['bad_origin_report_url'] = urljoin(
|
||||
# as an additional safeguard always use SITE_URL, not anything derived from request
|
||||
settings.SITE_URL,
|
||||
reverse('control:auth.bad_origin_report')
|
||||
)[::-1]
|
||||
return render(request, 'pretixcontrol/auth/login.html', ctx)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["POST"])
|
||||
def bad_origin_report(request):
|
||||
good_origin = urlparse(settings.SITE_URL).scheme + '://' + urlparse(settings.SITE_URL).hostname
|
||||
form_origin = request.POST.get("origin")
|
||||
if good_origin != form_origin:
|
||||
logger.warning(
|
||||
f"Received report of unexpected origin value. "
|
||||
f"Origin sent from JavaScript: {form_origin} / "
|
||||
f"Expected origin from configuration: {good_origin} / "
|
||||
f"HTTP Host header: {request.headers.get('Host')} / "
|
||||
f"HTTP origin header: {request.headers.get('Origin')} / "
|
||||
f"HTTP referer header: {request.headers.get('Referer')} / "
|
||||
f"IP address: {get_client_ip(request)}"
|
||||
)
|
||||
resp = HttpResponse()
|
||||
resp['Access-Control-Allow-Origin'] = '*'
|
||||
return resp
|
||||
|
||||
|
||||
def logout(request):
|
||||
"""
|
||||
Log the user out of the current session, then redirect to login page.
|
||||
@@ -284,7 +338,7 @@ class Forgot(TemplateView):
|
||||
rc.setex('pretix_pwreset_%s' % (user.id), 3600 * 24, '1')
|
||||
|
||||
except User.DoesNotExist:
|
||||
logger.warning('Password reset for unregistered e-mail \"' + email + '\" requested.')
|
||||
logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.')
|
||||
|
||||
except SendMailException:
|
||||
logger.exception('Sending password reset e-mail to \"' + email + '\" failed.')
|
||||
@@ -411,6 +465,7 @@ class Login2FAView(TemplateView):
|
||||
fail = True
|
||||
logintime = int(request.session.get('pretix_auth_2fa_time', '1'))
|
||||
if time.time() - logintime > 300:
|
||||
pretix_failed_logins.inc(1, reason="2fa-timeout")
|
||||
fail = True
|
||||
if fail:
|
||||
messages.error(request, _('Please try again.'))
|
||||
@@ -443,6 +498,7 @@ class Login2FAView(TemplateView):
|
||||
)
|
||||
sign_count = webauthn_assertion_response.new_sign_count
|
||||
if sign_count < credential_current_sign_count:
|
||||
pretix_failed_logins.inc(1, reason="webauthn-replay")
|
||||
raise Exception("Possible replay attack, sign count not higher")
|
||||
except Exception:
|
||||
if isinstance(d, U2FDevice):
|
||||
@@ -460,11 +516,13 @@ class Login2FAView(TemplateView):
|
||||
if webauthn_assertion_response.new_sign_count < 1:
|
||||
raise Exception("Possible replay attack, sign count set")
|
||||
except Exception:
|
||||
pretix_failed_logins.inc(1, reason="u2f")
|
||||
logger.exception('U2F login failed')
|
||||
else:
|
||||
valid = True
|
||||
break
|
||||
else:
|
||||
pretix_failed_logins.inc(1, reason="webauthn")
|
||||
logger.exception('Webauthn login failed')
|
||||
else:
|
||||
if isinstance(d, WebAuthnDevice):
|
||||
@@ -476,6 +534,9 @@ class Login2FAView(TemplateView):
|
||||
valid = match_token(self.user, token)
|
||||
|
||||
if valid:
|
||||
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
|
||||
pretix_successful_logins.inc(1)
|
||||
handle_login_source(self.user, request)
|
||||
auth_login(request, self.user)
|
||||
request.session['pretix_auth_login_time'] = int(time.time())
|
||||
del request.session['pretix_auth_2fa_user']
|
||||
@@ -484,6 +545,7 @@ class Login2FAView(TemplateView):
|
||||
return redirect_to_url(request.GET.get("next"))
|
||||
return redirect('control:index')
|
||||
else:
|
||||
pretix_failed_logins.inc(1, reason="2fa")
|
||||
messages.error(request, _('Invalid code, please try again.'))
|
||||
return redirect('control:auth.login.2fa')
|
||||
|
||||
|
||||
@@ -46,9 +46,13 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import FormView, TemplateView
|
||||
|
||||
from pretix.base.models import CachedFile
|
||||
from pretix.base.services.orderimport import import_orders, parse_csv
|
||||
from pretix.base.services.modelimport import (
|
||||
import_orders, import_vouchers, parse_csv,
|
||||
)
|
||||
from pretix.base.views.tasks import AsyncAction
|
||||
from pretix.control.forms.orderimport import ProcessForm
|
||||
from pretix.control.forms.modelimport import (
|
||||
OrdersProcessForm, VouchersProcessForm,
|
||||
)
|
||||
from pretix.control.permissions import EventPermissionRequiredMixin
|
||||
from pretix.helpers.http import redirect_to_url
|
||||
|
||||
@@ -64,28 +68,16 @@ ENCODINGS = (
|
||||
)
|
||||
|
||||
|
||||
class ImportView(EventPermissionRequiredMixin, TemplateView):
|
||||
template_name = 'pretixcontrol/orders/import_start.html'
|
||||
permission = 'can_change_orders'
|
||||
|
||||
class BaseImportView(TemplateView):
|
||||
def post(self, request, *args, **kwargs):
|
||||
if 'file' not in request.FILES:
|
||||
return redirect_to_url(reverse('control:event.orders.import', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.organizer.slug,
|
||||
}))
|
||||
return redirect_to_url(request.path)
|
||||
if not request.FILES['file'].name.lower().endswith('.csv'):
|
||||
messages.error(request, _('Please only upload CSV files.'))
|
||||
return redirect_to_url(reverse('control:event.orders.import', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.organizer.slug,
|
||||
}))
|
||||
return redirect_to_url(request.path)
|
||||
if request.FILES['file'].size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||
messages.error(request, _('Please do not upload files larger than 10 MB.'))
|
||||
return redirect_to_url(reverse('control:event.orders.import', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.organizer.slug,
|
||||
}))
|
||||
return redirect_to_url(request.path)
|
||||
|
||||
cf = CachedFile.objects.create(
|
||||
expires=now() + timedelta(days=1),
|
||||
@@ -100,41 +92,47 @@ class ImportView(EventPermissionRequiredMixin, TemplateView):
|
||||
else:
|
||||
charset = "auto"
|
||||
|
||||
return redirect(reverse('control:event.orders.import.process', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.organizer.slug,
|
||||
'file': cf.id
|
||||
}) + "?charset=" + charset)
|
||||
return redirect(self.get_process_url(request, cf, charset))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return super().get_context_data(encodings=ENCODINGS)
|
||||
|
||||
def get_process_url(self, request, cf, charset):
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
class ProcessView(EventPermissionRequiredMixin, AsyncAction, FormView):
|
||||
permission = 'can_change_orders'
|
||||
template_name = 'pretixcontrol/orders/import_process.html'
|
||||
form_class = ProcessForm
|
||||
task = import_orders
|
||||
|
||||
class BaseProcessView(AsyncAction, FormView):
|
||||
known_errortypes = ['DataImportError']
|
||||
|
||||
@property
|
||||
def settings_key(self):
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
@property
|
||||
def settings_holder(self):
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
def get_form_kwargs(self):
|
||||
k = super().get_form_kwargs()
|
||||
k.update({
|
||||
'event': self.request.event,
|
||||
'initial': self.request.event.settings.order_import_settings,
|
||||
'initial': self.settings_holder.settings.get(self.settings_key, as_type=dict),
|
||||
'headers': self.parsed.fieldnames
|
||||
})
|
||||
return k
|
||||
|
||||
def form_valid(self, form):
|
||||
self.request.event.settings.order_import_settings = form.cleaned_data
|
||||
self.settings_holder.settings.set(self.settings_key, form.cleaned_data)
|
||||
if self.request.GET.get("charset") in ENCODINGS:
|
||||
charset = self.request.GET.get("charset")
|
||||
else:
|
||||
charset = None
|
||||
return self.do(
|
||||
self.request.event.pk, self.file.id, form.cleaned_data, self.request.LANGUAGE_CODE,
|
||||
self.request.user.pk, charset
|
||||
self.settings_holder.pk,
|
||||
self.file.id,
|
||||
form.cleaned_data,
|
||||
self.request.LANGUAGE_CODE,
|
||||
self.request.user.pk,
|
||||
charset,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
@@ -176,28 +174,21 @@ class ProcessView(EventPermissionRequiredMixin, AsyncAction, FormView):
|
||||
return _('The import was successful.')
|
||||
|
||||
def get_success_url(self, value):
|
||||
return reverse('control:event.orders', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.organizer.slug,
|
||||
})
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
def get_form_url(self):
|
||||
raise NotImplementedError() # noqa
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if 'async_id' in request.GET and settings.HAS_CELERY:
|
||||
return self.get_result(request)
|
||||
if not self.parsed or not self.parsed_list:
|
||||
messages.error(request, _('We\'ve been unable to parse the uploaded file as a CSV file.'))
|
||||
return redirect(reverse('control:event.orders.import', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.organizer.slug,
|
||||
}))
|
||||
return redirect(self.get_form_url())
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_error_url(self):
|
||||
return reverse('control:event.orders.import.process', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.organizer.slug,
|
||||
'file': self.file.id
|
||||
})
|
||||
return self.request.path
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
@@ -205,3 +196,89 @@ class ProcessView(EventPermissionRequiredMixin, AsyncAction, FormView):
|
||||
ctx['parsed'] = self.parsed
|
||||
ctx['sample_rows'] = self.parsed_list[:3]
|
||||
return ctx
|
||||
|
||||
|
||||
class OrderImportView(EventPermissionRequiredMixin, BaseImportView):
|
||||
template_name = 'pretixcontrol/orders/import_start.html'
|
||||
permission = 'can_change_orders'
|
||||
|
||||
def get_process_url(self, request, cf, charset):
|
||||
return reverse('control:event.orders.import.process', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.organizer.slug,
|
||||
'file': cf.id
|
||||
}) + "?charset=" + charset
|
||||
|
||||
|
||||
class OrderProcessView(EventPermissionRequiredMixin, BaseProcessView):
|
||||
permission = 'can_change_orders'
|
||||
template_name = 'pretixcontrol/orders/import_process.html'
|
||||
form_class = OrdersProcessForm
|
||||
task = import_orders
|
||||
settings_key = 'order_import_settings'
|
||||
|
||||
@property
|
||||
def settings_holder(self):
|
||||
return self.request.event
|
||||
|
||||
def get_form_kwargs(self):
|
||||
k = super().get_form_kwargs()
|
||||
k.update({
|
||||
'event': self.request.event,
|
||||
})
|
||||
return k
|
||||
|
||||
def get_form_url(self):
|
||||
return reverse('control:event.orders.import', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.organizer.slug,
|
||||
})
|
||||
|
||||
def get_success_url(self, value):
|
||||
return reverse('control:event.orders', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.organizer.slug,
|
||||
})
|
||||
|
||||
|
||||
class VoucherImportView(EventPermissionRequiredMixin, BaseImportView):
|
||||
template_name = 'pretixcontrol/vouchers/import_start.html'
|
||||
permission = 'can_change_vouchers'
|
||||
|
||||
def get_process_url(self, request, cf, charset):
|
||||
return reverse('control:event.vouchers.import.process', kwargs={
|
||||
'event': request.event.slug,
|
||||
'organizer': request.organizer.slug,
|
||||
'file': cf.id
|
||||
}) + "?charset=" + charset
|
||||
|
||||
|
||||
class VoucherProcessView(EventPermissionRequiredMixin, BaseProcessView):
|
||||
permission = 'can_change_vouchers'
|
||||
template_name = 'pretixcontrol/vouchers/import_process.html'
|
||||
form_class = VouchersProcessForm
|
||||
task = import_vouchers
|
||||
settings_key = 'voucher_import_settings'
|
||||
|
||||
@property
|
||||
def settings_holder(self):
|
||||
return self.request.event
|
||||
|
||||
def get_form_kwargs(self):
|
||||
k = super().get_form_kwargs()
|
||||
k.update({
|
||||
'event': self.request.event,
|
||||
})
|
||||
return k
|
||||
|
||||
def get_form_url(self):
|
||||
return reverse('control:event.vouchers.import', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.organizer.slug,
|
||||
})
|
||||
|
||||
def get_success_url(self, value):
|
||||
return reverse('control:event.vouchers', kwargs={
|
||||
'event': self.request.event.slug,
|
||||
'organizer': self.request.organizer.slug,
|
||||
})
|
||||
@@ -262,7 +262,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['fonts'] = get_fonts()
|
||||
ctx['fonts'] = get_fonts(self.request.event, pdf_support_required=True)
|
||||
ctx['pdf'] = self.get_current_background()
|
||||
ctx['variables'] = self.get_variables()
|
||||
ctx['images'] = self.get_images()
|
||||
@@ -278,7 +278,7 @@ class FontsCSSView(TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['fonts'] = get_fonts()
|
||||
ctx['fonts'] = get_fonts(self.request.event if hasattr(self.request, 'event') else None, pdf_support_required=True)
|
||||
return ctx
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
@@ -323,6 +324,15 @@ class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView):
|
||||
obj.devicetype = 'webauthn'
|
||||
ctx['devices'] += objs
|
||||
|
||||
ctx['obligatory'] = None
|
||||
if settings.PRETIX_OBLIGATORY_2FA is True:
|
||||
ctx['obligatory'] = 'system'
|
||||
elif settings.PRETIX_OBLIGATORY_2FA == "staff" and self.request.user.is_staff:
|
||||
ctx['obligatory'] = 'staff'
|
||||
elif teams := self.request.user.teams.filter(require_2fa=True).select_related('organizer'):
|
||||
ctx['obligatory'] = 'team'
|
||||
ctx['obligatory_teams'] = teams
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -557,6 +567,28 @@ class User2FADeviceConfirmTOTPView(RecentAuthenticationRequiredMixin, TemplateVi
|
||||
}))
|
||||
|
||||
|
||||
class User2FALeaveTeamsView(RecentAuthenticationRequiredMixin, TemplateView):
|
||||
template_name = 'pretixcontrol/user/2fa_leaveteams.html'
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request, *args, **kwargs):
|
||||
for team in self.request.user.teams.filter(require_2fa=True).select_related('organizer'):
|
||||
team.members.remove(self.request.user)
|
||||
team.log_action(
|
||||
'pretix.team.member.removed', user=self.request.user, data={
|
||||
'email': self.request.user.email,
|
||||
'user': self.request.user.pk
|
||||
}
|
||||
)
|
||||
messages.success(request, _('You have left all teams that require two-factor authentication.'))
|
||||
return redirect(reverse('control:user.settings.2fa'))
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data()
|
||||
ctx['obligatory_teams'] = self.request.user.teams.filter(require_2fa=True).select_related('organizer')
|
||||
return ctx
|
||||
|
||||
|
||||
class User2FAEnableView(RecentAuthenticationRequiredMixin, TemplateView):
|
||||
template_name = 'pretixcontrol/user/2fa_enable.html'
|
||||
|
||||
|
||||
@@ -30,10 +30,12 @@ from django.contrib.auth import (
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.generic import ListView, TemplateView
|
||||
from django_otp.plugins.otp_static.models import StaticDevice
|
||||
from hijack import signals
|
||||
|
||||
from pretix.base.auth import get_auth_backends
|
||||
@@ -150,6 +152,32 @@ class UserResetView(AdministratorPermissionRequiredMixin, RecentAuthenticationRe
|
||||
return reverse('control:users.edit', kwargs=self.kwargs)
|
||||
|
||||
|
||||
class UserEmergencyTokenView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, View):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return redirect(reverse('control:users.edit', kwargs=self.kwargs))
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
|
||||
|
||||
d, __ = StaticDevice.objects.get_or_create(user=self.object, name='emergency')
|
||||
token = d.token_set.create(token=get_random_string(length=12, allowed_chars='1234567890'))
|
||||
self.object.log_action('pretix.user.settings.2fa.emergency', user=self.request.user)
|
||||
|
||||
messages.success(request, _(
|
||||
'The emergency token for this user is "{token}". It can only be used once. Please make sure to transmit '
|
||||
'this code only over an authenticated channel (other than email, if possible). Any previous emergency '
|
||||
'tokens for this user remain active.'
|
||||
).format(
|
||||
token=token.token
|
||||
))
|
||||
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:users.edit', kwargs=self.kwargs)
|
||||
|
||||
|
||||
class UserAnonymizeView(AdministratorPermissionRequiredMixin, RecentAuthenticationRequiredMixin, TemplateView):
|
||||
template_name = "pretixcontrol/users/anonymize.html"
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# This file is distributed under the same license as the Django package.
|
||||
#
|
||||
# Modified informal version by
|
||||
# Raphael Michel <mail@raphaelmichel.de>, 2016
|
||||
#
|
||||
# Original Translators:
|
||||
# André Hagenbruch, 2011-2012
|
||||
# Florian Apolloner <florian@apolloner.eu>, 2011
|
||||
# Jannis Vajen, 2011,2013
|
||||
# Jannis Leidel <jannis@leidel.info>, 2013-2015
|
||||
# Markus Holtermann <inyoka@markusholtermann.eu>, 2013,2015
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2015-10-09 17:42+0200\n"
|
||||
"PO-Revision-Date: 2016-03-18 21:22+0100\n"
|
||||
"Last-Translator: Raphael Michel <mail@raphaelmichel.de>\n"
|
||||
"Language-Team: German (http://www.transifex.com/django/django/language/de/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: de\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: Poedit 1.8.7\n"
|
||||
|
||||
|
||||
|
||||
msgid "Please either submit a file or check the clear checkbox, not both."
|
||||
msgstr ""
|
||||
"Bitte wähle entweder eine Datei aus oder wähle \"Löschen\", nicht "
|
||||
"beides."
|
||||
|
||||
msgid ""
|
||||
"You are seeing this message because this HTTPS site requires a 'Referer "
|
||||
"header' to be sent by your Web browser, but none was sent. This header is "
|
||||
"required for security reasons, to ensure that your browser is not being "
|
||||
"hijacked by third parties."
|
||||
msgstr ""
|
||||
"Du siehst diese Fehlermeldung da diese HTTPS-Seite einen „Referer“-Header "
|
||||
"von deinem Webbrowser erwartet, aber keinen erhalten hat. Dieser Header ist "
|
||||
"aus Sicherheitsgründen notwendig, um sicherzustellen, dass dein Webbrowser "
|
||||
"nicht von Dritten missbraucht wird."
|
||||
|
||||
msgid ""
|
||||
"If you have configured your browser to disable 'Referer' headers, please re-"
|
||||
"enable them, at least for this site, or for HTTPS connections, or for 'same-"
|
||||
"origin' requests."
|
||||
msgstr ""
|
||||
"Falls du deinen Webbrowser so konfiguriert hast, dass „Referer“-Header "
|
||||
"nicht gesendet werden, musst du diese Funktion mindestens für diese Seite, "
|
||||
"für sichere HTTPS-Verbindungen oder für „Same-Origin“-Verbindungen "
|
||||
"reaktivieren."
|
||||
|
||||
msgid ""
|
||||
"You are seeing this message because this site requires a CSRF cookie when "
|
||||
"submitting forms. This cookie is required for security reasons, to ensure "
|
||||
"that your browser is not being hijacked by third parties."
|
||||
msgstr ""
|
||||
"Du siehst Diese Nachricht, da diese Seite einen CSRF-Cookie beim Verarbeiten "
|
||||
"von Formulardaten benötigt. Dieses Cookie ist aus Sicherheitsgründen "
|
||||
"notwendig, um sicherzustellen, dass dein Webbrowser nicht von Dritten "
|
||||
"missbraucht wird."
|
||||
|
||||
msgid ""
|
||||
"If you have configured your browser to disable cookies, please re-enable "
|
||||
"them, at least for this site, or for 'same-origin' requests."
|
||||
msgstr ""
|
||||
"Falls du Cookies in Ihren Webbrowser deaktiviert hast, musst du sie "
|
||||
"mindestens für diese Seite oder für „Same-Origin“-Verbindungen reaktivieren."
|
||||
@@ -20,14 +20,23 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.gis.geoip2 import GeoIP2
|
||||
from django.core.cache import cache
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import Country
|
||||
from geoip2.errors import AddressNotFoundError
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.helpers.http import get_client_ip
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionInvalid(Exception):
|
||||
@@ -38,6 +47,14 @@ class SessionReauthRequired(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Session2FASetupRequired(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SessionPasswordChangeRequired(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def get_user_agent_hash(request):
|
||||
return hashlib.sha256(request.headers['User-Agent'].encode()).hexdigest()
|
||||
|
||||
@@ -71,6 +88,8 @@ def assert_session_valid(request):
|
||||
if 'User-Agent' in request.headers:
|
||||
if 'pinned_user_agent' in request.session:
|
||||
if request.session.get('pinned_user_agent') != get_user_agent_hash(request):
|
||||
logger.info(f"Backend session for user {request.user.pk} terminated due to user agent change. "
|
||||
f"New agent: \"{request.headers['User-Agent']}\"")
|
||||
raise SessionInvalid()
|
||||
else:
|
||||
request.session['pinned_user_agent'] = get_user_agent_hash(request)
|
||||
@@ -82,9 +101,79 @@ def assert_session_valid(request):
|
||||
|
||||
if 'pinned_country' in request.session:
|
||||
if request.session.get('pinned_country') != country:
|
||||
logger.info(f"Backend session for user {request.user.pk} terminated due to country change. "
|
||||
f"Old country: \"{request.session.get('pinned_countres')}\" New country: \"{country}\"")
|
||||
raise SessionInvalid()
|
||||
else:
|
||||
request.session['pinned_country'] = country
|
||||
|
||||
request.session['pretix_auth_last_used'] = int(time.time())
|
||||
|
||||
if request.user.needs_password_change:
|
||||
raise SessionPasswordChangeRequired()
|
||||
|
||||
force_2fa = not request.user.require_2fa and (
|
||||
settings.PRETIX_OBLIGATORY_2FA is True or
|
||||
(settings.PRETIX_OBLIGATORY_2FA == "staff" and request.user.is_staff) or
|
||||
cache.get_or_set(
|
||||
f'user_2fa_team_{request.user.pk}',
|
||||
lambda: request.user.teams.filter(require_2fa=True).exists(),
|
||||
timeout=300
|
||||
)
|
||||
)
|
||||
if force_2fa:
|
||||
raise Session2FASetupRequired()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_login_source(user, request):
|
||||
from ua_parser import user_agent_parser
|
||||
|
||||
parsed_string = user_agent_parser.Parse(request.headers.get("User-Agent", ""))
|
||||
country = None
|
||||
|
||||
if settings.HAS_GEOIP:
|
||||
client_ip = get_client_ip(request)
|
||||
hashed_client_ip = hashlib.sha256(client_ip.encode()).hexdigest()
|
||||
country = cache.get_or_set(f'geoip_country_{hashed_client_ip}', lambda: _get_country(request), timeout=300)
|
||||
if country == "None":
|
||||
country = None
|
||||
|
||||
src, created = user.known_login_sources.update_or_create(
|
||||
agent_type=parsed_string.get("user_agent").get("family"),
|
||||
os_type=parsed_string.get("os").get("family"),
|
||||
device_type=parsed_string.get("device").get("family"),
|
||||
country=country,
|
||||
defaults={
|
||||
"last_seen": now(),
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
user.log_action('pretix.control.auth.user.new_source', user=user, data={
|
||||
"agent_type": src.agent_type,
|
||||
"os_type": src.os_type,
|
||||
"device_type": src.device_type,
|
||||
"country": str(src.country) if src.country else "?",
|
||||
})
|
||||
if user.known_login_sources.count() > 1:
|
||||
# Do not send on first login or first login after introduction of this feature:
|
||||
try:
|
||||
with language(user.locale):
|
||||
mail(
|
||||
user.email,
|
||||
_('Login from new source detected'),
|
||||
'pretixcontrol/email/login_notice.txt',
|
||||
{
|
||||
'source': src,
|
||||
'country': Country(str(country)).name if country else _('Unknown country'),
|
||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||
'url': build_absolute_uri('control:user.settings')
|
||||
},
|
||||
event=None,
|
||||
user=user,
|
||||
locale=user.locale
|
||||
)
|
||||
except SendMailException:
|
||||
pass # Not much we can do
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
import logging
|
||||
|
||||
from django import template
|
||||
from django.core.files import File
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
from pretix import settings
|
||||
from pretix.helpers.thumb import get_thumbnail
|
||||
|
||||
register = template.Library()
|
||||
@@ -33,10 +33,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@register.filter
|
||||
def thumb(source, arg):
|
||||
if isinstance(source, File):
|
||||
source = source.name
|
||||
try:
|
||||
return get_thumbnail(source, arg).thumb.url
|
||||
formats = list(set().union(
|
||||
settings.PILLOW_FORMATS_IMAGE,
|
||||
settings.PILLOW_FORMATS_QUESTIONS_FAVICON,
|
||||
settings.PILLOW_FORMATS_QUESTIONS_IMAGE
|
||||
))
|
||||
return get_thumbnail(source, arg, formats=formats).thumb.url
|
||||
except:
|
||||
logger.exception(f'Failed to create thumbnail of {source}')
|
||||
return default_storage.url(source)
|
||||
# HACK: source.url works for some types of files (e.g. FieldFile), and for all files retrieved from Hierarkey,
|
||||
# default_storage.url works for all files in NanoCDNStorage. For others, this may return an invalid URL.
|
||||
# But for a fallback, this can probably be accepted.
|
||||
return source.url if hasattr(source, 'url') else default_storage.url(str(source))
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#
|
||||
import hashlib
|
||||
import math
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
from django.conf import settings
|
||||
@@ -164,9 +165,16 @@ def resize_image(image, size):
|
||||
return image
|
||||
|
||||
|
||||
def create_thumbnail(sourcename, size, formats=None):
|
||||
source = default_storage.open(sourcename)
|
||||
image = Image.open(BytesIO(source.read()), formats=formats or settings.PILLOW_FORMATS_QUESTIONS_IMAGE)
|
||||
def create_thumbnail(source, size, formats=None):
|
||||
source_name = str(source)
|
||||
|
||||
# HACK: this ensures that the file is opened in binary mode, which is not guaranteed otherwise, esp. for
|
||||
# files retrieved from hierarkey. For Django Files in FileSystemStorage, where source.name is the absolute
|
||||
# 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.
|
||||
source_rb = default_storage.open(source_name, mode='rb')
|
||||
|
||||
image = Image.open(BytesIO(source_rb.read()), formats=formats or settings.PILLOW_FORMATS_QUESTIONS_IMAGE)
|
||||
try:
|
||||
image.load()
|
||||
except:
|
||||
@@ -175,13 +183,14 @@ def create_thumbnail(sourcename, size, formats=None):
|
||||
frames = [resize_image(frame, size) for frame in ImageSequence.Iterator(image)]
|
||||
image_out = frames[0]
|
||||
save_kwargs = {}
|
||||
source_ext = os.path.splitext(source_name)[1].lower()
|
||||
|
||||
if source.name.lower().endswith('.jpg') or source.name.lower().endswith('.jpeg'):
|
||||
if source_ext == '.jpg' or source_ext == '.jpeg':
|
||||
# Yields better file sizes for photos
|
||||
target_ext = 'jpeg'
|
||||
quality = 95
|
||||
elif source.name.lower().endswith('.gif') or source.name.lower().endswith('.png'):
|
||||
target_ext = source.name.lower()[-3:]
|
||||
elif source_ext == '.gif' or source_ext == '.png':
|
||||
target_ext = source_name.lower()[-3:]
|
||||
quality = None
|
||||
image_out.info = image.info
|
||||
save_kwargs = {
|
||||
@@ -196,14 +205,14 @@ def create_thumbnail(sourcename, size, formats=None):
|
||||
checksum = hashlib.md5(image.tobytes()).hexdigest()
|
||||
name = checksum + '.' + size.replace('^', 'c') + '.' + target_ext
|
||||
buffer = BytesIO()
|
||||
if image_out.mode == "P" and source.name.lower().endswith('.png'):
|
||||
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)
|
||||
imgfile = ContentFile(buffer.getvalue())
|
||||
|
||||
t = Thumbnail.objects.create(source=sourcename, size=size)
|
||||
t = Thumbnail.objects.create(source=source_name, size=size)
|
||||
t.thumb.save(name, imgfile)
|
||||
return t
|
||||
|
||||
@@ -211,6 +220,7 @@ def create_thumbnail(sourcename, size, formats=None):
|
||||
def get_thumbnail(source, size, formats=None):
|
||||
# Assumes files are immutable
|
||||
try:
|
||||
return Thumbnail.objects.get(source=source, size=size)
|
||||
source_name = str(source)
|
||||
return Thumbnail.objects.get(source=source_name, size=size)
|
||||
except Thumbnail.DoesNotExist:
|
||||
return create_thumbnail(source, size, formats=formats)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-02-28 13:11+0000\n"
|
||||
"POT-Creation-Date: 2024-04-02 15:53+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -578,54 +578,54 @@ msgstr ""
|
||||
msgid "Unknown error."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:311
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:313
|
||||
msgid "Your color has great contrast and is very easy to read!"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:315
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:317
|
||||
msgid "Your color has decent contrast and is probably good-enough to read!"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:319
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:321
|
||||
msgid ""
|
||||
"Your color has bad contrast for text on white background, please choose a "
|
||||
"darker shade."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:473
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:493
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:475
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:495
|
||||
msgid "Search query"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:491
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:493
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:492
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:494
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:496
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:498
|
||||
msgid "Selected only"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:791
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:794
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:794
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:797
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:952
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:955
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:992
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:995
|
||||
msgid "Click to close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1067
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1070
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr ""
|
||||
|
||||
@@ -663,32 +663,32 @@ msgid_plural "The items in your cart are reserved for you for {num} minutes."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:171
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:203
|
||||
msgid "The organizer keeps %(currency)s %(amount)s"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:179
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:211
|
||||
msgid "You get %(currency)s %(amount)s back"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:195
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:227
|
||||
msgid "Please enter the amount the organizer can keep."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:412
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:444
|
||||
msgid "Please enter a quantity for one of the ticket types."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:448
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:480
|
||||
msgid "required"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:551
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:570
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:583
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:602
|
||||
msgid "Time zone:"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:561
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:593
|
||||
msgid "Your local time:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-02-28 13:11+0000\n"
|
||||
"POT-Creation-Date: 2024-04-02 15:53+0000\n"
|
||||
"PO-Revision-Date: 2021-09-15 11:22+0000\n"
|
||||
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
|
||||
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -608,54 +608,54 @@ msgstr "توليد الرسائل …"
|
||||
msgid "Unknown error."
|
||||
msgstr "خطأ غير معروف."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:311
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:313
|
||||
msgid "Your color has great contrast and is very easy to read!"
|
||||
msgstr "اللون يتمتع بتباين كبير وتسهل قراءته!"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:315
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:317
|
||||
msgid "Your color has decent contrast and is probably good-enough to read!"
|
||||
msgstr "اللون يحظى بتباين معقول ويمكن أن يكون مناسب للقراءة!"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:319
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:321
|
||||
msgid ""
|
||||
"Your color has bad contrast for text on white background, please choose a "
|
||||
"darker shade."
|
||||
msgstr "تباين اللون سيئ للخلفية البيضاء، الرجاء اختيار لون غامق."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:473
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:493
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:475
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:495
|
||||
msgid "Search query"
|
||||
msgstr "البحث في الاستفسارات"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:491
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:493
|
||||
msgid "All"
|
||||
msgstr "الكل"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:492
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:494
|
||||
msgid "None"
|
||||
msgstr "لا شيء"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:496
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:498
|
||||
msgid "Selected only"
|
||||
msgstr "المختارة فقط"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:791
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:794
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:794
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:797
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:952
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:955
|
||||
msgid "Use a different name internally"
|
||||
msgstr "قم باستخدم اسم مختلف داخليا"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:992
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:995
|
||||
msgid "Click to close"
|
||||
msgstr "اضغط لاغلاق الصفحة"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1067
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1070
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr "لم تقم بحفظ التعديلات!"
|
||||
|
||||
@@ -707,32 +707,32 @@ msgstr[3] "سيتم حجز العناصر لك في سلة التسوق لعدة
|
||||
msgstr[4] "سيتم حجز العناصر لك في سلة التسوق لدقائق {num}."
|
||||
msgstr[5] "سيتم حجز العناصر لك في سلة التسوق لمدة {num}."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:171
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:203
|
||||
msgid "The organizer keeps %(currency)s %(amount)s"
|
||||
msgstr "يحصل المنظم على %(currency) %(amount)"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:179
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:211
|
||||
msgid "You get %(currency)s %(amount)s back"
|
||||
msgstr "ستسترد %(currency)%(amount)"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:195
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:227
|
||||
msgid "Please enter the amount the organizer can keep."
|
||||
msgstr "الرجاء إدخال المبلغ الذي يمكن للمنظم الاحتفاظ به."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:412
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:444
|
||||
msgid "Please enter a quantity for one of the ticket types."
|
||||
msgstr "الرجاء إدخال عدد لأحد أنواع التذاكر."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:448
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:480
|
||||
msgid "required"
|
||||
msgstr "مطلوب"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:551
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:570
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:583
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:602
|
||||
msgid "Time zone:"
|
||||
msgstr "المنطقة الزمنية:"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:561
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:593
|
||||
msgid "Your local time:"
|
||||
msgstr "التوقيت المحلي:"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-02-28 13:11+0000\n"
|
||||
"POT-Creation-Date: 2024-04-02 15:53+0000\n"
|
||||
"PO-Revision-Date: 2020-12-19 07:00+0000\n"
|
||||
"Last-Translator: albert <albert.serra.monner@gmail.com>\n"
|
||||
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
@@ -579,54 +579,54 @@ msgstr ""
|
||||
msgid "Unknown error."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:311
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:313
|
||||
msgid "Your color has great contrast and is very easy to read!"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:315
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:317
|
||||
msgid "Your color has decent contrast and is probably good-enough to read!"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:319
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:321
|
||||
msgid ""
|
||||
"Your color has bad contrast for text on white background, please choose a "
|
||||
"darker shade."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:473
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:493
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:475
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:495
|
||||
msgid "Search query"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:491
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:493
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:492
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:494
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:496
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:498
|
||||
msgid "Selected only"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:791
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:794
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:794
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:797
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:952
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:955
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:992
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:995
|
||||
msgid "Click to close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1067
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1070
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr ""
|
||||
|
||||
@@ -668,34 +668,34 @@ msgid_plural "The items in your cart are reserved for you for {num} minutes."
|
||||
msgstr[0] "El contingut de la cistella ja no el teniu reservat."
|
||||
msgstr[1] "El contingut de la cistella ja no el teniu reservat."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:171
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:203
|
||||
msgid "The organizer keeps %(currency)s %(amount)s"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:179
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:211
|
||||
msgid "You get %(currency)s %(amount)s back"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:195
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:227
|
||||
msgid "Please enter the amount the organizer can keep."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:412
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:444
|
||||
msgid "Please enter a quantity for one of the ticket types."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:448
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:480
|
||||
#, fuzzy
|
||||
#| msgid "Cart expired"
|
||||
msgid "required"
|
||||
msgstr "Cistella expirada"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:551
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:570
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:583
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:602
|
||||
msgid "Time zone:"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:561
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:593
|
||||
msgid "Your local time:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-02-28 13:11+0000\n"
|
||||
"POT-Creation-Date: 2024-04-02 15:53+0000\n"
|
||||
"PO-Revision-Date: 2023-09-15 06:00+0000\n"
|
||||
"Last-Translator: Michael <michael.happl@gmx.at>\n"
|
||||
"Language-Team: Czech <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -599,16 +599,16 @@ msgstr "Vytváření zpráv…"
|
||||
msgid "Unknown error."
|
||||
msgstr "Neznámá chyba."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:311
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:313
|
||||
msgid "Your color has great contrast and is very easy to read!"
|
||||
msgstr "Tato barva má velmi dobrý kontrast a je velmi dobře čitelná!"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:315
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:317
|
||||
msgid "Your color has decent contrast and is probably good-enough to read!"
|
||||
msgstr ""
|
||||
"Tato barva má slušný kontrast a pravděpodobně je dostatečně dobře čitelná!"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:319
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:321
|
||||
msgid ""
|
||||
"Your color has bad contrast for text on white background, please choose a "
|
||||
"darker shade."
|
||||
@@ -616,40 +616,40 @@ msgstr ""
|
||||
"Tato barva je pro text na bílém pozadí špatně kontrastní, zvolte prosím "
|
||||
"tmavší odstín."
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:473
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:493
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:475
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:495
|
||||
msgid "Search query"
|
||||
msgstr "Hledaný výraz"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:491
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:493
|
||||
msgid "All"
|
||||
msgstr "Všechny"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:492
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:494
|
||||
msgid "None"
|
||||
msgstr "Žádný"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:496
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:498
|
||||
msgid "Selected only"
|
||||
msgstr "Pouze vybrané"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:791
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:794
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:794
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:797
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:952
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:955
|
||||
msgid "Use a different name internally"
|
||||
msgstr "Interně používat jiný název"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:992
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:995
|
||||
msgid "Click to close"
|
||||
msgstr "Kliknutím zavřete"
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1067
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1070
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr "Máte neuložené změny!"
|
||||
|
||||
@@ -694,32 +694,32 @@ msgstr[1] ""
|
||||
msgstr[2] ""
|
||||
"Produkty v nákupním košíku jsou pro vás rezervovány na dalších {num} minut."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:171
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:203
|
||||
msgid "The organizer keeps %(currency)s %(amount)s"
|
||||
msgstr "Organizátor si ponechává %(currency)s %(amount)s"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:179
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:211
|
||||
msgid "You get %(currency)s %(amount)s back"
|
||||
msgstr "Dostanete %(currency)s %(amount)s zpět"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:195
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:227
|
||||
msgid "Please enter the amount the organizer can keep."
|
||||
msgstr "Zadejte částku, kterou si organizátor může ponechat."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:412
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:444
|
||||
msgid "Please enter a quantity for one of the ticket types."
|
||||
msgstr "Zadejte prosím množství pro jeden z typů vstupenek."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:448
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:480
|
||||
msgid "required"
|
||||
msgstr "povinný"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:551
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:570
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:583
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:602
|
||||
msgid "Time zone:"
|
||||
msgstr "Časové pásmo:"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:561
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:593
|
||||
msgid "Your local time:"
|
||||
msgstr "Místní čas:"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-02-28 13:11+0000\n"
|
||||
"POT-Creation-Date: 2024-04-02 15:53+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -579,54 +579,54 @@ msgstr ""
|
||||
msgid "Unknown error."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:311
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:313
|
||||
msgid "Your color has great contrast and is very easy to read!"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:315
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:317
|
||||
msgid "Your color has decent contrast and is probably good-enough to read!"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:319
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:321
|
||||
msgid ""
|
||||
"Your color has bad contrast for text on white background, please choose a "
|
||||
"darker shade."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:473
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:493
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:475
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:495
|
||||
msgid "Search query"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:491
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:493
|
||||
msgid "All"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:492
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:494
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:496
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:498
|
||||
msgid "Selected only"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:791
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:794
|
||||
msgid "Enter page number between 1 and %(max)s."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:794
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:797
|
||||
msgid "Invalid page number."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:952
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:955
|
||||
msgid "Use a different name internally"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:992
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:995
|
||||
msgid "Click to close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1067
|
||||
#: pretix/static/pretixcontrol/js/ui/main.js:1070
|
||||
msgid "You have unsaved changes!"
|
||||
msgstr ""
|
||||
|
||||
@@ -664,32 +664,32 @@ msgid_plural "The items in your cart are reserved for you for {num} minutes."
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:171
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:203
|
||||
msgid "The organizer keeps %(currency)s %(amount)s"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:179
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:211
|
||||
msgid "You get %(currency)s %(amount)s back"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:195
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:227
|
||||
msgid "Please enter the amount the organizer can keep."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:412
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:444
|
||||
msgid "Please enter a quantity for one of the ticket types."
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:448
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:480
|
||||
msgid "required"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:551
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:570
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:583
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:602
|
||||
msgid "Time zone:"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:561
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:593
|
||||
msgid "Your local time:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user