diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7b1598129..adcbd20e8 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 975087c75..68e8639fb 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 b0fa5c142..bca924e3b 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 9f2d1999b..4d14790db 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 99d9006b9..acf3d22a8 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 00b2d261c..d72e3f6d6 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 eedf3be0a..db8a6d336 100644 --- a/doc/api/resources/item_program_times.rst +++ b/doc/api/resources/item_program_times.rst @@ -46,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/development/algorithms/pricing.rst b/doc/development/algorithms/pricing.rst index aa1c7769f..f64790e12 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 ca98fe9ab..19638c5b7 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 5de0c0984..74538aae4 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 ddda75cbd..437177516 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,18 +29,19 @@ 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-formset-js-improved==0.5.0.5", "django-formtools==2.5.1", "django-hierarkey==2.0.*,>=2.0.1", "django-hijack==3.7.*", @@ -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.1", # 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", + "pycparser==3.0", "pycryptodome==3.23.*", - "pypdf==6.3.*", + "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.45.*", + "sentry-sdk==2.50.*", "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 86d132f5e..9f5eafff1 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.1" +__version__ = "2026.1.0" diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 1f1c40cf2..58acf3d4f 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', @@ -805,6 +806,7 @@ class EventSettingsSerializer(SettingsSerializer): 'invoice_reissue_after_modify', 'invoice_include_free', 'invoice_generate', + 'invoice_generate_only_business', 'invoice_period', 'invoice_numbers_consecutive', 'invoice_numbers_prefix', @@ -943,6 +945,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/order.py b/src/pretix/api/serializers/order.py index 1459e1c26..6f5e995e0 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -191,7 +191,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer): {"transmission_info": {r: "This field is required for the selected type of invoice transmission."}} ) break # do not call else branch of for loop - elif t.exclusive: + elif t.is_exclusive(self.context["request"].event, data.get("country"), data.get("is_business")): if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")): raise ValidationError({ "transmission_type": "The transmission type '%s' must be used for this country or address type." % ( @@ -704,6 +704,16 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer): if 'answers.question' in self.context['expand']: self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True) + if 'addons' in self.context['expand']: + # Experimental feature, undocumented on purpose for now in case we need to remove it again + # for performance reasons + subl = CheckinListOrderPositionSerializer(read_only=True, many=True, context={ + **self.context, + 'expand': [v for v in self.context['expand'] if v != 'addons'], + 'pdf_data': False, + }) + self.fields['addons'] = subl + class OrderPaymentTypeField(serializers.Field): # TODO: Remove after pretix 2.2 @@ -1601,7 +1611,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): order.sales_channel, [ (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price, - bool(cp.addon_to), cp.is_bundled, pos._voucher_discount) + cp.addon_to, cp.is_bundled, pos._voucher_discount) for cp in order_positions ] ) diff --git a/src/pretix/api/serializers/orderchange.py b/src/pretix/api/serializers/orderchange.py index 10e84daf3..a92aa46b0 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 aee83af95..ce3ed39b7 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/checkin.py b/src/pretix/api/views/checkin.py index 26c0bbcae..7a7d50e97 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -381,15 +381,21 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr qs = qs.filter(reduce(operator.or_, lists_qs)) + prefetch_related = [ + Prefetch( + lookup='checkins', + queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device') + ), + Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), + 'answers', 'answers__options', 'answers__question', + ] + select_related = [ + 'item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat' + ] + if pdf_data: qs = qs.prefetch_related( - Prefetch( - lookup='checkins', - queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device') - ), - Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), - 'answers', 'answers__options', 'answers__question', - Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')), + # Don't add to list, we don't want to propagate to addons Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related( Prefetch( 'event', @@ -404,32 +410,39 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr ) ) )) - ).select_related( - 'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat' ) - else: - qs = qs.prefetch_related( - Prefetch( - lookup='checkins', - queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device') - ), - Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), - 'answers', 'answers__options', 'answers__question', - Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')) - ).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat') if expand and 'subevent' in expand: - qs = qs.prefetch_related( + prefetch_related += [ 'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set', 'subevent__seat_category_mappings', 'subevent__meta_values' - ) + ] if expand and 'item' in expand: - qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values', - 'item__variations').select_related('item__tax_rule') + prefetch_related += [ + 'item', 'item__addons', 'item__bundles', 'item__meta_values', + 'item__variations', + ] + select_related.append('item__tax_rule') if expand and 'variation' in expand: - qs = qs.prefetch_related('variation', 'variation__meta_values') + prefetch_related += [ + 'variation', 'variation__meta_values', + ] + + if expand and 'addons' in expand: + prefetch_related += [ + Prefetch('addons', OrderPosition.objects.prefetch_related(*prefetch_related).select_related(*select_related)), + ] + else: + prefetch_related += [ + Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')) + ] + + if pdf_data: + select_related.remove("order") # Don't need it twice on this queryset + + qs = qs.prefetch_related(*prefetch_related).select_related(*select_related) return qs @@ -966,6 +979,7 @@ class CheckinRPCSearchView(ListAPIView): def get_serializer_context(self): ctx = super().get_serializer_context() ctx['expand'] = self.request.query_params.getlist('expand') + ctx['organizer'] = self.request.organizer ctx['pdf_data'] = False return ctx diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index 443a5157f..be66bc482 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -106,7 +106,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): 'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property', 'variations__meta_values', 'variations__meta_values__property', 'require_membership_types', 'variations__require_membership_types', - 'limit_sales_channels', 'variations__limit_sales_channels', + 'limit_sales_channels', 'variations__limit_sales_channels', 'program_times' ).all() def perform_create(self, serializer): @@ -567,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/order.py b/src/pretix/api/views/order.py index 51d463047..2b5a7f82b 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -2031,7 +2031,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet): else: order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id) c = generate_cancellation(inv) - if inv.order.status != Order.STATUS_CANCELED: + if invoice_qualified(order): inv = generate_invoice(order) else: inv = c diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index f600086e1..f084a1679 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 c0c0c1016..2d6243b24 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 b14bd7139..ac3889157 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/datasync/datasync.py b/src/pretix/base/datasync/datasync.py index cb5bf01b3..bc3c5bfae 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 ecfd948c5..ebc98ca4e 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/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index c0756ca8a..d4bac74a5 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -39,8 +39,8 @@ from zoneinfo import ZoneInfo from django import forms from django.conf import settings from django.db.models import ( - Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef, - Q, Subquery, Sum, When, + Case, CharField, Count, DateTimeField, Exists, F, IntegerField, Max, Min, + OuterRef, Q, Subquery, Sum, When, ) from django.db.models.functions import Coalesce from django.dispatch import receiver @@ -144,6 +144,18 @@ class OrderListExporter(MultiSheetListExporter): d = OrderedDict(d) if not self.is_multievent and not self.event.has_subevents: del d['event_date_range'] + if not self.is_multievent: + d["items"] = forms.ModelMultipleChoiceField( + label=_("Products"), + queryset=self.event.items.all(), + widget=forms.CheckboxSelectMultiple( + attrs={"class": "scrolling-multiple-choice"} + ), + help_text=_("If none are selected, all products are included. Orders are included if they contain " + "at least one position of this product. The order totals etc. still include all products " + "contained in the order."), + required=False, + ) return d def _get_all_payment_methods(self, qs): @@ -249,6 +261,14 @@ class OrderListExporter(MultiSheetListExporter): pcnt=Subquery(s, output_field=IntegerField()) ).select_related('invoice_address', 'customer') + if form_data.get('items'): + qs = qs.filter( + Exists(OrderPosition.all.filter( + order=OuterRef('pk'), + item__in=form_data["items"] + )) + ) + qs = self._date_filter(qs, form_data, rel='') if form_data['paid_only']: @@ -364,7 +384,7 @@ class OrderListExporter(MultiSheetListExporter): order.invoice_address.city, order.invoice_address.country if order.invoice_address.country else order.invoice_address.country_old, - order.invoice_address.state, + order.invoice_address.state_for_address, order.invoice_address.custom_field, order.invoice_address.vat_id, ] @@ -440,6 +460,14 @@ class OrderListExporter(MultiSheetListExporter): if form_data['paid_only']: qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) + if form_data.get('items'): + qs = qs.filter( + Exists(OrderPosition.all.filter( + order=OuterRef('order'), + item__in=form_data["items"] + )) + ) + qs = self._date_filter(qs, form_data, rel='order__') return qs @@ -515,7 +543,7 @@ class OrderListExporter(MultiSheetListExporter): order.invoice_address.city, order.invoice_address.country if order.invoice_address.country else order.invoice_address.country_old, - order.invoice_address.state, + order.invoice_address.state_for_address, order.invoice_address.vat_id, ] except InvoiceAddress.DoesNotExist: @@ -535,6 +563,11 @@ class OrderListExporter(MultiSheetListExporter): if form_data['paid_only']: qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) + if form_data.get('items'): + qs = qs.filter( + item__in=form_data["items"] + ) + qs = self._date_filter(qs, form_data, rel='order__') return qs @@ -617,6 +650,7 @@ class OrderListExporter(MultiSheetListExporter): _('Country'), pgettext('address', 'State'), _('Voucher'), + _('Voucher budget usage'), _('Pseudonymization ID'), _('Ticket secret'), _('Seat ID'), @@ -732,8 +766,9 @@ class OrderListExporter(MultiSheetListExporter): op.zipcode or '', op.city or '', op.country if op.country else '', - op.state or '', + op.state_for_address or '', op.voucher.code if op.voucher else '', + op.voucher_budget_use if op.voucher_budget_use else '', op.pseudonymization_id, op.secret, ] @@ -797,7 +832,7 @@ class OrderListExporter(MultiSheetListExporter): order.invoice_address.city, order.invoice_address.country if order.invoice_address.country else order.invoice_address.country_old, - order.invoice_address.state, + order.invoice_address.state_for_address, order.invoice_address.vat_id, ] except InvoiceAddress.DoesNotExist: diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index bb56ceb9c..165c1c92e 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) @@ -1399,7 +1417,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): self.instance.transmission_type = transmission_type.identifier self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data) - elif transmission_type.exclusive: + elif transmission_type.is_exclusive(self.event, data.get("country"), data.get("is_business")): if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")): raise ValidationError({ "transmission_type": "The transmission type '%s' must be used for this country or address type." % ( diff --git a/src/pretix/base/i18n.py b/src/pretix/base/i18n.py index 7c510aa5d..25ebd8c01 100644 --- a/src/pretix/base/i18n.py +++ b/src/pretix/base/i18n.py @@ -34,14 +34,13 @@ from contextlib import contextmanager +from asgiref.local import Local from babel import localedata from django.conf import settings from django.utils import translation from django.utils.formats import date_format, number_format from django.utils.translation import gettext -from pretix.base.templatetags.money import money_filter - from i18nfield.fields import ( # noqa I18nCharField, I18nTextarea, I18nTextField, I18nTextInput, ) @@ -51,6 +50,9 @@ from i18nfield.strings import LazyI18nString # noqa from i18nfield.utils import I18nJSONEncoder # noqa +_active_region = Local() + + class LazyDate: def __init__(self, value): self.value = value @@ -86,6 +88,8 @@ class LazyCurrencyNumber: return self.__str__() def __str__(self): + from pretix.base.templatetags.money import money_filter + return money_filter(self.value, self.currency) @@ -105,14 +109,41 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES) def get_babel_locale(): - babel_locale = 'en' - # Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal - if translation.get_language(): - if localedata.exists(translation.get_language()): - babel_locale = translation.get_language() - elif localedata.exists(translation.get_language()[:2]): - babel_locale = translation.get_language()[:2] - return babel_locale + # Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal + # Also, this returns best-effort region information for number formatting etc + current_language = translation.get_language() + current_region = getattr(_active_region, "value", None) + + # Babel only accepts locales that exist on the system. We try combinations in the following order: + # language-languageversion-region + # language-region + # language-languageversion + # language + # fallback to system default + # fallback to english + + try_locales = [] + if current_language: + if "-" in current_language: + lng_parts = current_language.split("-") + if current_region: + try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}_{current_region.upper()}") + try_locales.append(f"{lng_parts[0]}_{current_region.upper()}") + try_locales.append(f"{lng_parts[0]}_{lng_parts[1].upper()}") + try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}") + try_locales.append(f"{lng_parts[0]}") + else: + if current_region: + try_locales.append(f"{current_language}_{current_region.upper()}") + try_locales.append(f"{current_language}") + + try_locales.append(settings.LANGUAGE_CODE) + + for locale in try_locales: + if localedata.exists(locale): + return localedata.normalize_locale(locale) + + return "en" def get_language_without_region(lng=None): @@ -132,6 +163,10 @@ def get_language_without_region(lng=None): return lng +def set_region(region): + _active_region.value = region + + @contextmanager def language(lng, region=None): """ @@ -143,15 +178,18 @@ def language(lng, region=None): formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region`` attribute will be ignored. """ - _lng = translation.get_language() + lng_before = translation.get_language() + region_before = getattr(_active_region, "value", None) lng = lng or settings.LANGUAGE_CODE if '-' not in lng and region: lng += '-' + region.lower() translation.activate(lng) + _active_region.value = region try: yield finally: - translation.activate(_lng) + translation.activate(lng_before) + _active_region.value = region_before class LazyLocaleException(Exception): diff --git a/src/pretix/base/invoicing/national.py b/src/pretix/base/invoicing/national.py index 47601145f..3b53dc23a 100644 --- a/src/pretix/base/invoicing/national.py +++ b/src/pretix/base/invoicing/national.py @@ -36,9 +36,11 @@ class ItalianSdITransmissionType(TransmissionType): identifier = "it_sdi" verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)") public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)") - exclusive = True enforce_transmission = True + def is_exclusive(self, event, country: Country, is_business: bool) -> bool: + return str(country) == "IT" + def is_available(self, event, country: Country, is_business: bool): return str(country) == "IT" and super().is_available(event, country, is_business) diff --git a/src/pretix/base/invoicing/pdf.py b/src/pretix/base/invoicing/pdf.py index a97d71374..2eed2f8e3 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 c8a28752a..194bf195e 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 @@ -61,7 +64,7 @@ class PeppolIdValidator: "0020": "[0-9]{9}", "0201": "[0-9a-zA-Z]{6}", "0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}", - "0208": "0[0-9]{9}", + "0208": "[01][0-9]{9}", "0209": ".*", "0210": "[A-Z0-9]+", "0211": "IT[0-9]{11}", @@ -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 @@ -149,13 +179,21 @@ class PeppolTransmissionType(TransmissionType): def is_available(self, event, country: Country, is_business: bool): return is_business and super().is_available(event, country, is_business) + def is_exclusive(self, event, country: Country, is_business: bool) -> bool: + if is_business and str(country) == "BE" and event and event.settings.invoice_address_from_country == "BE": + # Peppol is required to be used for intra-Belgian B2B invoices + return True + return False + @property def invoice_address_form_fields(self) -> dict: return { "transmission_peppol_participant_id": forms.CharField( label=_("Peppol participant ID"), validators=[ - PeppolIdValidator(), + PeppolIdValidator( + validate_online=True, + ), ] ), } diff --git a/src/pretix/base/invoicing/transmission.py b/src/pretix/base/invoicing/transmission.py index 6b7d39831..e04c02e61 100644 --- a/src/pretix/base/invoicing/transmission.py +++ b/src/pretix/base/invoicing/transmission.py @@ -58,15 +58,6 @@ class TransmissionType: """ return 100 - @property - def exclusive(self) -> bool: - """ - If a transmission type is exclusive, no other type can be chosen if this type is - available. Use e.g. if a certain transmission type is legally required in a certain - jurisdiction. - """ - return False - @property def enforce_transmission(self) -> bool: """ @@ -82,6 +73,15 @@ class TransmissionType: for provider, _ in providers ) + def is_exclusive(self, event, country: Country, is_business: bool) -> bool: + """ + If a transmission type is exclusive, no other type can be chosen if this type is + available. Use e.g. if a certain transmission type is legally required in a certain + jurisdiction. Event can be None in organizer-level contexts. Exclusiveness has no effect if + the type is not available. + """ + return False + def invoice_address_form_fields_required(self, country: Country, is_business: bool): return set() diff --git a/src/pretix/base/middleware.py b/src/pretix/base/middleware.py index 7e326d90f..677816723 100644 --- a/src/pretix/base/middleware.py +++ b/src/pretix/base/middleware.py @@ -35,7 +35,7 @@ from django.utils.translation.trans_real import ( parse_accept_lang_header, ) -from pretix.base.i18n import get_language_without_region +from pretix.base.i18n import get_language_without_region, set_region from pretix.base.settings import global_settings_object from pretix.multidomain.urlreverse import ( get_event_domain, get_organizer_domain, @@ -92,10 +92,14 @@ class LocaleMiddleware(MiddlewareMixin): ) if '-' not in language and settings_holder.settings.region: language += '-' + settings_holder.settings.region + if settings_holder.settings.region: + set_region(settings_holder.settings.region) else: gs = global_settings_object(request) if '-' not in language and gs.settings.region: language += '-' + gs.settings.region + if gs.settings.region: + set_region(gs.settings.region) translation.activate(language) request.LANGUAGE_CODE = get_language_without_region() diff --git a/src/pretix/base/modelimport.py b/src/pretix/base/modelimport.py index 43b956e1a..e274dbe85 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 347f4d271..44ff3587d 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 5027c067a..0a15dd1f9 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 @@ -162,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/customers.py b/src/pretix/base/models/customers.py index 942ae5918..d7d34db98 100644 --- a/src/pretix/base/models/customers.py +++ b/src/pretix/base/models/customers.py @@ -349,7 +349,7 @@ class AttendeeProfile(models.Model): def state_name(self): sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state)) if sd: - return sd.name + return _(sd.name) return self.state @property diff --git a/src/pretix/base/models/discount.py b/src/pretix/base/models/discount.py index 9c09edc65..cc5c459da 100644 --- a/src/pretix/base/models/discount.py +++ b/src/pretix/base/models/discount.py @@ -37,7 +37,7 @@ from pretix.base.decimal import round_decimal from pretix.base.models.base import LoggedModel PositionInfo = namedtuple('PositionInfo', - ['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'is_addon_to', + ['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to', 'voucher_discount']) @@ -279,6 +279,42 @@ class Discount(LoggedModel): for idx in condition_idx_group: collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)] + def _addon_idx(self, positions, idx): + """ + If we have the following cart: + + - Main product + - 10x Addon product 5€ + - Main product + - 10x Addon product 5€ + + And we have a discount rule that grants "every 10th product is free", people tend to expect + + - Main product + - 9x Addon product 5€ + - 1x Addon product free + - Main product + - 9x Addon product 5€ + - 1x Addon product free + + And get confused if they get + + - Main product + - 8x Addon product 5€ + - 2x Addon product free + - Main product + - 10x Addon product 5€ + + Even if the result is the same. Therefore, we sort positions in the cart not only by price, but also by their + relative index within their addon group. This is only a heuristic and there are *still* scenarios where the more + unexpected version happens, e.g. if prices are different. We need to accept this as long as discounts work on + cart level and not on addon-group level, but this simple sorting reduces the number of support issues by making + the weird case less likely. + """ + if not positions[idx].addon_to: + return 0 + return len([1 for i, p in positions.items() if i < idx and p.addon_to == positions[idx].addon_to]) + def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id): if len(condition_idx_group) < self.condition_min_count: return @@ -288,8 +324,8 @@ class Discount(LoggedModel): if self.benefit_only_apply_to_cheapest_n_matches: # sort by line_price - condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx)) - benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx)) + condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx)) + benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx)) # Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only # want to match multiples of 3 @@ -434,7 +470,7 @@ class Discount(LoggedModel): for idx, p in positions.items(): subevent_to_idx[p.subevent_id].append(idx) for v in subevent_to_idx.values(): - v.sort(key=lambda idx: positions[idx].line_price_gross) + v.sort(key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx))) subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True) # Build groups of exactly condition_min_count distinct subevents @@ -458,7 +494,7 @@ class Discount(LoggedModel): # Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start # and 2 from the end" scheme to optimize price distribution among groups - candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross) + candidates = sorted(candidates, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx))) if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0): candidate = candidates[0] else: diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index d307ba9c7..339ad4501 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -594,10 +594,11 @@ class Item(LoggedModel): on_delete=models.SET_NULL, verbose_name=_("Only show after sellout of"), help_text=_("If you select a product here, this product will only be shown when that product is " - "sold out. If combined with the option to hide sold-out products, this allows you to " - "swap out products for more expensive ones once the cheaper option is sold out. There might " - "be a short period in which both products are visible while all tickets of the referenced " - "product are reserved, but not yet sold.") + "no longer available. This will happen either because the other product has sold out or because " + "the time is outside of the sales window for the other product. If combined with the option " + "to hide sold-out products, this allows you to swap out products for more expensive ones once " + "the cheaper option is sold out. There might be a short period in which both products are visible " + "while all tickets of the referenced product are reserved, but not yet sold.") ) hidden_if_item_available_mode = models.CharField( choices=UNAVAIL_MODES, diff --git a/src/pretix/base/models/log.py b/src/pretix/base/models/log.py index 2ccf5c20e..43b5439ef 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/orders.py b/src/pretix/base/models/orders.py index f8201f071..c9c4da871 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1675,7 +1675,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model): def state_name(self): sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state)) if sd: - return sd.name + return _(sd.name) return self.state @property @@ -3480,7 +3480,7 @@ class InvoiceAddress(models.Model): def state_name(self): sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state)) if sd: - return sd.name + return _(sd.name) return self.state @property diff --git a/src/pretix/base/models/seating.py b/src/pretix/base/models/seating.py index 8ddc0b605..49b7c4b5f 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 cab647049..70872d5bb 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 a1531e987..3b4e919c9 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 1eedd4ab3..68d46cac4 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 @@ -158,6 +159,7 @@ class WaitingListEntry(LoggedModel): if availability[1] is None or availability[1] < 1: raise WaitingListException(_('This product is currently not available.')) + event = self.event ev = self.subevent or self.event if ev.seat_category_mappings.filter(product=self.item).exists(): # Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous @@ -185,44 +187,49 @@ 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) + locked_wle.event = event + 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() + self.event = event 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 7117551ff..5d11c3504 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 66c7c47bd..5a93f4d78 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): @@ -1424,7 +1528,7 @@ class CartManager: self._sales_channel.identifier, [ (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, - bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) + cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher) for cp in positions ] ) @@ -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/cross_selling.py b/src/pretix/base/services/cross_selling.py index 0a234dd8b..f407d2fad 100644 --- a/src/pretix/base/services/cross_selling.py +++ b/src/pretix/base/services/cross_selling.py @@ -121,7 +121,7 @@ class CrossSellingService: self.sales_channel, [ (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, - bool(cp.addon_to), cp.is_bundled, + cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher) for cp in self.cartpositions ], diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 6db88be3e..d76c48c99 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -521,9 +521,20 @@ def invoice_pdf_task(invoice: int): def invoice_qualified(order: Order): - if order.total == Decimal('0.00') or order.require_approval or \ - order.sales_channel.identifier not in order.event.settings.get('invoice_generate_sales_channels'): + if order.total == Decimal('0.00'): return False + if order.require_approval: + return False + if order.sales_channel.identifier not in order.event.settings.invoice_generate_sales_channels: + return False + if order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED): + return False + if order.event.settings.invoice_generate_only_business: + try: + ia = order.invoice_address + return ia.is_business + except InvoiceAddress.DoesNotExist: + return False return True diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index 3e05e9792..a21eec419 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 @@ -764,6 +763,8 @@ def render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_P 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 357ad8fc6..5a75dfafb 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 e8be424cf..365247483 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,13 +908,13 @@ 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, [ (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, - bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) + cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher) for cp in sorted_positions ] ) @@ -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 9376748eb..8ce503dab 100644 --- a/src/pretix/base/services/placeholders.py +++ b/src/pretix/base/services/placeholders.py @@ -801,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, diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index 3921a1b13..7ef2ae826 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -174,7 +174,9 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel], :param event: Event the cart belongs to :param sales_channel: Sales channel the cart was created with - :param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)`` + :param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled, voucher_discount)`` + ``addon_to_id`` does not have to be the proper ID, any identifier is okay, even ``True``/``False`` are accepted, but + a better result may be given if addons to the same main product have the same distinct value. :param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart based on the "consumed" items, but lack matching "benefitting" items will be collected therein. The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list @@ -196,9 +198,9 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel], ).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk') for discount in discount_qs: result = discount.apply({ - idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) + idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount) for - idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount) + idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount) in enumerate(positions) if not is_bundled and idx not in new_prices }, collect_potential_discounts) diff --git a/src/pretix/base/services/stats.py b/src/pretix/base/services/stats.py index eef5ac465..324f050eb 100644 --- a/src/pretix/base/services/stats.py +++ b/src/pretix/base/services/stats.py @@ -112,7 +112,8 @@ def dictsum(*dicts) -> dict: def order_overview( event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False, - admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None + admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None, + skip_empty_lines=False, ) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]: items = event.items.all().select_related( 'category', # for re-grouping @@ -205,13 +206,21 @@ def order_overview( for l in states.keys(): var.num[l] = num[l].get((item.id, variid), (0, 0, 0)) var.num['total'] = num['total'].get((item.id, variid), (0, 0, 0)) + var._skip = all(v[0] == 0 for v in var.num.values()) for l in states.keys(): item.num[l] = tuplesum(var.num[l] for var in item.all_variations) item.num['total'] = tuplesum(var.num['total'] for var in item.all_variations) + if skip_empty_lines: + item.all_variations = [v for v in item.all_variations if not v._skip] + item._skip = not item.all_variations else: for l in states.keys(): item.num[l] = num[l].get((item.id, None), (0, 0, 0)) item.num['total'] = num['total'].get((item.id, None), (0, 0, 0)) + item._skip = all(v[0] == 0 for v in item.num.values()) + + if skip_empty_lines: + items = [i for i in items if not i._skip] nonecat = ItemCategory(name=_('Uncategorized')) # Regroup those by category diff --git a/src/pretix/base/services/tax.py b/src/pretix/base/services/tax.py index 781374c56..2a05d36ec 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 238212ea0..527021997 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 c2422fe69..1562133f9 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -76,7 +76,7 @@ from pretix.base.validators import multimail_validate from pretix.control.forms import ( ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget, ) -from pretix.helpers.countries import CachedCountries +from pretix.helpers.countries import CachedCountries, pycountry_add ROUNDING_MODES = ( ('line', _('Compute taxes for every line individually')), @@ -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, @@ -1185,6 +1225,15 @@ DEFAULTS = { 'default': json.dumps(['web']), 'type': list }, + 'invoice_generate_only_business': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Only issue invoices to business customers"), + ) + }, 'invoice_address_from': { 'default': '', 'type': str, @@ -3887,7 +3936,7 @@ COUNTRIES_WITH_STATE_IN_ADDRESS = { 'MX': (['State', 'Federal district', 'Federal entity'], 'short'), 'US': (['State', 'Outlying area', 'District'], 'short'), 'IT': (['Province', 'Free municipal consortium', 'Metropolitan city', 'Autonomous province', - 'Free municipal consortium', 'Decentralized regional entity'], 'short'), + 'Decentralized regional entity'], 'short'), } COUNTRY_STATE_LABEL = { # Countries in which the "State" field should not be called "State" @@ -3895,6 +3944,8 @@ COUNTRY_STATE_LABEL = { 'JP': pgettext_lazy('address', 'Prefecture'), 'IT': pgettext_lazy('address', 'Province'), } +# Workaround for https://github.com/pretix/pretix/issues/5796 +pycountry_add(pycountry.subdivisions, code="IT-AO", country_code="IT", name="Valle d'Aosta", parent="23", parent_code="IT-23", type="Province") settings_hierarkey = Hierarkey(attribute_name='settings') diff --git a/src/pretix/base/templatetags/html_time.py b/src/pretix/base/templatetags/html_time.py new file mode 100644 index 000000000..55b4fc224 --- /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/money.py b/src/pretix/base/templatetags/money.py index edda9904e..e45468411 100644 --- a/src/pretix/base/templatetags/money.py +++ b/src/pretix/base/templatetags/money.py @@ -26,7 +26,8 @@ from babel.numbers import format_currency from django import template from django.conf import settings from django.template.defaultfilters import floatformat -from django.utils import translation + +from pretix.base.i18n import get_babel_locale register = template.Library() @@ -59,13 +60,10 @@ def money_filter(value: Decimal, arg='', hide_currency=False): if hide_currency: return floatformat(value, f"{places}g") - locale_parts = translation.get_language().split("-", 1) - locale = locale_parts[0] - if len(locale_parts) > 1 and len(locale_parts[1]) == 2: - try: - locale = Locale(locale_parts[0], locale_parts[1].upper()) - except UnknownLocaleError: - pass + try: + locale = Locale(get_babel_locale()) + except UnknownLocaleError: + locale = "en" try: return format_currency(value, arg, locale=locale) diff --git a/src/pretix/base/templatetags/rich_text.py b/src/pretix/base/templatetags/rich_text.py index 954e093ff..2fabad485 100644 --- a/src/pretix/base/templatetags/rich_text.py +++ b/src/pretix/base/templatetags/rich_text.py @@ -32,13 +32,14 @@ # 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 @@ -124,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))) @@ -333,8 +351,14 @@ def markdown_compile_email(source, allowed_tags=None, allowed_attributes=ALLOWED # 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 - attrs[None, "href"] = escape(format_map(attrs[None, "href"], context=context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)) + # 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) diff --git a/src/pretix/base/timeline.py b/src/pretix/base/timeline.py index be9d1d6b9..929422f9f 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/js_helpers.py b/src/pretix/base/views/js_helpers.py index 7da2c8395..b3cd198d5 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} @@ -55,7 +82,7 @@ def _info(cc): statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types] return { 'data': [ - {'name': s.name, 'code': s.code[3:]} + {'name': gettext(s.name), 'code': s.code[3:]} for s in sorted(statelist, key=lambda s: s.name) ], **info, @@ -82,7 +109,7 @@ def address_form(request): for t in get_transmission_types(): if t.is_available(event=event, country=country, is_business=is_business): result = {"name": str(t.public_name), "code": t.identifier} - if t.exclusive: + if t.is_exclusive(event=event, country=country, is_business=is_business): info["transmission_types"] = [result] break else: @@ -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 8402d35e2..917b4de1c 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -45,7 +45,7 @@ from django.core.exceptions import NON_FIELD_ERRORS, ValidationError 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 @@ -53,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 @@ -207,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'] @@ -927,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', @@ -937,6 +939,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm): 'invoice_show_payments', 'invoice_reissue_after_modify', 'invoice_generate', + 'invoice_generate_only_business', 'invoice_period', 'invoice_attendee_name', 'invoice_event_location', @@ -1309,9 +1312,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"), @@ -1479,6 +1490,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') @@ -1497,7 +1511,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: @@ -1957,6 +1971,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 6e330bb8f..783c12e85 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) + if d_start: + opqs = opqs.filter(subevent__date_from__gte=d_start) + if d_end: + opqs = opqs.filter(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 9d2dc82d6..4fc22a590 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 c8f63914f..1f10385af 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/logdisplay.py b/src/pretix/control/logdisplay.py index 28484710a..02ccce2e4 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -814,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 f5d1834c5..41fc60f8d 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 }} +