diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7b1598129f..adcbd20e81 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,10 +26,10 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 + - name: Set up Python 3.13 uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 - uses: actions/cache@v4 with: path: ~/.cache/pip diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 975087c752..68e8639fb5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,13 +23,13 @@ jobs: name: Tests strategy: matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.13"] database: [sqlite, postgres] exclude: - - database: sqlite - python-version: "3.9" - database: sqlite python-version: "3.10" + - database: sqlite + python-version: "3.11" services: postgres: image: postgres:15 diff --git a/doc/_themes/pretix_theme/layout.html b/doc/_themes/pretix_theme/layout.html index b0fa5c1421..bca924e3bd 100644 --- a/doc/_themes/pretix_theme/layout.html +++ b/doc/_themes/pretix_theme/layout.html @@ -6,10 +6,14 @@ {%- else %} {%- set titlesuffix = "" %} {%- endif %} +{%- set lang_attr = 'en' if language == None else (language | replace('_', '-')) %} + +{# Build sphinx_version_info tuple from sphinx_version string in pure Jinja #} +{%- set (_ver_major, _ver_minor) = (sphinx_version.split('.') | list)[:2] | map('int') -%} +{%- set sphinx_version_info = (_ver_major, _ver_minor, -1) -%} - - += (7, 2) %} data-content_root="{{ content_root }}"{% endif %}> {{ metatags }} @@ -18,59 +22,50 @@ {{ title|striptags|e }}{{ titlesuffix }} {% endblock %} - - {#- CSS #} - {%- for css in css_files %} - {%- if css|attr("rel") %} - + {#- CSS #} + {%- for css_file in css_files %} + {%- if css_file|attr("filename") %} + {{ css_tag(css_file) }} {%- else %} - + {%- endif %} - {%- endfor %} + {%- endfor %} - {%- for cssfile in extra_css_files %} - - {%- endfor -%} + {#- FAVICON #} + {%- if favicon_url %} + + {%- endif %} - {#- FAVICON - favicon_url is the only context var necessary since Sphinx 4. - In Sphinx<4, we use favicon but need to prepend path info. - #} - {%- set _favicon_url = favicon_url | default(pathto('_static/' + (favicon or ""), 1)) %} - {%- if favicon_url or favicon %} - - {%- endif %} - - {#- CANONICAL URL (deprecated) #} - {%- if theme_canonical_url and not pageurl %} + {#- CANONICAL URL (deprecated) #} + {%- if theme_canonical_url and not pageurl %} - {%- endif -%} + {%- endif -%} - {#- CANONICAL URL #} - {%- if pageurl %} + {#- CANONICAL URL #} + {%- if pageurl %} - {%- endif -%} + {%- endif -%} - {#- JAVASCRIPTS #} - {%- block scripts %} - - {%- if not embedded %} - {# XXX Sphinx 1.8.0 made this an external js-file, quick fix until we refactor the template to inherert more blocks directly from sphinx #} - {%- for scriptfile in script_files %} - {{ js_tag(scriptfile) }} - {%- endfor %} + {#- JAVASCRIPTS #} + {%- block scripts %} + {%- if not embedded %} + {%- for scriptfile in script_files %} + {{ js_tag(scriptfile) }} + {%- endfor %} + {%- if READTHEDOCS or DEBUG %} + + {%- endif %} + {#- OPENSEARCH #} {%- if use_opensearch %} {%- endif %} - {%- endif %} - {%- endblock %} + {%- endif %} + {%- endblock %} {%- block linktags %} {%- if hasdoc('about') %} @@ -123,23 +118,23 @@ {% endblock %} - + {%- endblock %} {% if theme_display_version %} {%- set nav_version = version %} @@ -158,53 +153,42 @@
{# MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #} - + - - {# PAGE CONTENT #} -
-
- {% include "breadcrumbs.html" %} -
-
- {% block body %}{% endblock %} -
-
- {% block comments %}{% endblock %} -
-
- {% include "footer.html" %} +
+ {%- block content %} + {%- if theme_style_external_links|tobool %} + -
-
{% include "versions.html" %} - {% if not embedded %} - - - {%- for scriptfile in script_files %} - - {%- endfor %} - - {% endif %} - {# RTD hosts this file, so just load on non RTD builds #} {% if not READTHEDOCS %} @@ -214,7 +198,7 @@ {% if theme_sticky_navigation %} {% endif %} diff --git a/doc/_themes/pretix_theme/layout_old.html b/doc/_themes/pretix_theme/layout_old.html index 9f2d1999b6..4d14790dbd 100644 --- a/doc/_themes/pretix_theme/layout_old.html +++ b/doc/_themes/pretix_theme/layout_old.html @@ -1,136 +1,86 @@ -{# - basic/layout.html - ~~~~~~~~~~~~~~~~~ - - Master layout template for Sphinx themes. - - :copyright: Copyright 2007-2013 by the Sphinx team, see AUTHORS. - :license: BSD, see LICENSE for details. -#} -{%- block doctype -%} - -{%- endblock %} -{%- set reldelim1 = reldelim1 is not defined and ' »' or reldelim1 %} -{%- set reldelim2 = reldelim2 is not defined and ' |' or reldelim2 %} -{%- set render_sidebar = (not embedded) and (not theme_nosidebar|tobool) and - (sidebars != []) %} +{# TEMPLATE VAR SETTINGS #} {%- set url_root = pathto('', 1) %} -{# XXX necessary? #} {%- if url_root == '#' %}{% set url_root = '' %}{% endif %} {%- if not embedded and docstitle %} {%- set titlesuffix = " — "|safe + docstitle|e %} {%- else %} {%- set titlesuffix = "" %} {%- endif %} +{%- set lang_attr = 'en' if language == None else (language | replace('_', '-')) %} -{%- macro relbar() %} - -{%- endmacro %} +{# Build sphinx_version_info tuple from sphinx_version string in pure Jinja #} +{%- set (_ver_major, _ver_minor) = (sphinx_version.split('.') | list)[:2] | map('int') -%} +{%- set sphinx_version_info = (_ver_major, _ver_minor, -1) -%} -{%- macro sidebar() %} - {%- if render_sidebar %} -
-
- {%- block sidebarlogo %} - {%- if logo %} - - {%- endif %} - {%- endblock %} - {%- if sidebars != None %} - {#- new style sidebar: explicitly include/exclude templates #} - {%- for sidebartemplate in sidebars %} - {%- include sidebartemplate %} - {%- endfor %} - {%- else %} - {#- old style sidebars: using blocks -- should be deprecated #} - {%- block sidebartoc %} - {%- include "localtoc.html" %} - {%- endblock %} - {%- block sidebarrel %} - {%- include "relations.html" %} - {%- endblock %} - {%- block sidebarsourcelink %} - {%- include "sourcelink.html" %} - {%- endblock %} - {%- if customsidebar %} - {%- include customsidebar %} - {%- endif %} - {%- block sidebarsearch %} - {%- include "searchbox.html" %} - {%- endblock %} - {%- endif %} -
-
- {%- endif %} -{%- endmacro %} + += (7, 2) %} data-content_root="{{ content_root }}"{% endif %}> + + + {%- if READTHEDOCS and not embedded %} + + {%- endif %} + {{- metatags }} + + {%- block htmltitle %} + {{ title|striptags|e }}{{ titlesuffix }} + {%- endblock -%} -{%- macro script() %} - + {#- CSS #} + {%- for css_file in css_files %} + {%- if css_file|attr("filename") %} + {{ css_tag(css_file) }} + {%- else %} + + {%- endif %} + {%- endfor %} + + {# + "extra_css_files" is an undocumented Read the Docs theme specific option. + There is no need to check for ``|attr("filename")`` here because it's always a string. + Note that this option should be removed in favor of regular ``html_css_files``: + https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_css_files + #} + {%- for css_file in extra_css_files %} + + {%- endfor -%} + + {#- FAVICON #} + {%- if favicon_url %} + + {%- endif %} + + {#- CANONICAL URL (deprecated) #} + {%- if theme_canonical_url and not pageurl %} + + {%- endif -%} + + {#- CANONICAL URL #} + {%- if pageurl %} + + {%- endif -%} + + {#- JAVASCRIPTS #} + {%- block scripts %} + {%- if not embedded %} {%- for scriptfile in script_files %} - + {{ js_tag(scriptfile) }} {%- endfor %} -{%- endmacro %} + -{%- macro css() %} - - - {%- for cssfile in css_files %} - - {%- endfor %} -{%- endmacro %} + {%- if READTHEDOCS or DEBUG %} + + {%- endif %} - - - - {{ metatags }} - {%- block htmltitle %} - {{ title|striptags|e }}{{ titlesuffix }} - {%- endblock %} - {{ css() }} - {%- if not embedded %} - {{ script() }} + {#- OPENSEARCH #} {%- if use_opensearch %} {%- endif %} - {%- if favicon %} - - {%- endif %} - {%- if theme_canonical_url %} - - {%- endif %} - {%- endif %} -{%- block linktags %} + {%- endif %} + {%- endblock %} + + {%- block linktags %} {%- if hasdoc('about') %} {%- endif %} @@ -143,67 +93,135 @@ {%- if hasdoc('copyright') %} {%- endif %} - - {%- if parents %} - - {%- endif %} {%- if next %} {%- endif %} {%- if prev %} {%- endif %} -{%- endblock %} -{%- block extrahead %} {% endblock %} - - -{%- block header %}{% endblock %} - -{%- block relbar1 %}{{ relbar() }}{% endblock %} - -{%- block content %} - {%- block sidebar1 %} {# possible location for sidebar #} {% endblock %} - -
- {%- block document %} -
- {%- if render_sidebar %} -
- {%- endif %} -
- {% block body %} {% endblock %} -
- {%- if render_sidebar %} -
- {%- endif %} -
{%- endblock %} + {%- block extrahead %} {% endblock %} + - {%- block sidebar2 %}{{ sidebar() }}{% endblock %} -
-
-{%- endblock %} + -{%- block relbar2 %}{{ relbar() }}{% endblock %} + {%- block extrabody %} {% endblock %} +
+ {#- SIDE NAV, TOGGLES ON MOBILE #} + + +
+ + {#- MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #} + {#- Translators: This is an ARIA section label for the navigation menu that is visible when viewing the page on mobile devices -#} + + +
+ {%- block content %} + {%- if theme_style_external_links|tobool %} + +
+
+ {% include "versions.html" -%} + + + + {#- Do not conflict with RTD insertion of analytics script #} + {%- if not READTHEDOCS %} + {%- if theme_analytics_id %} + + + -{%- block footer %} - -

asdf asdf asdf asdf 22

-{%- endblock %} - - + {%- endif %} + {%- block footer %} {% endblock %} + + + \ No newline at end of file diff --git a/doc/api/deviceauth.rst b/doc/api/deviceauth.rst index 99d9006b99..acf3d22a82 100644 --- a/doc/api/deviceauth.rst +++ b/doc/api/deviceauth.rst @@ -39,7 +39,7 @@ as well as the type of underlying hardware. Example: "rsa_pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh…nswIDAQAB\n-----END PUBLIC KEY-----\n" } -The ``rsa_pubkey`` is optional any only required for certain fatures such as working with reusable +The ``rsa_pubkey`` is optional any only required for certain features such as working with reusable media and NFC cryptography. Every initialization token can only be used once. On success, you will receive a response containing diff --git a/doc/api/fundamentals.rst b/doc/api/fundamentals.rst index 00b2d261c0..d72e3f6d66 100644 --- a/doc/api/fundamentals.rst +++ b/doc/api/fundamentals.rst @@ -117,7 +117,7 @@ List-level conditional fetching If modification checks are not possible with this granularity, you can instead check for the full list. In this case, the list of objects may contain a regular HTTP header ``Last-Modified`` with the date of the last modification to any item of that resource. You can then pass this date back in your next request in the -``If-Modified-Since`` header. If the any object has changed in the meantime, you will receive back a full list +``If-Modified-Since`` header. If any object has changed in the meantime, you will receive back a full list (if something it missing, this means the object has been deleted). If nothing happened, we'll send back a ``304 Not Modified`` return code. diff --git a/doc/api/resources/item_program_times.rst b/doc/api/resources/item_program_times.rst index 0bfaf617a3..db8a6d3368 100644 --- a/doc/api/resources/item_program_times.rst +++ b/doc/api/resources/item_program_times.rst @@ -5,6 +5,7 @@ Resource description -------------------- Program times for products (items) that can be set in addition to event times, e.g. to display seperate schedules within an event. +Note that ``program_times`` are not available for items inside event series. The program times resource contains the following public fields: .. rst-class:: rest-resource-table @@ -45,28 +46,28 @@ Endpoints Vary: Accept Content-Type: application/json - { - "count": 3, - "next": null, - "previous": null, - "results": [ - { - "id": 2, - "start": "2025-08-14T22:00:00Z", - "end": "2025-08-15T00:00:00Z" - }, - { - "id": 3, - "start": "2025-08-12T22:00:00Z", - "end": "2025-08-13T22:00:00Z" - }, - { - "id": 14, - "start": "2025-08-15T22:00:00Z", - "end": "2025-08-17T22:00:00Z" - } - ] - } + { + "count": 3, + "next": null, + "previous": null, + "results": [ + { + "id": 2, + "start": "2025-08-14T22:00:00Z", + "end": "2025-08-15T00:00:00Z" + }, + { + "id": 3, + "start": "2025-08-12T22:00:00Z", + "end": "2025-08-13T22:00:00Z" + }, + { + "id": 14, + "start": "2025-08-15T22:00:00Z", + "end": "2025-08-17T22:00:00Z" + } + ] + } :param organizer: The ``slug`` field of the organizer to fetch :param event: The ``slug`` field of the event to fetch diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 4c80412840..a8a5a14822 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -142,6 +142,7 @@ variations list of objects A list with o program_times list of objects A list with one object for each program time of this item. Can be empty. Only writable during creation, use separate endpoint to modify this later. + Not available for items in event series. ├ id integer Internal ID of the variation ├ value multi-lingual string The "name" of the variation ├ default_price money (string) The price set directly for this variation or ``null`` @@ -243,6 +244,8 @@ Also note that ``variations``, ``bundles``, ``addons`` and ``program_times`` ar bundles, add-ons and program times please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT`` with nested ``variations``, ``bundles``, ``addons`` and/or ``program_times``. +``program_times`` is not available to items in event series. + Endpoints --------- diff --git a/doc/development/algorithms/pricing.rst b/doc/development/algorithms/pricing.rst index aa1c7769fc..f64790e121 100644 --- a/doc/development/algorithms/pricing.rst +++ b/doc/development/algorithms/pricing.rst @@ -211,7 +211,7 @@ The line-based computation has a few significant advantages: The main disadvantage is that the tax looks "wrong" when computed from the sum. Taking the sum of net prices (420.15) and multiplying it with the tax rate (19%) yields a tax amount of 79.83 (instead of 79.85) and a gross sum of 499.98 -(instead of 499.98). This becomes a problem when juristictions, data formats, or external systems expect this calculation +(instead of 500.00). This becomes a problem when juristictions, data formats, or external systems expect this calculation to work on the level of the entire order. A prominent example is the EN 16931 standard for e-invoicing that does not allow the computation as created by pretix. diff --git a/doc/requirements.rtd.txt b/doc/requirements.rtd.txt index ca98fe9ab0..19638c5b73 100644 --- a/doc/requirements.rtd.txt +++ b/doc/requirements.rtd.txt @@ -1,9 +1,8 @@ -sphinx==7.4.* -jinja2==3.1.* -sphinx-rtd-theme -sphinxcontrib-httpdomain -sphinxcontrib-images -sphinxcontrib-jquery -sphinxcontrib-spelling==8.* -sphinxemoji +sphinx==9.1.* +sphinx-rtd-theme~=3.1.0 +sphinxcontrib-httpdomain~=1.8.1 +sphinxcontrib-images~=1.0.1 +sphinxcontrib-jquery~=4.1 +sphinxcontrib-spelling~=8.0.2 +sphinxemoji~=0.3.2 pyenchant==3.3.* diff --git a/doc/requirements.txt b/doc/requirements.txt index 5de0c0984f..74538aae46 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,10 +1,9 @@ -e ../ -sphinx==7.4.* -jinja2==3.1.* -sphinx-rtd-theme -sphinxcontrib-httpdomain -sphinxcontrib-images -sphinxcontrib-jquery -sphinxcontrib-spelling==8.* -sphinxemoji +sphinx==9.1.* +sphinx-rtd-theme~=3.1.0 +sphinxcontrib-httpdomain~=1.8.1 +sphinxcontrib-images~=1.0.1 +sphinxcontrib-jquery~=4.1 +sphinxcontrib-spelling~=8.0.2 +sphinxemoji~=0.3.2 pyenchant==3.3.* diff --git a/pyproject.toml b/pyproject.toml index 5bdb79bf68..a05b8de67c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "pretix" dynamic = ["version"] description = "Reinventing presales, one ticket at a time" readme = "README.rst" -requires-python = ">=3.9" +requires-python = ">=3.10" license = {file = "LICENSE"} keywords = ["tickets", "web", "shop", "ecommerce"] authors = [ @@ -29,16 +29,17 @@ dependencies = [ "arabic-reshaper==3.0.0", # Support for Arabic in reportlab "babel", "BeautifulSoup4==4.14.*", - "bleach==6.2.*", - "celery==5.5.*", + "bleach==6.3.*", + "celery==5.6.*", "chardet==5.2.*", "cryptography>=44.0.0", - "css-inline==0.18.*", + "css-inline==0.19.*", "defusedcsv>=1.1.0", + "dnspython==2.*", "Django[argon2]==4.2.*,>=4.2.26", - "django-bootstrap3==25.2", - "django-compressor==4.5.1", - "django-countries==7.6.*", + "django-bootstrap3==26.1", + "django-compressor==4.6.0", + "django-countries==8.2.*", "django-filter==25.1", "django-formset-js-improved==0.5.0.4", "django-formtools==2.5.1", @@ -49,22 +50,22 @@ dependencies = [ "django-localflavor==5.0", "django-markup", "django-oauth-toolkit==2.3.*", - "django-otp==1.6.*", - "django-phonenumber-field==7.3.*", + "django-otp==1.7.*", + "django-phonenumber-field==8.4.*", "django-redis==6.0.*", "django-scopes==2.0.*", "django-statici18n==2.6.*", "djangorestframework==3.16.*", - "dnspython==2.7.*", + "dnspython==2.8.*", "drf_ujson2==1.7.*", "geoip2==5.*", "importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+ "isoweek", "jsonschema", - "kombu==5.5.*", + "kombu==5.6.*", "libsass==0.23.*", "lxml", - "markdown==3.9", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3. + "markdown==3.10", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3. # We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7 "mt-940==4.30.*", "oauthlib==3.3.*", @@ -74,31 +75,30 @@ dependencies = [ "paypal-checkout-serversdk==1.0.*", "PyJWT==2.10.*", "phonenumberslite==9.0.*", - "Pillow==11.3.*", + "Pillow==12.1.*", "pretix-plugin-build", "protobuf==6.33.*", "psycopg2-binary", "pycountry", "pycparser==2.23", "pycryptodome==3.23.*", - "pypdf==6.2.*", + "pypdf==6.5.*", "python-bidi==0.6.*", # Support for Arabic in reportlab "python-dateutil==2.9.*", "pytz", "pytz-deprecation-shim==0.1.*", "pyuca", "qrcode==8.2", - "redis==6.4.*", + "redis==7.1.*", "reportlab==4.4.*", "requests==2.32.*", - "sentry-sdk==2.44.*", + "sentry-sdk==2.49.*", "sepaxml==2.7.*", "stripe==7.9.*", "text-unidecode==1.*", "tlds>=2020041600", "tqdm==4.*", "ua-parser==1.0.*", - "vat_moss_forked==2020.3.20.0.11.0", "vobject==0.9.*", "webauthn==2.7.*", "zeep==4.3.*" @@ -110,10 +110,10 @@ dev = [ "aiohttp==3.13.*", "coverage", "coveralls", - "fakeredis==2.32.*", + "fakeredis==2.33.*", "flake8==7.3.*", "freezegun", - "isort==6.1.*", + "isort==7.0.*", "pep8-naming==0.15.*", "potypo", "pytest-asyncio>=0.24", @@ -123,7 +123,7 @@ dev = [ "pytest-mock==3.15.*", "pytest-sugar", "pytest-xdist==3.8.*", - "pytest==8.4.*", + "pytest==9.0.*", "responses", ] diff --git a/src/pretix/__init__.py b/src/pretix/__init__.py index 7c5f8da56f..402b2279bf 100644 --- a/src/pretix/__init__.py +++ b/src/pretix/__init__.py @@ -19,4 +19,4 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -__version__ = "2025.10.0.dev0" +__version__ = "2025.11.0.dev0" diff --git a/src/pretix/api/migrations/0014_alter_webhook_target_url_and_more.py b/src/pretix/api/migrations/0014_alter_webhook_target_url_and_more.py new file mode 100644 index 0000000000..f146b7fe41 --- /dev/null +++ b/src/pretix/api/migrations/0014_alter_webhook_target_url_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.24 on 2025-11-14 16:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixapi", "0013_alter_webhookcallretry_retry_not_before"), + ] + + operations = [ + migrations.AlterField( + model_name="webhook", + name="target_url", + field=models.URLField(max_length=1024), + ), + migrations.AlterField( + model_name="webhookcall", + name="target_url", + field=models.URLField(max_length=1024), + ), + ] diff --git a/src/pretix/api/models.py b/src/pretix/api/models.py index a6c202e57b..36fda88593 100644 --- a/src/pretix/api/models.py +++ b/src/pretix/api/models.py @@ -114,7 +114,7 @@ class OAuthRefreshToken(AbstractRefreshToken): class WebHook(models.Model): organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks') enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook")) - target_url = models.URLField(verbose_name=_("Target URL"), max_length=255) + target_url = models.URLField(verbose_name=_("Target URL"), max_length=1024) all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)")) limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True) comment = models.CharField(verbose_name=_("Comment"), max_length=255, null=True, blank=True) @@ -140,7 +140,7 @@ class WebHookEventListener(models.Model): class WebHookCall(models.Model): webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls') datetime = models.DateTimeField(auto_now_add=True) - target_url = models.URLField(max_length=255) + target_url = models.URLField(max_length=1024) action_type = models.CharField(max_length=255) is_retry = models.BooleanField(default=False) execution_time = models.FloatField(null=True) diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 1f1c40cf28..325e24c7ce 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -795,6 +795,7 @@ class EventSettingsSerializer(SettingsSerializer): 'invoice_address_asked', 'invoice_address_required', 'invoice_address_vatid', + 'invoice_address_vatid_required_countries', 'invoice_address_company_required', 'invoice_address_beneficiary', 'invoice_address_custom_field', @@ -943,6 +944,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer): 'invoice_address_asked', 'invoice_address_required', 'invoice_address_vatid', + 'invoice_address_vatid_required_countries', 'invoice_address_company_required', 'invoice_address_beneficiary', 'invoice_address_custom_field', diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index dea711d792..a2c6258f5e 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -241,6 +241,12 @@ class ItemProgramTimeSerializer(serializers.ModelSerializer): if start > end: raise ValidationError(_("The program end must not be before the program start.")) + event = self.context['event'] + if event.has_subevents: + raise ValidationError({ + _("You cannot use program times on an event series.") + }) + return data diff --git a/src/pretix/api/serializers/orderchange.py b/src/pretix/api/serializers/orderchange.py index 10e84daf3f..a92aa46b03 100644 --- a/src/pretix/api/serializers/orderchange.py +++ b/src/pretix/api/serializers/orderchange.py @@ -33,7 +33,7 @@ from pretix.api.serializers.order import ( OrderFeeCreateSerializer, OrderPositionCreateSerializer, ) from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition -from pretix.base.services.orders import OrderError +from pretix.base.services.orders import OrderChangeManager, OrderError from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS logger = logging.getLogger(__name__) @@ -82,11 +82,11 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize return data def create(self, validated_data): - ocm = self.context['ocm'] + ocm: OrderChangeManager = self.context['ocm'] check_quotas = self.context.get('check_quotas', True) try: - ocm.add_position( + new_position = ocm.add_position( item=validated_data['item'], variation=validated_data.get('variation'), price=validated_data.get('price'), @@ -98,7 +98,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize ) if self.context.get('commit', True): ocm.commit(check_quotas=check_quotas) - return validated_data['order'].positions.order_by('-positionid').first() + return new_position.position else: return OrderPosition() # fake to appease DRF except OrderError as e: @@ -131,7 +131,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer): return data def create(self, validated_data): - ocm = self.context['ocm'] + ocm: OrderChangeManager = self.context['ocm'] try: f = OrderFee( @@ -146,7 +146,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer): ocm.add_fee(f) if self.context.get('commit', True): ocm.commit() - return validated_data['order'].fees.order_by('-pk').first() + return f else: return OrderFee() # fake to appease DRF except OrderError as e: @@ -310,7 +310,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer): return data def update(self, instance, validated_data): - ocm = self.context['ocm'] + ocm: OrderChangeManager = self.context['ocm'] check_quotas = self.context.get('check_quotas', True) current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None item = validated_data.get('item', instance.item) @@ -399,7 +399,7 @@ class OrderFeeChangeSerializer(serializers.ModelSerializer): ) def update(self, instance, validated_data): - ocm = self.context['ocm'] + ocm: OrderChangeManager = self.context['ocm'] value = validated_data.get('value', instance.value) try: diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index aee83af95b..ce3ed39b72 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -443,6 +443,7 @@ class OrganizerSettingsSerializer(SettingsSerializer): 'customer_accounts', 'customer_accounts_native', 'customer_accounts_link_by_email', + 'customer_accounts_require_login_for_order_access', 'invoice_regenerate_allowed', 'contact_mail', 'imprint_url', diff --git a/src/pretix/api/views/exporters.py b/src/pretix/api/views/exporters.py index 63344b21e6..9cbe4c59f7 100644 --- a/src/pretix/api/views/exporters.py +++ b/src/pretix/api/views/exporters.py @@ -74,6 +74,11 @@ class ExportersMixin: @action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P[^/]+)/(?P[^/]+)') def download(self, *args, **kwargs): cf = get_object_or_404(CachedFile, id=kwargs['cfid']) + if not cf.allowed_for_session(self.request, "exporters-api"): + return Response( + {'status': 'failed', 'message': 'Unknown file ID or export failed'}, + status=status.HTTP_410_GONE + ) if cf.file: resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type) resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore") @@ -109,7 +114,8 @@ class ExportersMixin: serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs()) serializer.is_valid(raise_exception=True) - cf = CachedFile(web_download=False) + cf = CachedFile(web_download=True) + cf.bind_to_session(self.request, "exporters-api") cf.date = now() cf.expires = now() + timedelta(hours=24) cf.save() diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index 6e0336c0c0..a84fa589ac 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -40,7 +40,7 @@ from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_scopes import scopes_disabled from rest_framework import viewsets from rest_framework.decorators import action -from rest_framework.exceptions import PermissionDenied +from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.response import Response from pretix.api.pagination import TotalOrderingFilter @@ -293,6 +293,8 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet): return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) def get_queryset(self): + if self.request.event.has_subevents: + raise ValidationError('You cannot use program times on an event series.') return self.item.program_times.all() def get_serializer_context(self): @@ -565,7 +567,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): write_permission = 'can_change_items' def get_queryset(self): - return self.request.event.quotas.all() + return self.request.event.quotas.select_related('subevent').prefetch_related('items', 'variations').all() def list(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()).distinct() diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index f600086e1c..f084a16794 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -721,7 +721,7 @@ class MembershipViewSet(viewsets.ModelViewSet): def get_queryset(self): return Membership.objects.filter( customer__organizer=self.request.organizer - ) + ).select_related('customer') def get_serializer_context(self): ctx = super().get_serializer_context() diff --git a/src/pretix/api/views/voucher.py b/src/pretix/api/views/voucher.py index c0c0c10169..2d6243b248 100644 --- a/src/pretix/api/views/voucher.py +++ b/src/pretix/api/views/voucher.py @@ -19,6 +19,7 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # + from django.db import transaction from django.db.models import F, Q from django.utils.timezone import now @@ -64,8 +65,13 @@ class VoucherViewSet(viewsets.ModelViewSet): permission = 'can_view_vouchers' write_permission = 'can_change_vouchers' + @scopes_disabled() # we have an event check here, and we can save some performance on subqueries def get_queryset(self): - return self.request.event.vouchers.select_related('seat').all() + return Voucher.annotate_budget_used( + self.request.event.vouchers + ).select_related( + 'item', 'quota', 'seat', 'variation' + ) @transaction.atomic() def create(self, request, *args, **kwargs): diff --git a/src/pretix/api/webhooks.py b/src/pretix/api/webhooks.py index b14bd7139e..ac3889157c 100644 --- a/src/pretix/api/webhooks.py +++ b/src/pretix/api/webhooks.py @@ -43,6 +43,7 @@ from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask from pretix.base.signals import periodic_task from pretix.celery_app import app from pretix.helpers import OF_SELF +from pretix.helpers.celery import get_task_priority logger = logging.getLogger(__name__) _ALL_EVENTS = None @@ -474,7 +475,10 @@ def notify_webhooks(logentry_ids: list): ) for wh in webhooks: - send_webhook.apply_async(args=(logentry.id, notification_type.action_type, wh.pk)) + send_webhook.apply_async( + args=(logentry.id, notification_type.action_type, wh.pk), + priority=get_task_priority("notifications", logentry.organizer_id), + ) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=60, acks_late=True, autoretry_for=(DatabaseError,),) diff --git a/src/pretix/base/customersso/oidc.py b/src/pretix/base/customersso/oidc.py index b093c0125b..638f0be2a3 100644 --- a/src/pretix/base/customersso/oidc.py +++ b/src/pretix/base/customersso/oidc.py @@ -112,23 +112,6 @@ def oidc_validate_and_complete_config(config): scope="openid", )) - for scope in config["scope"].split(" "): - if scope not in provider_config.get("scopes_supported", []): - raise ValidationError(_('You are requesting scope "{scope}" but provider only supports these: {scopes}.').format( - scope=scope, - scopes=", ".join(provider_config.get("scopes_supported", [])) - )) - - if "claims_supported" in provider_config: - claims_supported = provider_config.get("claims_supported", []) - for k, v in config.items(): - if k.endswith('_field') and v: - if v not in claims_supported: # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo - raise ValidationError(_('You are requesting field "{field}" but provider only supports these: {fields}.').format( - field=v, - fields=", ".join(provider_config.get("claims_supported", [])) - )) - if "token_endpoint_auth_methods_supported" in provider_config: token_endpoint_auth_methods_supported = provider_config.get("token_endpoint_auth_methods_supported", ["client_secret_basic"]) diff --git a/src/pretix/base/datasync/datasync.py b/src/pretix/base/datasync/datasync.py index cb5bf01b32..bc3c5bfaef 100644 --- a/src/pretix/base/datasync/datasync.py +++ b/src/pretix/base/datasync/datasync.py @@ -90,6 +90,7 @@ StaticMapping = namedtuple('StaticMapping', ('id', 'pretix_model', 'external_obj class OutboundSyncProvider: max_attempts = 5 + list_field_joiner = "," # set to None to keep native lists in properties def __init__(self, event): self.event = event @@ -281,7 +282,8 @@ class OutboundSyncProvider: 'Please update value mapping for field "{field_name}" - option "{val}" not assigned' ).format(field_name=key, val=val)]) - val = ",".join(val) + if self.list_field_joiner: + val = self.list_field_joiner.join(val) return val def get_properties(self, inputs: dict, property_mappings: List[dict]): diff --git a/src/pretix/base/datasync/utils.py b/src/pretix/base/datasync/utils.py index ecfd948c57..ebc98ca4e4 100644 --- a/src/pretix/base/datasync/utils.py +++ b/src/pretix/base/datasync/utils.py @@ -71,15 +71,20 @@ def assign_properties( return out -def _add_to_list(out, field_name, current_value, new_item, list_sep): - new_item = str(new_item) +def _add_to_list(out, field_name, current_value, new_item_input, list_sep): if list_sep is not None: - new_item = new_item.replace(list_sep, "") + new_items = str(new_item_input).split(list_sep) current_value = current_value.split(list_sep) if current_value else [] - elif not isinstance(current_value, (list, tuple)): - current_value = [str(current_value)] - if new_item not in current_value: - new_list = current_value + [new_item] + else: + new_items = [str(new_item_input)] + if not isinstance(current_value, (list, tuple)): + current_value = [str(current_value)] + + new_list = list(current_value) + for new_item in new_items: + if new_item not in current_value: + new_list.append(new_item) + if new_list != current_value: if list_sep is not None: new_list = list_sep.join(new_list) out[field_name] = new_list diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index e1efce0b03..ab71fb45f1 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -24,6 +24,7 @@ from itertools import groupby from smtplib import SMTPResponseException from typing import TypeVar +import bleach import css_inline from django.conf import settings from django.core.mail.backends.smtp import EmailBackend @@ -34,7 +35,10 @@ from django.utils.translation import get_language, gettext_lazy as _ from pretix.base.models import Event from pretix.base.signals import register_html_mail_renderers -from pretix.base.templatetags.rich_text import markdown_compile_email +from pretix.base.templatetags.rich_text import ( + DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback, + markdown_compile_email, truelink_callback, +) from pretix.helpers.format import SafeFormatter, format_map from pretix.base.services.placeholders import ( # noqa @@ -133,13 +137,24 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer): def template_name(self): raise NotImplementedError() - def compile_markdown(self, plaintext): - return markdown_compile_email(plaintext) + def compile_markdown(self, plaintext, context=None): + return markdown_compile_email(plaintext, context=context) def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str: - body_md = self.compile_markdown(plain_body) + body_md = self.compile_markdown(plain_body, context) if context: - body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML) + linker = bleach.Linker( + url_re=URL_RE, + email_re=EMAIL_RE, + callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback], + parse_email=True + ) + body_md = format_map( + body_md, + context=context, + mode=SafeFormatter.MODE_RICH_TO_HTML, + linkifier=linker + ) htmlctx = { 'site': settings.PRETIX_INSTANCE_NAME, 'site_url': settings.SITE_URL, diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index e7b51cd7b3..945737d352 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -610,7 +610,7 @@ class OrderListExporter(MultiSheetListExporter): headers.append(_('Attendee name') + ': ' + str(label)) headers += [ _('Attendee email'), - _('Company'), + _('Attendee company'), _('Address'), _('ZIP code'), _('City'), @@ -650,7 +650,7 @@ class OrderListExporter(MultiSheetListExporter): options[q.pk].append(o) headers.append(str(q.question)) headers += [ - _('Company'), + _('Invoice address company'), _('Invoice address name'), ] if name_scheme and len(name_scheme['fields']) > 1: diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index bb56ceb9c3..e991084e04 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -66,8 +66,10 @@ from geoip2.errors import AddressNotFoundError from phonenumber_field.formfields import PhoneNumberField from phonenumber_field.phonenumber import PhoneNumber from phonenumber_field.widgets import PhoneNumberPrefixWidget -from phonenumbers import NumberParseException, national_significant_number -from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE +from phonenumbers import ( + COUNTRY_CODE_TO_REGION_CODE, REGION_CODE_FOR_NON_GEO_ENTITY, + NumberParseException, national_significant_number, +) from PIL import ImageOps from pretix.base.forms.widgets import ( @@ -83,7 +85,7 @@ from pretix.base.invoicing.transmission import ( from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption from pretix.base.models.tax import ask_for_vat_id from pretix.base.services.tax import ( - VATIDFinalError, VATIDTemporaryError, validate_vat_id, + VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id, ) from pretix.base.settings import ( COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, @@ -305,7 +307,9 @@ class WrappedPhonePrefixSelect(Select): choices = [("", "---------")] if initial: - for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items(): + for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items(): + if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values): + continue if initial in values: self.initial = "+%d" % prefix break @@ -437,7 +441,9 @@ def guess_phone_prefix_from_request(request, event): def get_phone_prefix(country): - for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items(): + if country == REGION_CODE_FOR_NON_GEO_ENTITY: + return None + for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items(): if country in values: return prefix return None @@ -1165,13 +1171,11 @@ class BaseInvoiceAddressForm(forms.ModelForm): self.fields['vat_id'].help_text = '
'.join([ str(_('Optional, but depending on the country you reside in we might need to charge you ' 'additional taxes if you do not enter it.')), - str(_('If you are registered in Switzerland, you can enter your UID instead.')), ]) else: self.fields['vat_id'].help_text = '
'.join([ str(_('Optional, but it might be required for you to claim tax benefits on your invoice ' 'depending on your and the seller’s country of residence.')), - str(_('If you are registered in Switzerland, you can enter your UID instead.')), ]) transmission_type_choices = [ @@ -1358,13 +1362,24 @@ class BaseInvoiceAddressForm(forms.ModelForm): "transmission method.")} ) + vat_id_applicable = ( + 'vat_id' in self.fields and + data.get('is_business') and + ask_for_vat_id(data.get('country')) + ) + vat_id_required = vat_id_applicable and str(data.get('country')) in self.event.settings.invoice_address_vatid_required_countries + if vat_id_required and not data.get('vat_id'): + raise ValidationError({ + "vat_id": _("This field is required.") + }) + if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data: - pass - elif self.validate_vat_id and data.get('is_business') and ask_for_vat_id(data.get('country')) and data.get('vat_id'): + pass # Skip re-validation if it is validated + elif self.validate_vat_id and vat_id_applicable: try: normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country'))) self.instance.vat_id_validated = True - self.instance.vat_id = normalized_id + self.instance.vat_id = data['vat_id'] = normalized_id except VATIDFinalError as e: if self.all_optional: self.instance.vat_id_validated = False @@ -1372,6 +1387,9 @@ class BaseInvoiceAddressForm(forms.ModelForm): else: raise ValidationError({"vat_id": e.message}) except VATIDTemporaryError as e: + # We couldn't check it online, but we can still normalize it + normalized_id = normalize_vat_id(data.get('vat_id'), str(data.get('country'))) + self.instance.vat_id = data['vat_id'] = normalized_id self.instance.vat_id_validated = False if self.request and self.vat_warning: messages.warning(self.request, e.message) diff --git a/src/pretix/base/forms/user.py b/src/pretix/base/forms/user.py index e3083322de..773f952203 100644 --- a/src/pretix/base/forms/user.py +++ b/src/pretix/base/forms/user.py @@ -89,8 +89,6 @@ class User2FADeviceAddForm(forms.Form): class UserPasswordChangeForm(forms.Form): error_messages = { - 'pw_current': _("Please enter your current password if you want to change your email address " - "or password."), 'pw_current_wrong': _("The current password you entered was not correct."), 'pw_mismatch': _("Please enter the same password twice"), 'rate_limit': _("For security reasons, please wait 5 minutes before you try again."), @@ -103,19 +101,19 @@ class UserPasswordChangeForm(forms.Form): attrs={'autocomplete': 'username'}, )) old_pw = forms.CharField(max_length=255, - required=False, + required=True, label=_("Your current password"), widget=forms.PasswordInput( attrs={'autocomplete': 'current-password'}, )) new_pw = forms.CharField(max_length=255, - required=False, + required=True, label=_("New password"), widget=forms.PasswordInput( attrs={'autocomplete': 'new-password'}, )) new_pw_repeat = forms.CharField(max_length=255, - required=False, + required=True, label=_("Repeat new password"), widget=forms.PasswordInput( attrs={'autocomplete': 'new-password'}, @@ -130,7 +128,7 @@ class UserPasswordChangeForm(forms.Form): def clean_old_pw(self): old_pw = self.cleaned_data.get('old_pw') - if old_pw and settings.HAS_REDIS: + if settings.HAS_REDIS: from django_redis import get_redis_connection rc = get_redis_connection("redis") cnt = rc.incr('pretix_pwchange_%s' % self.user.pk) @@ -141,7 +139,7 @@ class UserPasswordChangeForm(forms.Form): code='rate_limit', ) - if old_pw and not check_password(old_pw, self.user.password): + if not check_password(old_pw, self.user.password): raise forms.ValidationError( self.error_messages['pw_current_wrong'], code='pw_current_wrong', @@ -151,17 +149,22 @@ class UserPasswordChangeForm(forms.Form): def clean_new_pw(self): password1 = self.cleaned_data.get('new_pw', '') - if password1 and validate_password(password1, user=self.user) is not None: + if validate_password(password1, user=self.user) is not None: raise forms.ValidationError( _(password_validators_help_texts()), code='pw_invalid' ) + if self.user.check_password(password1): + raise forms.ValidationError( + self.error_messages['pw_equal'], + code='pw_equal', + ) return password1 def clean_new_pw_repeat(self): password1 = self.cleaned_data.get('new_pw') password2 = self.cleaned_data.get('new_pw_repeat') - if password1 and password1 != password2: + if password1 != password2: raise forms.ValidationError( self.error_messages['pw_mismatch'], code='pw_mismatch' diff --git a/src/pretix/base/invoicing/pdf.py b/src/pretix/base/invoicing/pdf.py index a97d713744..2eed2f8e33 100644 --- a/src/pretix/base/invoicing/pdf.py +++ b/src/pretix/base/invoicing/pdf.py @@ -32,7 +32,6 @@ from itertools import groupby from typing import Tuple import bleach -import vat_moss.exchange_rates from bidi import get_display from django.contrib.staticfiles import finders from django.db.models import Sum @@ -47,7 +46,6 @@ from reportlab.lib.styles import ParagraphStyle, StyleSheet1 from reportlab.lib.units import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.pdfmetrics import stringWidth -from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfgen.canvas import Canvas from reportlab.platypus import ( BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate, @@ -60,7 +58,8 @@ from pretix.base.services.currencies import SOURCE_NAMES from pretix.base.signals import register_invoice_renderers from pretix.base.templatetags.money import money_filter from pretix.helpers.reportlab import ( - FontFallbackParagraph, ThumbnailingImageReader, reshaper, + FontFallbackParagraph, ThumbnailingImageReader, register_ttf_font_if_new, + reshaper, ) from pretix.presale.style import get_fonts @@ -235,25 +234,25 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer): """ Register fonts with reportlab. By default, this registers the OpenSans font family """ - pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf'))) - pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf'))) - pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf'))) - pdfmetrics.registerFont(TTFont('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf'))) + register_ttf_font_if_new('OpenSans', finders.find('fonts/OpenSans-Regular.ttf')) + register_ttf_font_if_new('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf')) + register_ttf_font_if_new('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf')) + register_ttf_font_if_new('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf')) pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd', italic='OpenSansIt', boldItalic='OpenSansBI') for family, styles in get_fonts(event=self.event, pdf_support_required=True).items(): - pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype']))) + register_ttf_font_if_new(family, finders.find(styles['regular']['truetype'])) if family == self.event.settings.invoice_renderer_font: self.font_regular = family if 'bold' in styles: self.font_bold = family + ' B' if 'italic' in styles: - pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype']))) + register_ttf_font_if_new(family + ' I', finders.find(styles['italic']['truetype'])) if 'bold' in styles: - pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype']))) + register_ttf_font_if_new(family + ' B', finders.find(styles['bold']['truetype'])) if 'bolditalic' in styles: - pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype']))) + register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype'])) def _normalize(self, text): # reportlab does not support unicode combination characters @@ -1059,7 +1058,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer): def fmt(val): try: - return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display) + return money_filter(val, self.invoice.foreign_currency_display) except ValueError: return localize(val) + ' ' + self.invoice.foreign_currency_display diff --git a/src/pretix/base/invoicing/peppol.py b/src/pretix/base/invoicing/peppol.py index c8a28752a9..a4d43c50d3 100644 --- a/src/pretix/base/invoicing/peppol.py +++ b/src/pretix/base/invoicing/peppol.py @@ -19,8 +19,11 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import base64 +import hashlib import re +import dns.resolver from django import forms from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _, pgettext @@ -70,6 +73,9 @@ class PeppolIdValidator: "0205": "[A-Z0-9]+", "0221": "T[0-9]{13}", "0230": ".*", + "0244": "[0-9]{13}", + "0245": "[0-9]{10}", + "0246": "DE[0-9]{9}(-[0-9]{5})?(\\.[0-9A-Z]{1,8})?", "9901": ".*", "9902": "[1-9][0-9]{7}", "9904": "DK[0-9]{8}", @@ -117,12 +123,14 @@ class PeppolIdValidator: "9951": ".*", "9952": ".*", "9953": ".*", - "9954": ".*", "9956": "0[0-9]{9}", "9957": ".*", "9959": ".*", } + def __init__(self, validate_online=False): + self.validate_online = validate_online + def __call__(self, value): if ":" not in value: raise ValidationError(_("A Peppol participant ID always starts with a prefix, followed by a colon (:).")) @@ -136,6 +144,28 @@ class PeppolIdValidator: raise ValidationError(_("The Peppol participant ID does not match the validation rules for the prefix " "%(number)s. Please reach out to us if you are sure this ID is correct."), params={"number": prefix}) + + if self.validate_online: + base_hostnames = ['edelivery.tech.ec.europa.eu', 'acc.edelivery.tech.ec.europa.eu'] + smp_id = base64.b32encode(hashlib.sha256(value.lower().encode()).digest()).decode().rstrip("=") + for base_hostname in base_hostnames: + smp_domain = f'{smp_id}.iso6523-actorid-upis.{base_hostname}' + resolver = dns.resolver.Resolver() + try: + answers = resolver.resolve(smp_domain, 'NAPTR', lifetime=1.0) + if answers: + return value + except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): + # ID not registered, do not set found=True + pass + except Exception: # noqa + # Error likely on our end or infrastructure is down, allow user to proceed + return value + + raise ValidationError( + _("The Peppol participant ID is not registered on the Peppol network."), + ) + return value @@ -155,7 +185,9 @@ class PeppolTransmissionType(TransmissionType): "transmission_peppol_participant_id": forms.CharField( label=_("Peppol participant ID"), validators=[ - PeppolIdValidator(), + PeppolIdValidator( + validate_online=True, + ), ] ), } diff --git a/src/pretix/base/modelimport.py b/src/pretix/base/modelimport.py index 43b956e1a9..e274dbe85a 100644 --- a/src/pretix/base/modelimport.py +++ b/src/pretix/base/modelimport.py @@ -47,6 +47,19 @@ class DataImportError(LazyLocaleException): super().__init__(msg) +def rename_duplicates(values): + used = set() + had_duplicates = False + for i, value in enumerate(values): + c = 0 + while values[i] in used: + c += 1 + values[i] = f'{value}__{c}' + had_duplicates = True + used.add(values[i]) + return had_duplicates + + def parse_csv(file, length=None, mode="strict", charset=None): file.seek(0) data = file.read(length) @@ -70,6 +83,7 @@ def parse_csv(file, length=None, mode="strict", charset=None): return None reader = csv.DictReader(io.StringIO(data), dialect=dialect) + reader._had_duplicates = rename_duplicates(reader.fieldnames) return reader diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index 347f4d2715..44ff3587d6 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -53,7 +53,6 @@ from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django_otp.models import Device from django_scopes import scopes_disabled -from webauthn.helpers.structs import PublicKeyCredentialDescriptor from pretix.base.i18n import language from pretix.helpers.urls import build_absolute_uri @@ -708,6 +707,8 @@ class U2FDevice(Device): @property def webauthndevice(self): + from webauthn.helpers.structs import PublicKeyCredentialDescriptor + d = json.loads(self.json_data) return PublicKeyCredentialDescriptor(websafe_decode(d['keyHandle'])) @@ -737,6 +738,8 @@ class WebAuthnDevice(Device): @property def webauthndevice(self): + from webauthn.helpers.structs import PublicKeyCredentialDescriptor + return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id)) @property diff --git a/src/pretix/base/models/base.py b/src/pretix/base/models/base.py index d8154aa8f9..0a15dd1f9d 100644 --- a/src/pretix/base/models/base.py +++ b/src/pretix/base/models/base.py @@ -31,6 +31,7 @@ from django.urls import reverse from django.utils.crypto import get_random_string from django.utils.functional import cached_property +from pretix.helpers.celery import get_task_priority from pretix.helpers.json import CustomJSONEncoder @@ -58,6 +59,37 @@ class CachedFile(models.Model): web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins session_key = models.TextField(null=True, blank=True) # only allow download in this session + def session_key_for_request(self, request, salt=None): + from ...api.models import OAuthAccessToken, OAuthApplication + from .devices import Device + from .organizer import TeamAPIToken + + if hasattr(request, "auth") and isinstance(request.auth, OAuthAccessToken): + k = f'app:{request.auth.application.pk}' + elif hasattr(request, "auth") and isinstance(request.auth, OAuthApplication): + k = f'app:{request.auth.pk}' + elif hasattr(request, "auth") and isinstance(request.auth, TeamAPIToken): + k = f'token:{request.auth.pk}' + elif hasattr(request, "auth") and isinstance(request.auth, Device): + k = f'device:{request.auth.pk}' + elif request.session.session_key: + k = request.session.session_key + else: + raise ValueError("No auth method found to bind to") + + if salt: + k = f"{k}!{salt}" + return k + + def allowed_for_session(self, request, salt=None): + return ( + not self.session_key or + self.session_key_for_request(request, salt) == self.session_key + ) + + def bind_to_session(self, request, salt=None): + self.session_key = self.session_key_for_request(request, salt) + @receiver(post_delete, sender=CachedFile) def cached_file_delete(sender, instance, **kwargs): @@ -131,9 +163,15 @@ class LoggingMixin: logentry.save() if logentry.notification_type: - notify.apply_async(args=(logentry.pk,)) + notify.apply_async( + args=(logentry.pk,), + priority=get_task_priority("notifications", logentry.organizer_id), + ) if logentry.webhook_type: - notify_webhooks.apply_async(args=(logentry.pk,)) + notify_webhooks.apply_async( + args=(logentry.pk,), + priority=get_task_priority("notifications", logentry.organizer_id), + ) return logentry diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index a6d78ed49e..dfe1616cb6 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -990,10 +990,11 @@ class Event(EventMixin, LoggedModel): ia.bundled_variation = variation_map[ia.bundled_variation.pk] ia.save(force_insert=True) - for ipt in ItemProgramTime.objects.filter(item__event=other).prefetch_related('item'): - ipt.pk = None - ipt.item = item_map[ipt.item.pk] - ipt.save(force_insert=True) + if not self.has_subevents and not other.has_subevents: + for ipt in ItemProgramTime.objects.filter(item__event=other).prefetch_related('item'): + ipt.pk = None + ipt.item = item_map[ipt.item.pk] + ipt.save(force_insert=True) quota_map = {} for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'): diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 84147ca7c9..d307ba9c7c 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -2311,6 +2311,8 @@ class ItemProgramTime(models.Model): end = models.DateTimeField(verbose_name=_("End")) def clean(self): + if hasattr(self, 'item') and self.item and self.item.event.has_subevents: + raise ValidationError(_("You cannot use program times on an event series.")) self.clean_start_end(start=self.start, end=self.end) super().clean() diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index 2ccf5c20e1..43b5439ef9 100644 --- a/src/pretix/base/models/log.py +++ b/src/pretix/base/models/log.py @@ -35,11 +35,14 @@ import json import logging +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import connections, models from django.utils.functional import cached_property +from pretix.helpers.celery import get_task_priority + class VisibleOnlyManager(models.Manager): def get_queryset(self): @@ -138,8 +141,9 @@ class LogEntry(models.Model): log_entry_type, meta = log_entry_types.get(action_type=self.action_type) if log_entry_type: + sender = self.event if self.event else self.organizer link_info = log_entry_type.get_object_link_info(self) - if is_app_active(self.event, meta['plugin']): + if is_app_active(sender, meta['plugin']): return make_link(link_info, log_entry_type.object_link_wrapper) else: return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False, @@ -186,7 +190,19 @@ class LogEntry(models.Model): to_notify = [o.id for o in objects if o.notification_type] if to_notify: - notify.apply_async(args=(to_notify,)) + organizer_ids = set(o.organizer_id for o in objects if o.notification_type) + notify.apply_async( + args=(to_notify,), + priority=settings.PRIORITY_CELERY_HIGHEST_FUNC( + get_task_priority("notifications", oid) for oid in organizer_ids + ), + ) to_wh = [o.id for o in objects if o.webhook_type] if to_wh: - notify_webhooks.apply_async(args=(to_wh,)) + organizer_ids = set(o.organizer_id for o in objects if o.webhook_type) + notify_webhooks.apply_async( + args=(to_wh,), + priority=settings.PRIORITY_CELERY_HIGHEST_FUNC( + get_task_priority("notifications", oid) for oid in organizer_ids + ), + ) diff --git a/src/pretix/base/models/seating.py b/src/pretix/base/models/seating.py index 8ddc0b605f..49b7c4b5f8 100644 --- a/src/pretix/base/models/seating.py +++ b/src/pretix/base/models/seating.py @@ -22,7 +22,6 @@ import json from collections import namedtuple -import jsonschema from django.contrib.staticfiles import finders from django.core.exceptions import ValidationError from django.db import models @@ -38,6 +37,8 @@ from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent @deconstructible class SeatingPlanLayoutValidator: def __call__(self, value): + import jsonschema + if not isinstance(value, dict): try: val = json.loads(value) diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index cab647049a..70872d5bbc 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -23,7 +23,6 @@ import json from decimal import Decimal from typing import Optional -import jsonschema from django.contrib.staticfiles import finders from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -298,6 +297,8 @@ def cc_to_vat_prefix(country_code): @deconstructible class CustomRulesValidator: def __call__(self, value): + import jsonschema + if not isinstance(value, dict): try: val = json.loads(value) diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py index a1531e9871..3b4e919c91 100644 --- a/src/pretix/base/models/vouchers.py +++ b/src/pretix/base/models/vouchers.py @@ -623,7 +623,7 @@ class Voucher(LoggedModel): return max(1, self.min_usages - self.redeemed) @classmethod - def annotate_budget_used_orders(cls, qs): + def annotate_budget_used(cls, qs): opq = OrderPosition.objects.filter( voucher_id=OuterRef('pk'), voucher_budget_use__isnull=False, @@ -632,7 +632,7 @@ class Voucher(LoggedModel): Order.STATUS_PENDING ] ).order_by().values('voucher_id').annotate(s=Sum('voucher_budget_use')).values('s') - return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=13, decimal_places=2)), Decimal('0.00'))) + return qs.annotate(budget_used=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=13, decimal_places=2)), Decimal('0.00'))) def budget_used(self): ops = OrderPosition.objects.filter( diff --git a/src/pretix/base/models/waitinglist.py b/src/pretix/base/models/waitinglist.py index 1eedd4ab3d..c4f1394f06 100644 --- a/src/pretix/base/models/waitinglist.py +++ b/src/pretix/base/models/waitinglist.py @@ -35,6 +35,7 @@ from pretix.base.email import get_email_context from pretix.base.i18n import language from pretix.base.models import User, Voucher from pretix.base.services.mail import SendMailException, mail, render_mail +from pretix.helpers import OF_SELF from ...helpers.format import format_map from ...helpers.names import build_name @@ -185,44 +186,47 @@ class WaitingListEntry(LoggedModel): if not free_seats: raise WaitingListException(_('No seat with this product is currently available.')) - if self.voucher: - raise WaitingListException(_('A voucher has already been sent to this person.')) if '@' not in self.email: raise WaitingListException(_('This entry is anonymized and can no longer be used.')) with transaction.atomic(): - e = self.email - if self.name: - e += ' / ' + self.name + locked_wle = WaitingListEntry.objects.select_for_update(of=OF_SELF).get(pk=self.pk) + if locked_wle.voucher: + raise WaitingListException(_('A voucher has already been sent to this person.')) + e = locked_wle.email + if locked_wle.name: + e += ' / ' + locked_wle.name v = Voucher.objects.create( - event=self.event, + event=locked_wle.event, max_usages=1, - valid_until=now() + timedelta(hours=self.event.settings.waiting_list_hours), - item=self.item, - variation=self.variation, + valid_until=now() + timedelta(hours=locked_wle.event.settings.waiting_list_hours), + item=locked_wle.item, + variation=locked_wle.variation, tag='waiting-list', comment=_('Automatically created from waiting list entry for {email}').format( email=e ), block_quota=True, - subevent=self.subevent, + subevent=locked_wle.subevent, ) v.log_action('pretix.voucher.added', { - 'item': self.item.pk, - 'variation': self.variation.pk if self.variation else None, + 'item': locked_wle.item.pk, + 'variation': locked_wle.variation.pk if locked_wle.variation else None, 'tag': 'waiting-list', 'block_quota': True, 'valid_until': v.valid_until.isoformat(), 'max_usages': 1, - 'subevent': self.subevent.pk if self.subevent else None, + 'subevent': locked_wle.subevent.pk if locked_wle.subevent else None, 'source': 'waitinglist', }, user=user, auth=auth) v.log_action('pretix.voucher.added.waitinglist', { - 'email': self.email, - 'waitinglistentry': self.pk, + 'email': locked_wle.email, + 'waitinglistentry': locked_wle.pk, }, user=user, auth=auth) - self.voucher = v - self.save() + locked_wle.voucher = v + locked_wle.save() + + self.refresh_from_db() with language(self.locale, self.event.settings.region): self.send_mail( diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index 7117551ff8..5d11c35045 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -47,7 +47,6 @@ from collections import OrderedDict, defaultdict from functools import partial from io import BytesIO -import jsonschema import pypdf import pypdf.generic import reportlab.rl_config @@ -72,9 +71,7 @@ from reportlab.lib.colors import Color from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT from reportlab.lib.styles import ParagraphStyle from reportlab.lib.units import mm -from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.pdfmetrics import getAscentDescent -from reportlab.pdfbase.ttfonts import TTFont from reportlab.pdfgen.canvas import Canvas from reportlab.platypus import Paragraph @@ -85,7 +82,9 @@ from pretix.base.signals import layout_image_variables, layout_text_variables from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.phone_format import phone_format from pretix.helpers.daterange import datetimerange -from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper +from pretix.helpers.reportlab import ( + ThumbnailingImageReader, register_ttf_font_if_new, reshaper, +) from pretix.presale.style import get_fonts logger = logging.getLogger(__name__) @@ -795,19 +794,19 @@ class Renderer: def _register_fonts(cls, event: Event = None): if hasattr(cls, '_fonts_registered'): return - pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf'))) - pdfmetrics.registerFont(TTFont('Open Sans I', finders.find('fonts/OpenSans-Italic.ttf'))) - 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'))) + register_ttf_font_if_new('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')) + register_ttf_font_if_new('Open Sans I', finders.find('fonts/OpenSans-Italic.ttf')) + register_ttf_font_if_new('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')) + register_ttf_font_if_new('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')) for family, styles in get_fonts(event, pdf_support_required=True).items(): - pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype']))) + register_ttf_font_if_new(family, finders.find(styles['regular']['truetype'])) if 'italic' in styles: - pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype']))) + register_ttf_font_if_new(family + ' I', finders.find(styles['italic']['truetype'])) if 'bold' in styles: - pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype']))) + register_ttf_font_if_new(family + ' B', finders.find(styles['bold']['truetype'])) if 'bolditalic' in styles: - pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype']))) + register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype'])) cls._fonts_registered = True @@ -1311,6 +1310,8 @@ def _correct_page_media_box(page: pypdf.PageObject): @deconstructible class PdfLayoutValidator: def __call__(self, value): + import jsonschema + if not isinstance(value, dict): try: val = json.loads(value) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 66c7c47bd3..97e7471535 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -97,6 +97,10 @@ class CartError(Exception): super().__init__(msg) +class CartPositionError(CartError): + pass + + error_messages = { 'busy': gettext_lazy( 'We were not able to process your request completely as the ' @@ -106,6 +110,9 @@ error_messages = { 'unknown_position': gettext_lazy('Unknown cart position.'), 'subevent_required': pgettext_lazy('subevent', 'No date was specified.'), 'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'), + 'positions_removed': gettext_lazy( + 'Some products can no longer be purchased and have been removed from your cart for the following reason: %s' + ), 'unavailable': gettext_lazy( 'Some of the products you selected are no longer available. ' 'Please see below for details.' @@ -258,6 +265,138 @@ def _get_voucher_availability(event, voucher_use_diff, now_dt, exclude_position_ return vouchers_ok, _voucher_depend_on_cart +def _check_position_constraints( + event: Event, item: Item, variation: ItemVariation, voucher: Voucher, subevent: SubEvent, + seat: Seat, sales_channel: SalesChannel, already_in_cart: bool, cart_is_expired: bool, real_now_dt: datetime, + item_requires_seat: bool, is_addon: bool, is_bundled: bool, +): + """ + Checks if a cart position with the given constraints can still be sold. This checks configuration and time-based + constraints of item, subevent, and voucher. + + It does NOT + - check if quota/voucher/seat are still available + - check prices + - check memberships + - perform any checks that go beyond the single line (like item.max_per_order) + """ + time_machine_now_dt = time_machine_now(real_now_dt) + # Item or variation disabled + # Item disabled or unavailable by time + if not item.is_available(time_machine_now_dt) or (variation and not variation.is_available(time_machine_now_dt)): + raise CartPositionError(error_messages['unavailable']) + + # Invalid media policy for online sale + if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): + mt = MEDIA_TYPES[item.media_type] + if not mt.medium_created_by_server: + raise CartPositionError(error_messages['media_usage_not_implemented']) + elif item.media_policy == Item.MEDIA_POLICY_REUSE: + raise CartPositionError(error_messages['media_usage_not_implemented']) + + # Item removed from sales channel + if not item.all_sales_channels: + if sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()): + raise CartPositionError(error_messages['unavailable']) + + # Variation removed from sales channel + if variation and not variation.all_sales_channels: + if sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()): + raise CartPositionError(error_messages['unavailable']) + + # Item disabled or unavailable by time in subevent + if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available(time_machine_now_dt): + raise CartPositionError(error_messages['not_for_sale']) + + # Variation disabled or unavailable by time in subevent + if subevent and variation and variation.pk in subevent.var_overrides and \ + not subevent.var_overrides[variation.pk].is_available(time_machine_now_dt): + raise CartPositionError(error_messages['not_for_sale']) + + # Item requires a variation (should never happen) + if item.has_variations and not variation: + raise CartPositionError(error_messages['not_for_sale']) + + # Variation belongs to wrong item (should never happen) + if variation and variation.item_id != item.pk: + raise CartPositionError(error_messages['not_for_sale']) + + # Voucher does not apply to product + if voucher and not voucher.applies_to(item, variation): + raise CartPositionError(error_messages['voucher_invalid_item']) + + # Voucher does not apply to seat + if voucher and voucher.seat and voucher.seat != seat: + raise CartPositionError(error_messages['voucher_invalid_seat']) + + # Voucher does not apply to subevent + if voucher and voucher.subevent_id and voucher.subevent_id != subevent.pk: + raise CartPositionError(error_messages['voucher_invalid_subevent']) + + # Voucher expired + if voucher and voucher.valid_until and voucher.valid_until < time_machine_now_dt: + raise CartPositionError(error_messages['voucher_expired']) + + # Subevent has been disabled + if subevent and not subevent.active: + raise CartPositionError(error_messages['inactive_subevent']) + + # Subevent sale not started + if subevent and subevent.effective_presale_start and time_machine_now_dt < subevent.effective_presale_start: + raise CartPositionError(error_messages['not_started']) + + # Subevent sale has ended + if subevent and subevent.presale_has_ended: + raise CartPositionError(error_messages['ended']) + + # Payment for subevent no longer possible + if subevent: + tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper) + if tlv: + term_last = make_aware(datetime.combine( + tlv.datetime(subevent).date(), + time(hour=23, minute=59, second=59) + ), event.timezone) + if term_last < time_machine_now_dt: + raise CartPositionError(error_messages['payment_ended']) + + # Seat required but no seat given + if item_requires_seat and not seat: + raise CartPositionError(error_messages['seat_invalid']) + + # Seat given but no seat required + if seat and not item_requires_seat: + raise CartPositionError(error_messages['seat_forbidden']) + + # Item requires to be add-on but is top-level position + if item.category and item.category.is_addon and not is_addon: + raise CartPositionError(error_messages['addon_only']) + + # Item requires bundling but is top-level position + if item.require_bundling and not is_bundled: + raise CartPositionError(error_messages['bundled_only']) + + # Seat for wrong product + if seat and seat.product != item: + raise CartPositionError(error_messages['seat_invalid']) + + # Seat blocked + if seat and seat.blocked and sales_channel.identifier not in event.settings.seating_allow_blocked_seats_for_channel: + raise CartPositionError(error_messages['seat_invalid']) + + # Item requires voucher but no voucher given + if item.require_voucher and voucher is None and not is_bundled: + raise CartPositionError(error_messages['voucher_required']) + + # Item or variation is hidden without voucher but no voucher is given + if ( + (item.hide_without_voucher or (variation and variation.hide_without_voucher)) and + (voucher is None or not voucher.show_hidden_items) and + not is_bundled + ): + raise CartPositionError(error_messages['voucher_required']) + + class CartManager: AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas', 'addon_to', 'subevent', 'bundled', 'seat', 'listed_price', @@ -294,6 +433,7 @@ class CartManager: self._widget_data = widget_data or {} self._sales_channel = sales_channel self.num_extended_positions = 0 + self.price_change_for_extended = False if reservation_time: self._reservation_time = reservation_time @@ -421,14 +561,14 @@ class CartManager: if cartsize > limit: raise CartError(error_messages['max_items'] % limit) - def _check_item_constraints(self, op, current_ops=[]): + def _check_item_constraints(self, op): if isinstance(op, (self.AddOperation, self.ExtendOperation)): if not ( (isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or (isinstance(op, self.ExtendOperation) and op.position.is_bundled) ): if op.item.require_voucher and op.voucher is None: - if getattr(op, 'voucher_ignored', False): + if getattr(op, 'voucher_ignored', False): # todo?? raise CartError(error_messages['voucher_redeemed']) raise CartError(error_messages['voucher_required']) @@ -440,88 +580,39 @@ class CartManager: raise CartError(error_messages['voucher_redeemed']) raise CartError(error_messages['voucher_required']) - if not op.item.is_available() or (op.variation and not op.variation.is_available()): - raise CartError(error_messages['unavailable']) - - if op.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): - mt = MEDIA_TYPES[op.item.media_type] - if not mt.medium_created_by_server: - raise CartError(error_messages['media_usage_not_implemented']) - elif op.item.media_policy == Item.MEDIA_POLICY_REUSE: - raise CartError(error_messages['media_usage_not_implemented']) - - if not op.item.all_sales_channels: - if self._sales_channel.identifier not in (s.identifier for s in op.item.limit_sales_channels.all()): - raise CartError(error_messages['unavailable']) - - if op.variation and not op.variation.all_sales_channels: - if self._sales_channel.identifier not in (s.identifier for s in op.variation.limit_sales_channels.all()): - raise CartError(error_messages['unavailable']) - - if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available(): - raise CartError(error_messages['not_for_sale']) - - if op.subevent and op.variation and op.variation.pk in op.subevent.var_overrides and \ - not op.subevent.var_overrides[op.variation.pk].is_available(): - raise CartError(error_messages['not_for_sale']) - - if op.item.has_variations and not op.variation: - raise CartError(error_messages['not_for_sale']) - - if op.variation and op.variation.item_id != op.item.pk: - raise CartError(error_messages['not_for_sale']) - - if op.voucher and not op.voucher.applies_to(op.item, op.variation): - raise CartError(error_messages['voucher_invalid_item']) - - if op.voucher and op.voucher.seat and op.voucher.seat != op.seat: - raise CartError(error_messages['voucher_invalid_seat']) - - if op.voucher and op.voucher.subevent_id and op.voucher.subevent_id != op.subevent.pk: - raise CartError(error_messages['voucher_invalid_subevent']) - - if op.subevent and not op.subevent.active: - raise CartError(error_messages['inactive_subevent']) - - if op.subevent and op.subevent.presale_start and time_machine_now(self.real_now_dt) < op.subevent.presale_start: - raise CartError(error_messages['not_started']) - - if op.subevent and op.subevent.presale_has_ended: - raise CartError(error_messages['ended']) - - seated = self._is_seated(op.item, op.subevent) - if ( - seated and ( - not op.seat or ( - op.seat.blocked and - self._sales_channel.identifier not in self.event.settings.seating_allow_blocked_seats_for_channel - ) - ) - ): - raise CartError(error_messages['seat_invalid']) - elif op.seat and not seated: - raise CartError(error_messages['seat_forbidden']) - elif op.seat and op.seat.product != op.item: - raise CartError(error_messages['seat_invalid']) - elif op.seat and op.count > 1: + if op.seat and op.count > 1: raise CartError('Invalid request: A seat can only be bought once.') - if op.subevent: - tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) - if tlv: - term_last = make_aware(datetime.combine( - tlv.datetime(op.subevent).date(), - time(hour=23, minute=59, second=59) - ), self.event.timezone) - if term_last < time_machine_now(self.real_now_dt): - raise CartError(error_messages['payment_ended']) + if isinstance(op, self.AddOperation): + is_addon = op.addon_to + is_bundled = op.addon_to == "FAKE" + else: + is_addon = op.position.addon_to + is_bundled = op.position.is_bundled - if isinstance(op, self.AddOperation): - if op.item.category and op.item.category.is_addon and not (op.addon_to and op.addon_to != 'FAKE'): - raise CartError(error_messages['addon_only']) - - if op.item.require_bundling and not op.addon_to == 'FAKE': - raise CartError(error_messages['bundled_only']) + try: + _check_position_constraints( + event=self.event, + item=op.item, + variation=op.variation, + voucher=op.voucher, + subevent=op.subevent, + seat=op.seat, + sales_channel=self._sales_channel, + already_in_cart=isinstance(op, self.ExtendOperation), + cart_is_expired=isinstance(op, self.ExtendOperation), + real_now_dt=self.real_now_dt, + item_requires_seat=self._is_seated(op.item, op.subevent), + is_addon=is_addon, + is_bundled=is_bundled, + ) + # Quota, seat, and voucher availability is checked for in perform_operations + # Price changes are checked for in extend_expired_positions + except CartPositionError as e: + if e.args[0] == error_messages['voucher_required'] and getattr(op, 'voucher_ignored', False): + # This is the case where someone clicks +1 on a voucher-only item with a fully redeemed voucher: + raise CartPositionError(error_messages['voucher_redeemed']) + raise def _get_price(self, item: Item, variation: Optional[ItemVariation], voucher: Optional[Voucher], custom_price: Optional[Decimal], @@ -541,7 +632,7 @@ class CartManager: else: raise e - def extend_expired_positions(self): + def _extend_expired_positions(self): requires_seat = Exists( SeatCategoryMapping.objects.filter( Q(product=OuterRef('item')) @@ -604,10 +695,14 @@ class CartManager: quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price, price_after_voucher=price_after_voucher, ) - self._check_item_constraints(op) + try: + self._check_item_constraints(op) + except CartPositionError as e: + self._operations.append(self.RemoveOperation(position=cp)) + err = error_messages['positions_removed'] % str(e) if cp.voucher: - self._voucher_use_diff[cp.voucher] += 2 + self._voucher_use_diff[cp.voucher] += 1 self._operations.append(op) return err @@ -797,7 +892,7 @@ class CartManager: custom_price_input_is_net=False, voucher_ignored=False, ) - self._check_item_constraints(bop, operations) + self._check_item_constraints(bop) bundled.append(bop) listed_price = get_listed_price(item, variation, subevent) @@ -836,7 +931,7 @@ class CartManager: custom_price_input_is_net=self.event.settings.display_net_prices, voucher_ignored=voucher_ignored, ) - self._check_item_constraints(op, operations) + self._check_item_constraints(op) operations.append(op) self._quota_diff.update(quota_diff) @@ -975,7 +1070,7 @@ class CartManager: custom_price_input_is_net=self.event.settings.display_net_prices, voucher_ignored=False, ) - self._check_item_constraints(op, operations) + self._check_item_constraints(op) operations.append(op) # Check constraints on the add-on combinations @@ -1172,7 +1267,9 @@ class CartManager: op.position.delete() elif isinstance(op, (self.AddOperation, self.ExtendOperation)): - # Create a CartPosition for as much items as we can + if isinstance(op, self.ExtendOperation) and (op.position.pk in deleted_positions or not op.position.pk): + continue # Already deleted in other operation + # Create a CartPosition for as many items as we can requested_count = quota_available_count = voucher_available_count = op.count if op.seat: @@ -1343,6 +1440,8 @@ class CartManager: addons.delete() op.position.delete() elif available_count == 1: + if op.price_after_voucher != op.position.price_after_voucher: + self.price_change_for_extended = True op.position.expires = self._expiry op.position.max_extend = self._max_expiry_extend op.position.listed_price = op.listed_price @@ -1361,6 +1460,11 @@ class CartManager: deleted_positions.add(op.position.pk) addons.delete() op.position.delete() + if op.position.is_bundled: + deleted_positions |= {a.pk for a in op.position.addon_to.addons.all()} + deleted_positions.add(op.position.addon_to.pk) + op.position.addon_to.addons.all().delete() + op.position.addon_to.delete() else: raise AssertionError("ExtendOperation cannot affect more than one item") elif isinstance(op, self.VoucherOperation): @@ -1439,15 +1543,24 @@ class CartManager: return diff + def _remove_parents_if_bundles_are_removed(self): + removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)} + for op in self._operations: + if isinstance(op, self.RemoveOperation): + if op.position.is_bundled and op.position.addon_to_id not in removed_positions: + self._operations.append(self.RemoveOperation(position=op.position.addon_to)) + removed_positions.add(op.position.addon_to_id) + def commit(self): self._check_presale_dates() self._check_max_cart_size() err = self._delete_out_of_timeframe() - err = self.extend_expired_positions() or err + err = self._extend_expired_positions() or err err = err or self._check_min_per_voucher() self._extend_expiry_of_valid_existing_positions() + self._remove_parents_if_bundles_are_removed() err = self._perform_operations() or err self.recompute_final_prices_and_taxes() @@ -1703,7 +1816,12 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', try: cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm.commit() - return {"success": cm.num_extended_positions, "expiry": cm._expiry, "max_expiry_extend": cm._max_expiry_extend} + return { + "success": cm.num_extended_positions, + "expiry": cm._expiry, + "max_expiry_extend": cm._max_expiry_extend, + "price_changed": cm.price_change_for_extended, + } except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 45d65c4574..6db88be3ee 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -696,7 +696,7 @@ def retry_stuck_invoices(sender, **kwargs): with transaction.atomic(): qs = Invoice.objects.filter( transmission_status=Invoice.TRANSMISSION_STATUS_INFLIGHT, - transmission_date__lte=now() - timedelta(hours=24), + transmission_date__lte=now() - timedelta(hours=48), ).select_for_update( of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked ) diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index 03bd489e7e..a21eec4194 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -47,7 +47,6 @@ from urllib.parse import urljoin, urlparse from zoneinfo import ZoneInfo import requests -from bs4 import BeautifulSoup from celery import chain from celery.exceptions import MaxRetriesExceededError from django.conf import settings @@ -222,7 +221,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La 'invoice_company': '' }) renderer = ClassicMailRenderer(None, organizer) - content_plain = body_plain = render_mail(template, context) + body_plain = render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN) subject = str(subject).format_map(TolerantDict(context)) sender = ( sender or @@ -316,6 +315,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La with override(timezone): try: + content_plain = render_mail(template, context, placeholder_mode=None) if plain_text_only: body_html = None elif 'context' in inspect.signature(renderer.render).parameters: @@ -751,11 +751,11 @@ def mail_send(*args, **kwargs): mail_send_task.apply_async(args=args, kwargs=kwargs) -def render_mail(template, context): +def render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN): if isinstance(template, LazyI18nString): body = str(template) - if context: - body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH) + if context and placeholder_mode: + body = format_map(body, context, mode=placeholder_mode) else: tpl = get_template(template) body = tpl.render(context) @@ -763,6 +763,8 @@ def render_mail(template, context): def replace_images_with_cid_paths(body_html): + from bs4 import BeautifulSoup + if body_html: email = BeautifulSoup(body_html, "lxml") cid_images = [] diff --git a/src/pretix/base/services/notifications.py b/src/pretix/base/services/notifications.py index 357ad8fc64..5a75dfafbe 100644 --- a/src/pretix/base/services/notifications.py +++ b/src/pretix/base/services/notifications.py @@ -32,6 +32,7 @@ from pretix.base.services.mail import mail_send_task from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask from pretix.base.signals import notification from pretix.celery_app import app +from pretix.helpers.celery import get_task_priority from pretix.helpers.urls import build_absolute_uri @@ -88,12 +89,18 @@ def notify(logentry_ids: list): for um, enabled in notify_specific.items(): user, method = um if enabled: - send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method)) + send_notification.apply_async( + args=(logentry.id, notification_type.action_type, user.pk, method), + priority=get_task_priority("notifications", logentry.organizer_id), + ) for um, enabled in notify_global.items(): user, method = um if enabled and um not in notify_specific: - send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method)) + send_notification.apply_async( + args=(logentry.id, notification_type.action_type, user.pk, method), + priority=get_task_priority("notifications", logentry.organizer_id), + ) notification.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index e8be424cf5..5c9556f141 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -81,7 +81,7 @@ from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule from pretix.base.payment import GiftCardPayment, PaymentException from pretix.base.reldate import RelativeDateWrapper from pretix.base.secrets import assign_ticket_secret -from pretix.base.services import tickets +from pretix.base.services import cart, tickets from pretix.base.services.invoices import ( generate_cancellation, generate_invoice, invoice_qualified, invoice_transmission_separately, order_invoice_transmission_separately, @@ -130,6 +130,9 @@ class OrderError(Exception): error_messages = { + 'positions_removed': gettext_lazy( + 'Some products can no longer be purchased and have been removed from your cart for the following reason: %s' + ), 'unavailable': gettext_lazy( 'Some of the products you selected were no longer available. ' 'Please see below for details.' @@ -182,14 +185,6 @@ error_messages = { 'The voucher code used for one of the items in your cart is not valid for this item. We removed this item from your cart.' ), 'voucher_required': gettext_lazy('You need a valid voucher code to order one of the products.'), - 'some_subevent_not_started': gettext_lazy( - 'The booking period for one of the events in your cart has not yet started. The ' - 'affected positions have been removed from your cart.' - ), - 'some_subevent_ended': gettext_lazy( - 'The booking period for one of the events in your cart has ended. The affected ' - 'positions have been removed from your cart.' - ), 'seat_invalid': gettext_lazy('One of the seats in your order was invalid, we removed the position from your cart.'), 'seat_unavailable': gettext_lazy('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'), 'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'), @@ -744,12 +739,37 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti deleted_positions.add(cp.pk) cp.delete() - sorted_positions = sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk)) + sorted_positions = list(sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk))) for cp in sorted_positions: cp._cached_quotas = list(cp.quotas) + for cp in sorted_positions: + try: + cart._check_position_constraints( + event=event, + item=cp.item, + variation=cp.variation, + voucher=cp.voucher, + subevent=cp.subevent, + seat=cp.seat, + sales_channel=sales_channel, + already_in_cart=True, + cart_is_expired=cp.expires < now_dt, + real_now_dt=now_dt, + item_requires_seat=cp.requires_seat, + is_addon=bool(cp.addon_to_id), + is_bundled=bool(cp.addon_to_id) and cp.is_bundled, + ) + # Quota, seat, and voucher availability is checked for below + # Prices are checked for below + # Memberships are checked in _create_order + except cart.CartPositionError as e: + err = error_messages['positions_removed'] % str(e) + delete(cp) + # Create locks + sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted if any(cp.expires < now() + timedelta(seconds=LOCK_TRUST_WINDOW) for cp in sorted_positions): # No need to perform any locking if the cart positions still guarantee everything long enough. full_lock_required = any( @@ -774,15 +794,12 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti # Check availability for i, cp in enumerate(sorted_positions): - if cp.pk in deleted_positions: + if cp.pk in deleted_positions or not cp.pk: continue - if not cp.item.is_available() or (cp.variation and not cp.variation.is_available()): - err = err or error_messages['unavailable'] - delete(cp) - continue quotas = cp._cached_quotas + # Product per order limits products_seen[cp.item] += 1 if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order: err = error_messages['max_items_per_product'] % { @@ -792,6 +809,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti delete(cp) break + # Voucher availability if cp.voucher: v_usages[cp.voucher] += 1 if cp.voucher not in v_avail: @@ -806,48 +824,14 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti delete(cp) continue - if cp.subevent and cp.subevent.presale_start and time_machine_now_dt < cp.subevent.presale_start: - err = err or error_messages['some_subevent_not_started'] - delete(cp) - break - - if cp.subevent: - tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper) - if tlv: - term_last = make_aware(datetime.combine( - tlv.datetime(cp.subevent).date(), - time(hour=23, minute=59, second=59) - ), event.timezone) - if term_last < time_machine_now_dt: - err = err or error_messages['some_subevent_ended'] - delete(cp) - break - - if cp.subevent and cp.subevent.presale_has_ended: - err = err or error_messages['some_subevent_ended'] - delete(cp) - break - - if (cp.requires_seat and not cp.seat) or (cp.seat and not cp.requires_seat) or (cp.seat and cp.seat.product != cp.item) or cp.seat in seats_seen: + # Check duplicate seats in order + if cp.seat in seats_seen: err = err or error_messages['seat_invalid'] delete(cp) break + if cp.seat: seats_seen.add(cp.seat) - - if cp.item.require_voucher and cp.voucher is None and not cp.is_bundled: - delete(cp) - err = err or error_messages['voucher_required'] - break - - if (cp.item.hide_without_voucher or (cp.variation and cp.variation.hide_without_voucher)) and ( - cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation) - ) and not cp.is_bundled: - delete(cp) - err = error_messages['voucher_required'] - break - - if cp.seat: # Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every # time, since we absolutely can not overbook a seat. if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel.identifier): @@ -855,34 +839,13 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti delete(cp) continue - if cp.expires >= now_dt and not cp.voucher: - # Other checks are not necessary - continue - + # Check useful quota configuration if len(quotas) == 0: err = err or error_messages['unavailable'] delete(cp) continue - if cp.subevent and cp.item.pk in cp.subevent.item_overrides and not cp.subevent.item_overrides[cp.item.pk].is_available(time_machine_now_dt): - err = err or error_messages['unavailable'] - delete(cp) - continue - - if cp.subevent and cp.variation and cp.variation.pk in cp.subevent.var_overrides and \ - not cp.subevent.var_overrides[cp.variation.pk].is_available(time_machine_now_dt): - err = err or error_messages['unavailable'] - delete(cp) - continue - - if cp.voucher: - if cp.voucher.valid_until and cp.voucher.valid_until < time_machine_now_dt: - err = err or error_messages['voucher_expired'] - delete(cp) - continue - quota_ok = True - ignore_all_quotas = cp.expires >= now_dt or ( cp.voucher and ( cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None) @@ -914,7 +877,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti }) # Check prices - sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] + sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted old_total = sum(cp.price for cp in sorted_positions) for i, cp in enumerate(sorted_positions): if cp.listed_price is None: @@ -945,7 +908,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti delete(cp) continue - sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] + sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted discount_results = apply_discounts( event, sales_channel.identifier, @@ -1667,7 +1630,7 @@ class OrderChangeManager: MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership')) CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff')) AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership', - 'valid_from', 'valid_until', 'is_bundled')) + 'valid_from', 'valid_until', 'is_bundled', 'result')) SplitOperation = namedtuple('SplitOperation', ('position',)) FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff')) AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff')) @@ -1679,6 +1642,18 @@ class OrderChangeManager: AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked')) RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked')) + class AddPositionResult: + _position: Optional[OrderPosition] + + def __init__(self): + self._position = None + + @property + def position(self) -> OrderPosition: + if self._position is None: + raise RuntimeError("Order position has not been created yet. Call commit() first on OrderChangeManager.") + return self._position + def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False): self.order = order self.user = user @@ -1883,7 +1858,7 @@ class OrderChangeManager: def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None, subevent: SubEvent = None, seat: Seat = None, membership: Membership = None, - valid_from: datetime = None, valid_until: datetime = None): + valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult': if isinstance(seat, str): if not seat: seat = None @@ -1942,8 +1917,11 @@ class OrderChangeManager: self._quotadiff.update(new_quotas) if seat: self._seatdiff.update([seat]) + + result = self.AddPositionResult() self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership, - valid_from, valid_until, is_bundled)) + valid_from, valid_until, is_bundled, result)) + return result def split(self, position: OrderPosition): if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): @@ -2562,6 +2540,7 @@ class OrderChangeManager: 'valid_from': op.valid_from.isoformat() if op.valid_from else None, 'valid_until': op.valid_until.isoformat() if op.valid_until else None, }) + op.result._position = pos elif isinstance(op, self.SplitOperation): position = position_cache.setdefault(op.position.pk, op.position) split_positions.append(position) diff --git a/src/pretix/base/services/placeholders.py b/src/pretix/base/services/placeholders.py index c4cdc03c3f..8ce503dab6 100644 --- a/src/pretix/base/services/placeholders.py +++ b/src/pretix/base/services/placeholders.py @@ -26,7 +26,7 @@ from decimal import Decimal from django.dispatch import receiver from django.utils.formats import date_format -from django.utils.html import escape +from django.utils.html import escape, mark_safe from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ @@ -123,6 +123,10 @@ class BaseRichTextPlaceholder(BaseTextPlaceholder): def identifier(self): return self._identifier + @property + def allowed_in_plain_content(self): + return False + @property def required_context(self): return self._args @@ -194,6 +198,33 @@ class SimpleButtonPlaceholder(BaseRichTextPlaceholder): return f'{text}: {url}' +class MarkdownTextPlaceholder(BaseRichTextPlaceholder): + def __init__(self, identifier, args, func, sample, inline): + super().__init__(identifier, args) + self._func = func + self._sample = sample + self._snippet = inline + + @property + def allowed_in_plain_content(self): + return self._snippet + + def render_plain(self, **context): + return self._func(**{k: context[k] for k in self._args}) + + def render_html(self, **context): + return mark_safe(markdown_compile_email(self.render_plain(**context), snippet=self._snippet)) + + def render_sample_plain(self, event): + if callable(self._sample): + return self._sample(event) + else: + return self._sample + + def render_sample_html(self, event): + return mark_safe(markdown_compile_email(self.render_sample_plain(event), snippet=self._snippet)) + + class PlaceholderContext(SafeFormatter): """ Holds the contextual arguments and corresponding list of available placeholders for formatting @@ -574,7 +605,7 @@ def base_placeholders(sender, **kwargs): 'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '', _('Sample Corporation') ), - SimpleFunctionalTextPlaceholder( + MarkdownTextPlaceholder( 'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join( '* {} - {}'.format( order.full_code, @@ -604,6 +635,7 @@ def base_placeholders(sender, **kwargs): {'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'} ] ), + inline=False, ), SimpleFunctionalTextPlaceholder( 'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry: @@ -618,12 +650,13 @@ def base_placeholders(sender, **kwargs): 'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code, '68CYU2H6ZTP3WLK5' ), - SimpleFunctionalTextPlaceholder( + MarkdownTextPlaceholder( # join vouchers with two spaces at end of line so markdown-parser inserts a
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list), - ' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2' + '68CYU2H6ZTP3WLK5 \n7MB94KKPVEPSMVF2', + inline=False, ), - SimpleFunctionalTextPlaceholder( + MarkdownTextPlaceholder( # join vouchers with two spaces at end of line so markdown-parser inserts a
'voucher_url_list', ['event', 'voucher_list'], lambda event, voucher_list: ' \n'.join([ @@ -638,6 +671,7 @@ def base_placeholders(sender, **kwargs): ) + '?voucher=' + c for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2'] ]), + inline=False, ), SimpleFunctionalTextPlaceholder( 'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={ @@ -656,13 +690,13 @@ def base_placeholders(sender, **kwargs): 'comment', ['comment'], lambda comment: comment, _('An individual text with a reason can be inserted here.'), ), - SimpleFunctionalTextPlaceholder( + MarkdownTextPlaceholder( 'payment_info', ['order', 'payments'], _placeholder_payments, - _('The amount has been charged to your card.'), + _('The amount has been charged to your card.'), inline=False, ), - SimpleFunctionalTextPlaceholder( + MarkdownTextPlaceholder( 'payment_info', ['payment_info'], lambda payment_info: payment_info, - _('Please transfer money to this bank account: 9999-9999-9999-9999'), + _('Please transfer money to this bank account: 9999-9999-9999-9999'), inline=False, ), SimpleFunctionalTextPlaceholder( 'attendee_name', ['position'], lambda position: position.attendee_name, @@ -719,13 +753,13 @@ def base_placeholders(sender, **kwargs): )) for k, v in sender.meta_data.items(): - ph.append(SimpleFunctionalTextPlaceholder( + ph.append(MarkdownTextPlaceholder( 'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k], - v + v, inline=True, )) - ph.append(SimpleFunctionalTextPlaceholder( + ph.append(MarkdownTextPlaceholder( 'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k], - v + v, inline=True, )) return ph @@ -753,7 +787,7 @@ def get_available_placeholders(event, base_parameters, rich=False): if not isinstance(val, (list, tuple)): val = [val] for v in val: - if isinstance(v, BaseRichTextPlaceholder) and not rich: + if isinstance(v, BaseRichTextPlaceholder) and not rich and not v.allowed_in_plain_content: continue if all(rp in base_parameters for rp in v.required_context): params[v.identifier] = v @@ -767,7 +801,11 @@ def get_sample_context(event, context_parameters, rich=True): sample = v.render_sample(event) if isinstance(sample, PlainHtmlAlternativeString): context_dict[k] = PlainHtmlAlternativeString( - sample.plain, + '<{el} class="placeholder" title="{title}">{plain}'.format( + el='span', + title=lbl, + plain=escape(sample.plain), + ), '<{el} class="placeholder placeholder-html" title="{title}">{html}'.format( el='div' if sample.is_block else 'span', title=lbl, @@ -775,13 +813,13 @@ def get_sample_context(event, context_parameters, rich=True): ) ) elif str(sample).strip().startswith('* ') or str(sample).startswith(' '): - context_dict[k] = '
{}
'.format( + context_dict[k] = mark_safe('
{}
'.format( lbl, markdown_compile_email(str(sample)) - ) + )) else: - context_dict[k] = '{}'.format( + context_dict[k] = mark_safe('{}'.format( lbl, escape(sample) - ) + )) return context_dict diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index 6f03a91394..3921a1b132 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -231,7 +231,7 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep """ def _key(line): - return (line.tax_rate, line.tax_code) + return (line.tax_rate, line.tax_code or "") places = settings.CURRENCY_PLACES.get(currency, 2) minimum_unit = Decimal('1') / 10 ** places diff --git a/src/pretix/base/services/tax.py b/src/pretix/base/services/tax.py index 781374c568..2a05d36ecc 100644 --- a/src/pretix/base/services/tax.py +++ b/src/pretix/base/services/tax.py @@ -27,7 +27,6 @@ from decimal import Decimal from xml.etree import ElementTree import requests -import vat_moss.id from django.conf import settings from django.utils.translation import gettext_lazy as _ from zeep import Client, Transport @@ -42,14 +41,142 @@ logger = logging.getLogger(__name__) error_messages = { 'unavailable': _( 'Your VAT ID could not be checked, as the VAT checking service of ' - 'your country is currently not available. We will therefore ' - 'need to charge VAT on your invoice. You can get the tax amount ' - 'back via the VAT reimbursement process.' + 'your country is currently not available. We will therefore need to ' + 'charge you the same tax rate as if you did not enter a VAT ID.' ), 'invalid': _('This VAT ID is not valid. Please re-check your input.'), 'country_mismatch': _('Your VAT ID does not match the selected country.'), } +VAT_ID_PATTERNS = { + # Patterns generated by consulting the following URLs: + # + # - http://en.wikipedia.org/wiki/VAT_identification_number + # - http://ec.europa.eu/taxation_customs/vies/faq.html + # - https://euipo.europa.eu/tunnel-web/secure/webdav/guest/document_library/Documents/COSME/VAT%20numbers%20EU.pdf + # - http://www.skatteetaten.no/en/International-pages/Felles-innhold-benyttes-i-flere-malgrupper/Brochure/Guide-to-value-added-tax-in-Norway/?chapter=7159 + 'AT': { # Austria + 'regex': '^U\\d{8}$', + 'country_code': 'AT' + }, + 'BE': { # Belgium + 'regex': '^(1|0?)\\d{9}$', + 'country_code': 'BE' + }, + 'BG': { # Bulgaria + 'regex': '^\\d{9,10}$', + 'country_code': 'BG' + }, + 'CH': { # Switzerland + 'regex': '^\\dE{9}$', + 'country_code': 'CH' + }, + 'CY': { # Cyprus + 'regex': '^\\d{8}[A-Z]$', + 'country_code': 'CY' + }, + 'CZ': { # Czech Republic + 'regex': '^\\d{8,10}$', + 'country_code': 'CZ' + }, + 'DE': { # Germany + 'regex': '^\\d{9}$', + 'country_code': 'DE' + }, + 'DK': { # Denmark + 'regex': '^\\d{8}$', + 'country_code': 'DK' + }, + 'EE': { # Estonia + 'regex': '^\\d{9}$', + 'country_code': 'EE' + }, + 'EL': { # Greece + 'regex': '^\\d{9}$', + 'country_code': 'GR' + }, + 'ES': { # Spain + 'regex': '^[A-Z0-9]\\d{7}[A-Z0-9]$', + 'country_code': 'ES' + }, + 'FI': { # Finland + 'regex': '^\\d{8}$', + 'country_code': 'FI' + }, + 'FR': { # France + 'regex': '^[A-Z0-9]{2}\\d{9}$', + 'country_code': 'FR' + }, + 'GB': { # United Kingdom + 'regex': '^(GD\\d{3}|HA\\d{3}|\\d{9}|\\d{12})$', + 'country_code': 'GB' + }, + 'HR': { # Croatia + 'regex': '^\\d{11}$', + 'country_code': 'HR' + }, + 'HU': { # Hungary + 'regex': '^\\d{8}$', + 'country_code': 'HU' + }, + 'IE': { # Ireland + 'regex': '^(\\d{7}[A-Z]{1,2}|\\d[A-Z+*]\\d{5}[A-Z])$', + 'country_code': 'IE' + }, + 'IT': { # Italy + 'regex': '^\\d{11}$', + 'country_code': 'IT' + }, + 'LT': { # Lithuania + 'regex': '^(\\d{9}|\\d{12})$', + 'country_code': 'LT' + }, + 'LU': { # Luxembourg + 'regex': '^\\d{8}$', + 'country_code': 'LU' + }, + 'LV': { # Latvia + 'regex': '^\\d{11}$', + 'country_code': 'LV' + }, + 'MT': { # Malta + 'regex': '^\\d{8}$', + 'country_code': 'MT' + }, + 'NL': { # Netherlands + 'regex': '^\\d{9}B\\d{2}$', + 'country_code': 'NL' + }, + 'NO': { # Norway + 'regex': '^\\d{9}MVA$', + 'country_code': 'NO' + }, + 'PL': { # Poland + 'regex': '^\\d{10}$', + 'country_code': 'PL' + }, + 'PT': { # Portugal + 'regex': '^\\d{9}$', + 'country_code': 'PT' + }, + 'RO': { # Romania + 'regex': '^\\d{2,10}$', + 'country_code': 'RO' + }, + 'SE': { # Sweden + 'regex': '^\\d{12}$', + 'country_code': 'SE' + }, + 'SI': { # Slovenia + 'regex': '^\\d{8}$', + 'country_code': 'SI' + }, + 'SK': { # Slovakia + 'regex': '^\\d{10}$', + 'country_code': 'SK' + }, +} + class VATIDError(Exception): def __init__(self, message): @@ -64,13 +191,57 @@ class VATIDTemporaryError(VATIDError): pass +def normalize_vat_id(vat_id, country_code): + """ + Accepts a VAT ID and normaizes it, getting rid of spaces, periods, dashes + etc and converting it to upper case. + + Original function from https://github.com/wbond/vat_moss-python + Copyright (c) 2015 Will Bond + MIT License + """ + if not vat_id: + return None + + if not isinstance(vat_id, str): + raise TypeError('VAT ID is not a string') + + if len(vat_id) < 3: + raise ValueError('VAT ID must be at least three character long') + + # Normalize the ID for simpler regexes + vat_id = re.sub('\\s+', '', vat_id) + vat_id = vat_id.replace('-', '') + vat_id = vat_id.replace('.', '') + vat_id = vat_id.upper() + + # Clean the different shapes a number can take in Switzerland depending on purpse + if country_code == "CH": + vat_id = re.sub('[^A-Z0-9]', '', vat_id.replace('HR', '').replace('MWST', '')) + + # Fix people using GR prefix for Greece + if vat_id[0:2] == "GR" and country_code == "GR": + vat_id = "EL" + vat_id[2:] + + # Check if we already have a valid country prefix. If not, we try to figure out if we can + # add one, since in some countries (e.g. Italy) it's very custom to enter it without the prefix + if vat_id[:2] in VAT_ID_PATTERNS and re.match(VAT_ID_PATTERNS[vat_id[0:2]]['regex'], vat_id[2:]): + # Prefix set and prefix matches pattern, nothing to do + pass + elif re.match(VAT_ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], vat_id): + # Prefix not set but adding it fixes pattern + vat_id = cc_to_vat_prefix(country_code) + vat_id + else: + # We have no idea what this is + pass + + return vat_id + + def _validate_vat_id_NO(vat_id, country_code): # Inspired by vat_moss library - if not vat_id.startswith("NO"): - # prefix is not usually used in Norway, but expected by vat_moss library - vat_id = "NO" + vat_id try: - vat_id = vat_moss.id.normalize(vat_id) + vat_id = normalize_vat_id(vat_id, country_code) except ValueError: raise VATIDFinalError(error_messages['invalid']) @@ -104,7 +275,7 @@ def _validate_vat_id_NO(vat_id, country_code): def _validate_vat_id_EU(vat_id, country_code): # Inspired by vat_moss library try: - vat_id = vat_moss.id.normalize(vat_id) + vat_id = normalize_vat_id(vat_id, country_code) except ValueError: raise VATIDFinalError(error_messages['invalid']) @@ -112,11 +283,10 @@ def _validate_vat_id_EU(vat_id, country_code): raise VATIDFinalError(error_messages['invalid']) number = vat_id[2:] - if vat_id[:2] != cc_to_vat_prefix(country_code): raise VATIDFinalError(error_messages['country_mismatch']) - if not re.match(vat_moss.id.ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], number): + if not re.match(VAT_ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], number): raise VATIDFinalError(error_messages['invalid']) # We are relying on the country code of the normalized VAT-ID and not the user/InvoiceAddress-provided @@ -175,9 +345,12 @@ def _validate_vat_id_EU(vat_id, country_code): def _validate_vat_id_CH(vat_id, country_code): if vat_id[:3] != 'CHE': - raise VATIDFinalError(_('Your VAT ID does not match the selected country.')) + raise VATIDFinalError(error_messages['country_mismatch']) - vat_id = re.sub('[^A-Z0-9]', '', vat_id.replace('HR', '').replace('MWST', '')) + try: + vat_id = normalize_vat_id(vat_id, country_code) + except ValueError: + raise VATIDFinalError(error_messages['invalid']) try: transport = Transport( cache=SqliteCache(os.path.join(settings.CACHE_DIR, "validate_vat_id_ch_zeep_cache.db")), diff --git a/src/pretix/base/services/waitinglist.py b/src/pretix/base/services/waitinglist.py index 238212ea04..527021997d 100644 --- a/src/pretix/base/services/waitinglist.py +++ b/src/pretix/base/services/waitinglist.py @@ -113,6 +113,11 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None) lock_objects(quotas, shared_lock_objects=[event]) for wle in qs: + # add this event to wle.item as it is not yet cached and is needed in check_quotas + wle.item.event = event + if wle.variation: + wle.variation.item = wle.item + if (wle.item_id, wle.variation_id, wle.subevent_id) in gone: continue ev = (wle.subevent or event) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 7c57318173..540ab5c3a6 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -180,6 +180,19 @@ DEFAULTS = { widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}), ) }, + 'customer_accounts_require_login_for_order_access': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Require login to access order confirmation pages"), + help_text=_("If enabled, users who were logged in at the time of purchase must also log in to access their order information. " + "If a customer account is created while placing an order, the restriction only becomes active after the customer " + "account is activated."), + widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}), + ) + }, 'customer_accounts_link_by_email': { 'default': 'False', 'type': bool, @@ -629,13 +642,40 @@ DEFAULTS = { 'form_kwargs': dict( label=_("Ask for VAT ID"), help_text=format_lazy( - _("Only works if an invoice address is asked for. VAT ID is never required and only requested from " - "business customers in the following countries: {countries}"), + _("Only works if an invoice address is asked for. VAT ID is only requested from business customers " + "in the following countries: {countries}."), countries=lazy(lambda *args: ', '.join(sorted(gettext(Country(cc).name) for cc in VAT_ID_COUNTRIES)), str)() ), widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}), ) }, + 'invoice_address_vatid_required_countries': { + 'default': ['IT', 'GR'], + 'type': list, + 'form_class': forms.MultipleChoiceField, + 'serializer_class': serializers.MultipleChoiceField, + 'serializer_kwargs': dict( + choices=lazy( + lambda *args: sorted([(cc, gettext(Country(cc).name)) for cc in VAT_ID_COUNTRIES], key=lambda c: c[1]), + list + )(), + ), + 'form_kwargs': dict( + label=_("Require VAT ID in"), + choices=lazy( + lambda *args: sorted([(cc, gettext(Country(cc).name)) for cc in VAT_ID_COUNTRIES], key=lambda c: c[1]), + list + )(), + help_text=format_lazy( + _("VAT ID is optional by default, because not all businesses are assigned a VAT ID in all countries. " + "VAT ID will be required for all business addresses in the selected countries."), + ), + widget=forms.CheckboxSelectMultiple(attrs={ + "class": "scrolling-multiple-choice", + 'data-display-dependency': '#id_invoice_address_vatid' + }), + ) + }, 'invoice_address_explanation_text': { 'default': '', 'type': LazyI18nString, @@ -690,6 +730,7 @@ DEFAULTS = { label=_("Minimum length of invoice number after prefix"), help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."), max_value=12, + min_value=1, required=True, ) }, @@ -725,8 +766,9 @@ DEFAULTS = { message=lazy(lambda *args: _('Please only use the characters {allowed} in this field.').format( allowed='A-Z, a-z, 0-9, -./:#' ), str)() - ) + ), ], + max_length=155, ) }, 'invoice_numbers_prefix_cancellations': { @@ -747,8 +789,9 @@ DEFAULTS = { message=lazy(lambda *args: _('Please only use the characters {allowed} in this field.').format( allowed='A-Z, a-z, 0-9, -./:#' ), str)() - ) + ), ], + max_length=155, ) }, 'invoice_renderer_highlight_order_code': { @@ -1203,6 +1246,7 @@ DEFAULTS = { 'form_class': forms.CharField, 'serializer_class': serializers.CharField, 'form_kwargs': dict( + max_length=190, label=_("Company name"), ) }, @@ -1216,6 +1260,7 @@ DEFAULTS = { 'placeholder': '12345' }), label=_("ZIP code"), + max_length=190, ) }, 'invoice_address_from_city': { @@ -1228,6 +1273,7 @@ DEFAULTS = { 'placeholder': _('Random City') }), label=_("City"), + max_length=190, ) }, 'invoice_address_from_state': { @@ -1264,7 +1310,8 @@ DEFAULTS = { 'serializer_class': serializers.CharField, 'form_kwargs': dict( label=_("Domestic tax ID"), - help_text=_("e.g. tax number in Germany, ABN in Australia, …") + help_text=_("e.g. tax number in Germany, ABN in Australia, …"), + max_length=190, ) }, 'invoice_address_from_vat_id': { @@ -1274,6 +1321,7 @@ DEFAULTS = { 'serializer_class': serializers.CharField, 'form_kwargs': dict( label=_("EU VAT ID"), + max_length=190, ) }, 'invoice_introductory_text': { diff --git a/src/pretix/base/templatetags/html_time.py b/src/pretix/base/templatetags/html_time.py new file mode 100644 index 0000000000..55b4fc224d --- /dev/null +++ b/src/pretix/base/templatetags/html_time.py @@ -0,0 +1,65 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-today pretix GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# 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 +# . +# +from datetime import datetime + +from django import template +from django.utils.html import format_html +from django.utils.timezone import get_current_timezone + +from pretix.base.i18n import LazyExpiresDate +from pretix.helpers.templatetags.date_fast import date_fast + +register = template.Library() + + +@register.simple_tag +def html_time(value: datetime, dt_format: str = "SHORT_DATE_FORMAT", **kwargs): + """ + Building a html string, + where the html-datetime as well as the human-readable datetime can be set + to a value from django's FORMAT_SETTINGS or "format_expires". + + If attr_fmt isn’t provided, it will be set to isoformat. + + Usage example: + {% html_time event_start "SHORT_DATETIME_FORMAT" %} + or + {% html_time event_start "TIME_FORMAT" attr_fmt="H:i" %} + """ + if value in (None, ''): + return '' + value = value.astimezone(get_current_timezone()) + attr_fmt = kwargs["attr_fmt"] if kwargs else None + + try: + if not attr_fmt: + date_html = value.isoformat() + else: + date_html = date_fast(value, attr_fmt) + + if dt_format == "format_expires": + date_human = LazyExpiresDate(value) + else: + date_human = date_fast(value, dt_format) + return format_html("", date_html, date_human) + except AttributeError: + return '' diff --git a/src/pretix/base/templatetags/rich_text.py b/src/pretix/base/templatetags/rich_text.py index f38cdeab8c..2fabad485c 100644 --- a/src/pretix/base/templatetags/rich_text.py +++ b/src/pretix/base/templatetags/rich_text.py @@ -32,18 +32,20 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. +import html import re import urllib.parse import bleach import markdown -from bleach import DEFAULT_CALLBACKS -from bleach.linkifier import build_email_re, build_url_re +from bleach import DEFAULT_CALLBACKS, html5lib_shim +from bleach.linkifier import build_email_re from django import template from django.conf import settings from django.core import signing from django.urls import reverse from django.utils.functional import SimpleLazyObject +from django.utils.html import escape from django.utils.http import url_has_allowed_host_and_scheme from django.utils.safestring import mark_safe from markdown import Extension @@ -52,6 +54,8 @@ from markdown.postprocessors import Postprocessor from markdown.treeprocessors import UnescapeTreeprocessor from tlds import tld_set +from pretix.helpers.format import SafeFormatter, format_map + register = template.Library() @@ -121,6 +125,23 @@ ALLOWED_ATTRIBUTES = { ALLOWED_PROTOCOLS = {'http', 'https', 'mailto', 'tel'} + +def build_url_re(tlds=tld_set, protocols=html5lib_shim.allowed_protocols): + # Differs from bleach regex by allowing { and } in URL to allow placeholders in URL parameters + return re.compile( + r"""\(* # Match any opening parentheses. + \b(?"]*)? + # /path/zz (excluding "unsafe" chars from RFC 3986, + # except for # and ~, which happen in practice) + """.format( + "|".join(sorted(protocols)), "|".join(sorted(tlds)) + ), + re.IGNORECASE | re.VERBOSE | re.UNICODE, + ) + + URL_RE = SimpleLazyObject(lambda: build_url_re(tlds=sorted(tld_set, key=len, reverse=True))) EMAIL_RE = SimpleLazyObject(lambda: build_email_re(tlds=sorted(tld_set, key=len, reverse=True))) @@ -321,27 +342,50 @@ class LinkifyAndCleanExtension(Extension): ) -def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes=ALLOWED_ATTRIBUTES): +def markdown_compile_email(source, allowed_tags=None, allowed_attributes=ALLOWED_ATTRIBUTES, snippet=False, context=None): + if allowed_tags is None: + allowed_tags = ALLOWED_TAGS_SNIPPET if snippet else ALLOWED_TAGS + + context_callbacks = [] + if context: + # This is a workaround to fix placeholders in URL targets + def context_callback(attrs, new=False): + if (None, "href") in attrs and "{" in attrs[None, "href"]: + # Do not use MODE_RICH_TO_HTML to avoid recursive linkification. + # We want to esacpe the end result, however, we need to unescape the input to prevent & being turned + # to &amp; because the input is already escaped by the markdown parser. + attrs[None, "href"] = escape(format_map( + html.unescape(attrs[None, "href"]), + context=context, + mode=SafeFormatter.MODE_RICH_TO_PLAIN + )) + return attrs + + context_callbacks.append(context_callback) + linker = bleach.Linker( url_re=URL_RE, email_re=EMAIL_RE, - callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback], + callbacks=context_callbacks + DEFAULT_CALLBACKS + [truelink_callback, abslink_callback], parse_email=True ) + exts = [ + 'markdown.extensions.sane_lists', + 'markdown.extensions.tables', + EmailNl2BrExtension(), + LinkifyAndCleanExtension( + linker, + tags=set(allowed_tags), + attributes=allowed_attributes, + protocols=ALLOWED_PROTOCOLS, + strip=snippet, + ) + ] + if snippet: + exts.append(SnippetExtension()) return markdown.markdown( source, - extensions=[ - 'markdown.extensions.sane_lists', - 'markdown.extensions.tables', - EmailNl2BrExtension(), - LinkifyAndCleanExtension( - linker, - tags=set(allowed_tags), - attributes=allowed_attributes, - protocols=ALLOWED_PROTOCOLS, - strip=False, - ) - ] + extensions=exts ) diff --git a/src/pretix/base/timeline.py b/src/pretix/base/timeline.py index be9d1d6b9f..929422f9fe 100644 --- a/src/pretix/base/timeline.py +++ b/src/pretix/base/timeline.py @@ -93,7 +93,9 @@ def timeline_for_event(event, subevent=None): description=format_lazy( '{} ({})', pgettext_lazy('timeline', 'End of ticket sales'), - pgettext_lazy('timeline', 'automatically because the event is over and no end of presale has been configured') if not ev.presale_end else "" + pgettext_lazy('timeline', 'automatically because the event is over and no end of presale has been configured') + ) if not ev.presale_end else ( + pgettext_lazy('timeline', 'End of ticket sales') ), edit_url=ev_edit_url + '#id_presale_end_0' )) diff --git a/src/pretix/base/views/cachedfiles.py b/src/pretix/base/views/cachedfiles.py index 744ea8c1d2..487d608636 100644 --- a/src/pretix/base/views/cachedfiles.py +++ b/src/pretix/base/views/cachedfiles.py @@ -36,9 +36,8 @@ class DownloadView(TemplateView): def object(self) -> CachedFile: try: o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True) - if o.session_key: - if o.session_key != self.request.session.session_key: - raise Http404() + if not o.allowed_for_session(self.request): + raise Http404() return o except (ValueError, ValidationError): # Invalid URLs raise Http404() diff --git a/src/pretix/base/views/js_helpers.py b/src/pretix/base/views/js_helpers.py index 7da2c83952..df291cfeba 100644 --- a/src/pretix/base/views/js_helpers.py +++ b/src/pretix/base/views/js_helpers.py @@ -22,7 +22,7 @@ import pycountry from django.http import JsonResponse from django.shortcuts import get_object_or_404 -from django.utils.translation import pgettext +from django.utils.translation import gettext, pgettext, pgettext_lazy from django_countries.fields import Country from django_scopes import scope @@ -36,6 +36,28 @@ from pretix.base.settings import ( COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, ) +VAT_ID_LABELS = { + # VAT ID is a EU concept and Switzerland has a distinct, but differently-named concept + # Translators: Only translate to French (IDE) and Italien (IDI), otherwise keep the same + "CH": pgettext_lazy("tax_id_swiss", "UID"), + + # Awareness around VAT IDs differes by EU country. For example, in Germany the VAT ID is assigned + # separately to each company and only used in cross-country transactions. Therefore, it makes sense + # to call it just "VAT ID" on the form, and people will either know their VAT ID or they don't. + # In contrast, in Italy the EU-compatible VAT ID is not separately assigned, but is just "IT" + the national tax + # number (Partita IVA) and also used on domestic transactions. So someone who never purchased something international + # for their company, might still know the value, if we call it the right way and not just "VAT ID". + + # Translators: Translate to only "P.IVA" in Italian, keep second part as-is in other languages + "IT": pgettext_lazy("tax_id_italy", "VAT ID / P.IVA"), + # Translators: Translate to only "ΑΦΜ" in Greek + "GR": pgettext_lazy("tax_id_greece", "VAT ID / TIN"), + # Translators: Translate to only "NIF" in Spanish + "ES": pgettext_lazy("tax_id_spain", "VAT ID / NIF"), + # Translators: Translate to only "NIF" in Portuguese + "PT": pgettext_lazy("tax_id_portugal", "VAT ID / NIF"), +} + def _info(cc): info = { @@ -47,7 +69,12 @@ def _info(cc): 'required': 'if_any' if cc in COUNTRIES_WITH_STATE_IN_ADDRESS else False, 'label': COUNTRY_STATE_LABEL.get(cc, pgettext('address', 'State')), }, - 'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False}, + 'vat_id': { + 'visible': cc in VAT_ID_COUNTRIES, + 'required': False, + 'label': VAT_ID_LABELS.get(cc, gettext("VAT ID")), + 'helptext_visible': True, + }, } if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS: return {'data': [], **info} @@ -124,4 +151,10 @@ def address_form(request): "required": transmission_type.identifier == selected_transmission_type and k in required } + if is_business and country in event.settings.invoice_address_vatid_required_countries and info["vat_id"]["visible"]: + info["vat_id"]["required"] = True + if info["vat_id"]["required"]: + # The help text explains that it is optional, so we want to hide that if it is required + info["vat_id"]["helptext_visible"] = False + return JsonResponse(info) diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 5df8f714fe..b04c378ce7 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -42,11 +42,10 @@ import pycountry from django import forms from django.conf import settings from django.core.exceptions import NON_FIELD_ERRORS, ValidationError -from django.core.validators import MaxValueValidator from django.db.models import Prefetch, Q, prefetch_related_objects from django.forms import formset_factory, inlineformset_factory from django.urls import reverse -from django.utils.functional import cached_property +from django.utils.functional import cached_property, lazy from django.utils.html import escape, format_html from django.utils.safestring import mark_safe from django.utils.timezone import get_current_timezone_name @@ -54,7 +53,7 @@ from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy from django_countries.fields import LazyTypedChoiceField from django_scopes.forms import SafeModelMultipleChoiceField from i18nfield.forms import ( - I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput, + I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput, ) from pytz import common_timezones @@ -208,6 +207,7 @@ class EventWizardBasicsForm(I18nModelForm): 'Sample Conference Center\nHeidelberg, Germany' ) self.fields['slug'].widget.prefix = build_absolute_uri(self.organizer, 'presale:organizer.index') + self.fields['tax_rate']._required = True # Do not render as optional because it is conditionally required if self.has_subevents: del self.fields['presale_start'] del self.fields['presale_end'] @@ -374,6 +374,13 @@ class EventUpdateForm(I18nModelForm): super().__init__(*args, **kwargs) if not self.change_slug: self.fields['slug'].widget.attrs['readonly'] = 'readonly' + + if self.instance.orders.exists(): + self.fields['currency'].disabled = True + self.fields['currency'].help_text = _( + 'The currency cannot be changed because orders already exist.' + ) + self.fields['location'].widget.attrs['rows'] = '3' self.fields['location'].widget.attrs['placeholder'] = _( 'Sample Conference Center\nHeidelberg, Germany' @@ -921,6 +928,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm): 'invoice_address_asked', 'invoice_address_required', 'invoice_address_vatid', + 'invoice_address_vatid_required_countries', 'invoice_address_company_required', 'invoice_address_beneficiary', 'invoice_address_custom_field', @@ -993,8 +1001,6 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm): self.fields['invoice_generate_sales_channels'].choices = ( (c.identifier, c.label) for c in event.organizer.sales_channels.all() ) - self.fields['invoice_numbers_counter_length'].validators.append(MaxValueValidator(15)) - pps = [str(pp.verbose_name) for pp in event.get_payment_providers().values() if pp.requires_invoice_immediately] if pps: generate_paid_help_text = _('An invoice will be issued before payment if the customer selects one of the following payment methods: {list}').format( @@ -1305,9 +1311,17 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm): mail_text_order_invoice = I18nFormField( label=_("Text"), required=False, - widget=I18nMarkdownTextarea, - help_text=_("This will only be used if the invoice is sent to a different email address or at a different time " - "than the order confirmation."), + widget=I18nTextarea, # no Markdown supported + help_text=lazy( + lambda: str(_( + "This will only be used if the invoice is sent to a different email address or at a different time " + "than the order confirmation." + )) + " " + str(_( + "Formatting is not supported, as some accounting departments process mail automatically and do not " + "handle formatted emails properly." + )), + str + )() ) mail_subject_download_reminder = I18nFormField( label=_("Subject sent to order contact address"), @@ -1475,6 +1489,9 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm): 'mail_subject_resend_all_links': ['event', 'orders'], 'mail_attach_ical_description': ['event', 'event_or_subevent'], } + plain_rendering = { + 'mail_text_order_invoice', + } def __init__(self, *args, **kwargs): self.event = event = kwargs.get('obj') @@ -1493,7 +1510,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm): self.event.meta_values_cached = self.event.meta_values.select_related('property').all() for k, v in self.base_context.items(): - self._set_field_placeholders(k, v, rich=k.startswith('mail_text_')) + self._set_field_placeholders(k, v, rich=k.startswith('mail_text_') and k not in self.plain_rendering) for k, v in list(self.fields.items()): if k.endswith('_attendee') and not event.settings.attendee_emails_asked: @@ -1860,7 +1877,11 @@ class QuickSetupForm(I18nForm): self.fields['payment_banktransfer_bank_details'].required = False for f in self.fields.values(): if 'data-required-if' in f.widget.attrs: - del f.widget.attrs['data-required-if'] + f.widget.attrs['data-required-if'] += ",#id_payment_banktransfer__enabled" + + self.fields['payment_banktransfer_bank_details'].widget.attrs["data-required-if"] = ( + "#id_payment_banktransfer_bank_details_type_1,#id_payment_banktransfer__enabled" + ) def clean(self): cleaned_data = super().clean() @@ -1949,6 +1970,13 @@ class EventFooterLinkForm(I18nModelForm): class Meta: model = EventFooterLink fields = ('label', 'url') + widgets = { + "url": forms.URLInput( + attrs={ + "placeholder": "https://..." + } + ) + } class BaseEventFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet): diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 6e330bb8fc..0f3b2a1e44 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -61,6 +61,10 @@ from pretix.base.models import ( SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher, ) from pretix.base.signals import register_payment_providers +from pretix.base.timeframes import ( + DateFrameField, + resolve_timeframe_to_datetime_start_inclusive_end_exclusive, +) from pretix.control.forms import SplitDateTimeField from pretix.control.forms.widgets import Select2, Select2ItemVarQuota from pretix.control.signals import order_search_filter_q @@ -1219,6 +1223,129 @@ class OrderPaymentSearchFilterForm(forms.Form): return qs +class QuestionAnswerFilterForm(forms.Form): + STATUS_VARIANTS = [ + ("", _("All orders")), + (Order.STATUS_PAID, _("Paid")), + (Order.STATUS_PAID + 'v', _("Paid or confirmed")), + (Order.STATUS_PENDING, _("Pending")), + (Order.STATUS_PENDING + Order.STATUS_PAID, _("Pending or paid")), + ("o", _("Pending (overdue)")), + (Order.STATUS_EXPIRED, _("Expired")), + (Order.STATUS_PENDING + Order.STATUS_EXPIRED, _("Pending or expired")), + (Order.STATUS_CANCELED, _("Canceled")) + ] + + status = forms.ChoiceField( + choices=STATUS_VARIANTS, + required=False, + label=_("Order status"), + ) + item = forms.ChoiceField( + choices=[], + required=False, + label=_("Products"), + ) + subevent = forms.ModelChoiceField( + queryset=SubEvent.objects.none(), + required=False, + empty_label=pgettext_lazy('subevent', 'All dates'), + label=pgettext_lazy("subevent", "Date"), + ) + date_range = DateFrameField( + required=False, + include_future_frames=True, + label=_('Event date'), + ) + + def __init__(self, *args, **kwargs): + self.event = kwargs.pop('event') + super().__init__(*args, **kwargs) + self.initial['status'] = Order.STATUS_PENDING + Order.STATUS_PAID + + choices = [('', _('All products'))] + for i in self.event.items.prefetch_related('variations').all(): + variations = list(i.variations.all()) + if variations: + choices.append((str(i.pk), _('{product} – Any variation').format(product=str(i)))) + for v in variations: + choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (str(i), v.value))) + else: + choices.append((str(i.pk), str(i))) + self.fields['item'].choices = choices + + if self.event.has_subevents: + self.fields["subevent"].queryset = self.event.subevents.all() + self.fields['subevent'].widget = Select2( + attrs={ + 'data-model-select2': 'event', + 'data-select2-url': reverse('control:event.subevents.select2', kwargs={ + 'event': self.event.slug, + 'organizer': self.event.organizer.slug, + }), + 'data-placeholder': pgettext_lazy('subevent', 'All dates') + } + ) + self.fields['subevent'].widget.choices = self.fields['subevent'].choices + else: + del self.fields['subevent'] + + def clean(self): + cleaned_data = super().clean() + subevent = cleaned_data.get('subevent') + date_range = cleaned_data.get('date_range') + + if subevent is not None and date_range is not None: + d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone) + if ( + (d_start and not (d_start <= subevent.date_from)) or + (d_end and not (subevent.date_from < d_end)) + ): + self.add_error('subevent', pgettext_lazy('subevent', "Date doesn't start in selected date range.")) + return cleaned_data + + def filter_qs(self, opqs): + fdata = self.cleaned_data + + subevent = fdata.get('subevent', None) + date_range = fdata.get('date_range', None) + + if subevent is not None: + opqs = opqs.filter(subevent=subevent) + + if date_range is not None: + d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone) + opqs = opqs.filter( + subevent__date_from__gte=d_start, + subevent__date_from__lt=d_end + ) + + s = fdata.get("status", Order.STATUS_PENDING + Order.STATUS_PAID) + if s != "": + if s == Order.STATUS_PENDING: + opqs = opqs.filter(order__status=Order.STATUS_PENDING, + order__expires__lt=now().replace(hour=0, minute=0, second=0)) + elif s == Order.STATUS_PENDING + Order.STATUS_PAID: + opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]) + elif s == Order.STATUS_PAID + 'v': + opqs = opqs.filter( + Q(order__status=Order.STATUS_PAID) | + Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True) + ) + elif s == Order.STATUS_PENDING + Order.STATUS_EXPIRED: + opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED]) + else: + opqs = opqs.filter(order__status=s) + + if s not in (Order.STATUS_CANCELED, ""): + opqs = opqs.filter(canceled=False) + if fdata.get("item", "") != "": + i = fdata.get("item", "") + opqs = opqs.filter(item_id__in=(i,)) + + return opqs + + class SubEventFilterForm(FilterForm): orders = { 'date_from': 'date_from', diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index 9d2dc82d67..4fc22a5903 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -974,7 +974,7 @@ class EventCancelForm(FormPlaceholderMixin, forms.Form): self._set_field_placeholders('send_subject', ['event_or_subevent', 'refund_amount', 'position_or_address', 'order', 'event']) self._set_field_placeholders('send_message', ['event_or_subevent', 'refund_amount', 'position_or_address', - 'order', 'event']) + 'order', 'event'], rich=True) self.fields['send_waitinglist_subject'] = I18nFormField( label=_("Subject"), required=True, @@ -998,7 +998,7 @@ class EventCancelForm(FormPlaceholderMixin, forms.Form): )) ) self._set_field_placeholders('send_waitinglist_subject', ['event_or_subevent', 'event']) - self._set_field_placeholders('send_waitinglist_message', ['event_or_subevent', 'event']) + self._set_field_placeholders('send_waitinglist_message', ['event_or_subevent', 'event'], rich=True) if self.event.has_subevents: self.fields['subevent'].queryset = self.event.subevents.all() diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index c8f63914f6..1f10385af7 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -474,6 +474,7 @@ class OrganizerSettingsForm(SettingsForm): 'customer_accounts', 'customer_accounts_native', 'customer_accounts_link_by_email', + 'customer_accounts_require_login_for_order_access', 'invoice_regenerate_allowed', 'contact_mail', 'imprint_url', @@ -1024,6 +1025,13 @@ class OrganizerFooterLinkForm(I18nModelForm): class Meta: model = OrganizerFooterLink fields = ('label', 'url') + widgets = { + "url": forms.URLInput( + attrs={ + "placeholder": "https://..." + } + ) + } class BaseOrganizerFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet): diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py index 5138b7b7cd..63f8d3069e 100644 --- a/src/pretix/control/forms/vouchers.py +++ b/src/pretix/control/forms/vouchers.py @@ -308,8 +308,8 @@ class VoucherBulkForm(VoucherForm): ) Recipient = namedtuple('Recipient', 'email number name tag') - def _set_field_placeholders(self, fn, base_parameters): - placeholders = get_available_placeholders(self.instance.event, base_parameters) + def _set_field_placeholders(self, fn, base_parameters, rich=False): + placeholders = get_available_placeholders(self.instance.event, base_parameters, rich=rich) ht = format_placeholders_help_text(placeholders, self.instance.event) if self.fields[fn].help_text: @@ -345,7 +345,7 @@ class VoucherBulkForm(VoucherForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._set_field_placeholders('send_subject', ['event', 'name']) - self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name']) + self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name'], rich=True) with language(self.instance.event.settings.locale, self.instance.event.settings.region): for f in ("send_subject", "send_message"): diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 6c4a561175..02ccce2e4c 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -582,6 +582,7 @@ class CoreOrderLogEntryType(OrderLogEntryType): 'The voucher has been set to expire because the recipient removed themselves from the waiting list.'), 'pretix.voucher.changed': _('The voucher has been changed.'), 'pretix.voucher.deleted': _('The voucher has been deleted.'), + 'pretix.voucher.carts.deleted': _('Cart positions including the voucher have been deleted.'), 'pretix.voucher.added.waitinglist': _('The voucher has been assigned to {email} through the waiting list.'), }) class CoreVoucherLogEntryType(VoucherLogEntryType): @@ -813,7 +814,7 @@ class OrganizerPluginStateLogEntryType(LogEntryType): if app and hasattr(app, 'PretixPluginMeta'): return { 'href': reverse('control:organizer.settings.plugins', kwargs={ - 'organizer': logentry.event.organizer.slug, + 'organizer': logentry.organizer.slug, }) + '#plugin_' + logentry.parsed_data['plugin'], 'val': app.PretixPluginMeta.name } diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index f5d1834c5d..41fc60f8de 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -126,7 +126,9 @@ {% endif %} - {{ settings.PRETIX_INSTANCE_NAME }} + + {{ settings.PRETIX_INSTANCE_NAME }} +