mirror of
https://github.com/pretix/pretix.git
synced 2026-06-18 02:26:17 +00:00
Compare commits
174 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70a304d37c | |||
| 815e31d9a0 | |||
| ed618f2f32 | |||
| a900e11ce0 | |||
| 112d5da792 | |||
| ceb2e13d27 | |||
| b5ad372bb2 | |||
| cdea82d206 | |||
| de9045afcf | |||
| 6b65cb4e33 | |||
| c4792800f0 | |||
| ca23f7ebc2 | |||
| 0259899e00 | |||
| efb94265b2 | |||
| 2aa27f56f1 | |||
| 4f3d90fc50 | |||
| 9cf66de437 | |||
| 9f4cbabd30 | |||
| 0fc2d6134f | |||
| 1e0e16642d | |||
| a58403559e | |||
| dfd53f0ea2 | |||
| 06250ef55e | |||
| ab3104fe65 | |||
| bb6e424cde | |||
| c2623dba60 | |||
| d8f7465b03 | |||
| ac0546499b | |||
| ebbb532478 | |||
| 94dad4d0d2 | |||
| a06cd687ba | |||
| fd9f3ea6ed | |||
| 608622e3f3 | |||
| 4d94294e5a | |||
| 4dbdadabb5 | |||
| d494c61cba | |||
| 55a7dfbff3 | |||
| b8c271cf9c | |||
| 5af7e1b6d6 | |||
| 9222ce0ecd | |||
| 8afb0e43e0 | |||
| c65fecf45e | |||
| 1c684d62d4 | |||
| 48809dc477 | |||
| 71df116079 | |||
| ad64f6e88b | |||
| 891ba9d99c | |||
| 5cd1476a07 | |||
| cb393a0b31 | |||
| af59a89ecb | |||
| 1eb0008da9 | |||
| d6489c6dd8 | |||
| abe6acc9d8 | |||
| 2dcbb791f0 | |||
| 2efc40e20b | |||
| 0693681473 | |||
| 3aabc8a163 | |||
| 062f8fa409 | |||
| 106339c928 | |||
| 222ea08dd0 | |||
| 62bc16f963 | |||
| 3332fc818a | |||
| d87dbaf9e5 | |||
| 67580c4ca5 | |||
| c5b32484b1 | |||
| b5560509ad | |||
| c78365ce43 | |||
| 8cc12fa1c7 | |||
| 59c09e27fd | |||
| 4d68d24eca | |||
| cc5693017e | |||
| 6a07b7d5d1 | |||
| 26dc3486a0 | |||
| de60183456 | |||
| 520bb9e378 | |||
| 97e344e81a | |||
| a3f5f33ed5 | |||
| 5a123bf88f | |||
| 64c52a5e36 | |||
| a60341afe9 | |||
| 308e14bab3 | |||
| aa5f635932 | |||
| 66a9902eb4 | |||
| 79a58fe104 | |||
| bb5a9bdbf1 | |||
| 449b960438 | |||
| a3f247117c | |||
| e279ecb423 | |||
| ca6a650398 | |||
| 696e5602ac | |||
| 4c7987cef6 | |||
| 37c65030f8 | |||
| 0d1673136f | |||
| 32d8dce6aa | |||
| 8a2ecb4e97 | |||
| 91348e3b00 | |||
| 459f4f84c7 | |||
| 31a1385946 | |||
| adfd0bfcfd | |||
| ef7433dbcd | |||
| ebbd18bb26 | |||
| fc4ce102b6 | |||
| 8854ae3187 | |||
| c5a91ef479 | |||
| aa9c478c30 | |||
| 847dc0f992 | |||
| daaae85865 | |||
| 06770bcef5 | |||
| dc6eae4708 | |||
| bf8bb78d2a | |||
| 091be266fc | |||
| dde655f7d6 | |||
| 409e64d5f2 | |||
| 5d67a4fa33 | |||
| 4eb2c50d95 | |||
| a7e85a157d | |||
| 4c3584c788 | |||
| e466c4fb72 | |||
| d0d7670ca5 | |||
| a17a098b15 | |||
| 40516ab8e0 | |||
| 3ca343fabc | |||
| 7304b7f24b | |||
| abaf968103 | |||
| 86e2f5a155 | |||
| 4c64af02c1 | |||
| 11df4398e1 | |||
| 2e89fc0a94 | |||
| 510c4850a5 | |||
| b13368d614 | |||
| b5cc8b368b | |||
| 87c30d0acb | |||
| ffed8b29b1 | |||
| 53fbb64225 | |||
| e10ec4074b | |||
| 7f2dc77aca | |||
| 199a3bf1e7 | |||
| 904aa807a3 | |||
| 0e41353a0e | |||
| 82ca50c7ff | |||
| 3437b64947 | |||
| b895d9bbca | |||
| f214edaf34 | |||
| 165a47b593 | |||
| e06f281f1e | |||
| 203c7e660d | |||
| 8c360b8754 | |||
| 90b6511d11 | |||
| bb356257cb | |||
| e1950e408e | |||
| 99d5722ce1 | |||
| 324eeb8d40 | |||
| 449e8dc905 | |||
| c491c8232e | |||
| aa02cc7968 | |||
| cfa13d6b9d | |||
| af4eabc800 | |||
| e1f5678d7c | |||
| 609b7c82ee | |||
| 8d66e1e732 | |||
| c925f094f2 | |||
| 5caaa8586d | |||
| 1b1cf1557d | |||
| 35d8a7eec5 | |||
| d428c3e1a4 | |||
| 63850f3139 | |||
| 04c8270d43 | |||
| 74a960e239 | |||
| 5a1bcae085 | |||
| 051eb78312 | |||
| 15808e55fd | |||
| c886c0b415 | |||
| 47472447eb | |||
| 1a40215e91 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+79
-95
@@ -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) -%}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<!--[if IE 8]><html class="no-js lt-ie9" lang="en" > <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" lang="en" > <!--<![endif]-->
|
||||
<html class="writer-html5" lang="{{ lang_attr }}"{% if sphinx_version_info >= (7, 2) %} data-content_root="{{ content_root }}"{% endif %}>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
{{ metatags }}
|
||||
@@ -18,59 +22,50 @@
|
||||
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{#- CSS #}
|
||||
{%- for css in css_files %}
|
||||
{%- if css|attr("rel") %}
|
||||
<link rel="{{ css.rel }}" href="{{ pathto(css.filename, 1) }}" type="text/css"{% if css.title is not none %} title="{{ css.title }}"{% endif %} />
|
||||
{#- CSS #}
|
||||
{%- for css_file in css_files %}
|
||||
{%- if css_file|attr("filename") %}
|
||||
{{ css_tag(css_file) }}
|
||||
{%- else %}
|
||||
<link rel="stylesheet" href="{{ pathto(css, 1) }}" type="text/css" />
|
||||
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endfor %}
|
||||
|
||||
{%- for cssfile in extra_css_files %}
|
||||
<link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" />
|
||||
{%- endfor -%}
|
||||
{#- FAVICON #}
|
||||
{%- if favicon_url %}
|
||||
<link rel="shortcut icon" href="{{ 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 %}
|
||||
<link rel="shortcut icon" href="{{ _favicon_url }}"/>
|
||||
{%- endif %}
|
||||
|
||||
{#- CANONICAL URL (deprecated) #}
|
||||
{%- if theme_canonical_url and not pageurl %}
|
||||
{#- CANONICAL URL (deprecated) #}
|
||||
{%- if theme_canonical_url and not pageurl %}
|
||||
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
|
||||
{#- CANONICAL URL #}
|
||||
{%- if pageurl %}
|
||||
{#- CANONICAL URL #}
|
||||
{%- if pageurl %}
|
||||
<link rel="canonical" href="{{ pageurl|e }}" />
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
|
||||
{#- JAVASCRIPTS #}
|
||||
{%- block scripts %}
|
||||
<!--[if lt IE 9]>
|
||||
<script src="{{ pathto('_static/js/html5shiv.min.js', 1) }}"></script>
|
||||
<![endif]-->
|
||||
{%- 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 %}
|
||||
<script src="{{ pathto('_static/js/theme.js', 1) }}"></script>
|
||||
|
||||
{%- if READTHEDOCS or DEBUG %}
|
||||
<script src="{{ pathto('_static/js/versions.js', 1) }}"></script>
|
||||
{%- endif %}
|
||||
|
||||
{#- OPENSEARCH #}
|
||||
{%- if use_opensearch %}
|
||||
<link rel="search" type="application/opensearchdescription+xml"
|
||||
title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}"
|
||||
href="{{ pathto('_static/opensearch.xml', 1) }}"/>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endblock %}
|
||||
{%- endif %}
|
||||
{%- endblock %}
|
||||
|
||||
{%- block linktags %}
|
||||
{%- if hasdoc('about') %}
|
||||
@@ -123,23 +118,23 @@
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
|
||||
{% block menu %}
|
||||
{#
|
||||
The singlehtml builder doesn't handle this toctree call when the
|
||||
toctree is empty. Skip building this for now.
|
||||
#}
|
||||
{% if 'singlehtml' not in builder %}
|
||||
{% set global_toc = toctree(maxdepth=theme_navigation_depth|int, collapse=theme_collapse_navigation, includehidden=True) %}
|
||||
{% endif %}
|
||||
{% if global_toc %}
|
||||
{{ global_toc }}
|
||||
{% else %}
|
||||
{%- block navigation %}
|
||||
{#- Translators: This is an ARIA section label for the main navigation menu -#}
|
||||
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="{{ _('Navigation menu') }}">
|
||||
{%- block menu %}
|
||||
{%- set toctree = toctree(maxdepth=theme_navigation_depth|int,
|
||||
collapse=theme_collapse_navigation|tobool,
|
||||
includehidden=theme_includehidden|tobool,
|
||||
titles_only=theme_titles_only|tobool) %}
|
||||
{%- if toctree %}
|
||||
{{ toctree }}
|
||||
{%- else %}
|
||||
<!-- Local TOC -->
|
||||
<div class="local-toc">{{ toc }}</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
{%- endif %}
|
||||
{%- endblock %}
|
||||
</div>
|
||||
{%- endblock %}
|
||||
|
||||
{% if theme_display_version %}
|
||||
{%- set nav_version = version %}
|
||||
@@ -158,53 +153,42 @@
|
||||
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
|
||||
|
||||
{# MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #}
|
||||
<nav class="wy-nav-top" role="navigation" aria-label="top navigation">
|
||||
{% block mobile_nav %}
|
||||
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
|
||||
<a href="{{ pathto('index') }}">{{ project }}</a>
|
||||
{% endblock %}
|
||||
</nav>
|
||||
<nav class="wy-nav-top" aria-label="{{ _('Mobile navigation menu') }}" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}>
|
||||
{%- block mobile_nav %}
|
||||
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
|
||||
<a href="{{ pathto(master_doc) }}">{{ project }}</a>
|
||||
{%- endblock %}
|
||||
</nav>
|
||||
|
||||
|
||||
{# PAGE CONTENT #}
|
||||
<div class="wy-nav-content">
|
||||
<div class="rst-content">
|
||||
{% include "breadcrumbs.html" %}
|
||||
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
|
||||
<div itemprop="articleBody" class="section">
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
<div class="articleComments">
|
||||
{% block comments %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
||||
<div class="wy-nav-content">
|
||||
{%- block content %}
|
||||
{%- if theme_style_external_links|tobool %}
|
||||
<div class="rst-content style-external-links">
|
||||
{%- else %}
|
||||
<div class="rst-content">
|
||||
{%- endif %}
|
||||
{% include "breadcrumbs.html" %}
|
||||
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
|
||||
{%- block document %}
|
||||
<div itemprop="articleBody">
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
{%- if self.comments()|trim %}
|
||||
<div class="articleComments">
|
||||
{%- block comments %}{% endblock %}
|
||||
</div>
|
||||
{%- endif%}
|
||||
</div>
|
||||
{%- endblock %}
|
||||
{% include "footer.html" %}
|
||||
</div>
|
||||
{%- endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{% include "versions.html" %}
|
||||
|
||||
{% if not embedded %}
|
||||
|
||||
<script type="text/javascript">
|
||||
var DOCUMENTATION_OPTIONS = {
|
||||
URL_ROOT:'{{ url_root }}',
|
||||
VERSION:'{{ release|e }}',
|
||||
COLLAPSE_INDEX:false,
|
||||
FILE_SUFFIX:'{{ '' if no_search_suffix else file_suffix }}',
|
||||
HAS_SOURCE: {{ has_source|lower }},
|
||||
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
|
||||
};
|
||||
</script>
|
||||
{%- for scriptfile in script_files %}
|
||||
<script type="text/javascript" src="{{ pathto(scriptfile, 1) }}"></script>
|
||||
{%- endfor %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{# RTD hosts this file, so just load on non RTD builds #}
|
||||
{% if not READTHEDOCS %}
|
||||
<script type="text/javascript" src="{{ pathto('_static/js/theme.js', 1) }}"></script>
|
||||
@@ -214,7 +198,7 @@
|
||||
{% if theme_sticky_navigation %}
|
||||
<script type="text/javascript">
|
||||
jQuery(function () {
|
||||
SphinxRtdTheme.StickyNav.enable();
|
||||
SphinxRtdTheme.Navigation.enable({{ 'true' if theme_sticky_navigation|tobool else 'false' }});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
+184
-166
@@ -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 -%}
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
{%- 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() %}
|
||||
<div class="related">
|
||||
<h3>{{ _('Navigation') }}</h3>
|
||||
<ul>
|
||||
{%- for rellink in rellinks %}
|
||||
<li class="right" {% if loop.first %}style="margin-right: 10px"{% endif %}>
|
||||
<a href="{{ pathto(rellink[0]) }}" title="{{ rellink[1]|striptags|e }}"
|
||||
{{ accesskey(rellink[2]) }}>{{ rellink[3] }}</a>
|
||||
{%- if not loop.first %}{{ reldelim2 }}{% endif %}</li>
|
||||
{%- endfor %}
|
||||
{%- block rootrellink %}
|
||||
<li><a href="{{ pathto(master_doc) }}">{{ shorttitle|e }}</a>{{ reldelim1 }}</li>
|
||||
{%- endblock %}
|
||||
{%- for parent in parents %}
|
||||
<li><a href="{{ parent.link|e }}" {% if loop.last %}{{ accesskey("U") }}{% endif %}>{{ parent.title }}</a>{{ reldelim1 }}</li>
|
||||
{%- endfor %}
|
||||
{%- block relbaritems %} {% endblock %}
|
||||
</ul>
|
||||
</div>
|
||||
{%- 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 %}
|
||||
<div class="sphinxsidebar">
|
||||
<div class="sphinxsidebarwrapper">
|
||||
{%- block sidebarlogo %}
|
||||
{%- if logo %}
|
||||
<p class="logo"><a href="{{ pathto(master_doc) }}">
|
||||
<img class="logo" src="{{ pathto('_static/' + logo, 1) }}" alt="Logo"/>
|
||||
</a></p>
|
||||
{%- 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 %}
|
||||
</div>
|
||||
</div>
|
||||
{%- endif %}
|
||||
{%- endmacro %}
|
||||
<!DOCTYPE html>
|
||||
<html class="writer-html5" lang="{{ lang_attr }}"{% if sphinx_version_info >= (7, 2) %} data-content_root="{{ content_root }}"{% endif %}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
{%- if READTHEDOCS and not embedded %}
|
||||
<meta name="readthedocs-addons-api-version" content="1">
|
||||
{%- endif %}
|
||||
{{- metatags }}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
{%- block htmltitle %}
|
||||
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
||||
{%- endblock -%}
|
||||
|
||||
{%- macro script() %}
|
||||
<script type="text/javascript">
|
||||
var DOCUMENTATION_OPTIONS = {
|
||||
URL_ROOT: '{{ url_root }}',
|
||||
VERSION: '{{ release|e }}',
|
||||
COLLAPSE_INDEX: false,
|
||||
FILE_SUFFIX: '{{ '' if no_search_suffix else file_suffix }}',
|
||||
HAS_SOURCE: {{ has_source|lower }},
|
||||
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
|
||||
};
|
||||
</script>
|
||||
{#- CSS #}
|
||||
{%- for css_file in css_files %}
|
||||
{%- if css_file|attr("filename") %}
|
||||
{{ css_tag(css_file) }}
|
||||
{%- else %}
|
||||
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
|
||||
{%- 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 %}
|
||||
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
|
||||
{%- endfor -%}
|
||||
|
||||
{#- FAVICON #}
|
||||
{%- if favicon_url %}
|
||||
<link rel="shortcut icon" href="{{ favicon_url }}"/>
|
||||
{%- endif %}
|
||||
|
||||
{#- CANONICAL URL (deprecated) #}
|
||||
{%- if theme_canonical_url and not pageurl %}
|
||||
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
|
||||
{%- endif -%}
|
||||
|
||||
{#- CANONICAL URL #}
|
||||
{%- if pageurl %}
|
||||
<link rel="canonical" href="{{ pageurl|e }}" />
|
||||
{%- endif -%}
|
||||
|
||||
{#- JAVASCRIPTS #}
|
||||
{%- block scripts %}
|
||||
{%- if not embedded %}
|
||||
{%- for scriptfile in script_files %}
|
||||
<script type="text/javascript" src="{{ pathto(scriptfile, 1) }}"></script>
|
||||
{{ js_tag(scriptfile) }}
|
||||
{%- endfor %}
|
||||
{%- endmacro %}
|
||||
<script src="{{ pathto('_static/js/theme.js', 1) }}"></script>
|
||||
|
||||
{%- macro css() %}
|
||||
<link rel="stylesheet" href="{{ pathto('_static/' + style, 1) }}" type="text/css" />
|
||||
<link rel="stylesheet" href="{{ pathto('_static/pygments.css', 1) }}" type="text/css" />
|
||||
{%- for cssfile in css_files %}
|
||||
<link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" />
|
||||
{%- endfor %}
|
||||
{%- endmacro %}
|
||||
{%- if READTHEDOCS or DEBUG %}
|
||||
<script src="{{ pathto('_static/js/versions.js', 1) }}"></script>
|
||||
{%- endif %}
|
||||
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset={{ encoding }}" />
|
||||
{{ metatags }}
|
||||
{%- block htmltitle %}
|
||||
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
||||
{%- endblock %}
|
||||
{{ css() }}
|
||||
{%- if not embedded %}
|
||||
{{ script() }}
|
||||
{#- OPENSEARCH #}
|
||||
{%- if use_opensearch %}
|
||||
<link rel="search" type="application/opensearchdescription+xml"
|
||||
title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}"
|
||||
href="{{ pathto('_static/opensearch.xml', 1) }}"/>
|
||||
{%- endif %}
|
||||
{%- if favicon %}
|
||||
<link rel="shortcut icon" href="{{ pathto('_static/' + favicon, 1) }}"/>
|
||||
{%- endif %}
|
||||
{%- if theme_canonical_url %}
|
||||
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- block linktags %}
|
||||
{%- endif %}
|
||||
{%- endblock %}
|
||||
|
||||
{%- block linktags %}
|
||||
{%- if hasdoc('about') %}
|
||||
<link rel="author" title="{{ _('About these documents') }}" href="{{ pathto('about') }}" />
|
||||
{%- endif %}
|
||||
@@ -143,67 +93,135 @@
|
||||
{%- if hasdoc('copyright') %}
|
||||
<link rel="copyright" title="{{ _('Copyright') }}" href="{{ pathto('copyright') }}" />
|
||||
{%- endif %}
|
||||
<link rel="top" title="{{ docstitle|e }}" href="{{ pathto('index') }}" />
|
||||
{%- if parents %}
|
||||
<link rel="up" title="{{ parents[-1].title|striptags|e }}" href="{{ parents[-1].link|e }}" />
|
||||
{%- endif %}
|
||||
{%- if next %}
|
||||
<link rel="next" title="{{ next.title|striptags|e }}" href="{{ next.link|e }}" />
|
||||
{%- endif %}
|
||||
{%- if prev %}
|
||||
<link rel="prev" title="{{ prev.title|striptags|e }}" href="{{ prev.link|e }}" />
|
||||
{%- endif %}
|
||||
{%- endblock %}
|
||||
{%- block extrahead %} {% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{%- block header %}{% endblock %}
|
||||
|
||||
{%- block relbar1 %}{{ relbar() }}{% endblock %}
|
||||
|
||||
{%- block content %}
|
||||
{%- block sidebar1 %} {# possible location for sidebar #} {% endblock %}
|
||||
|
||||
<div class="document">
|
||||
{%- block document %}
|
||||
<div class="documentwrapper">
|
||||
{%- if render_sidebar %}
|
||||
<div class="bodywrapper">
|
||||
{%- endif %}
|
||||
<div class="body">
|
||||
{% block body %} {% endblock %}
|
||||
</div>
|
||||
{%- if render_sidebar %}
|
||||
</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
{%- endblock %}
|
||||
{%- block extrahead %} {% endblock %}
|
||||
</head>
|
||||
|
||||
{%- block sidebar2 %}{{ sidebar() }}{% endblock %}
|
||||
<div class="clearer"></div>
|
||||
</div>
|
||||
{%- endblock %}
|
||||
<body class="wy-body-for-nav">
|
||||
|
||||
{%- block relbar2 %}{{ relbar() }}{% endblock %}
|
||||
{%- block extrabody %} {% endblock %}
|
||||
<div class="wy-grid-for-nav">
|
||||
{#- SIDE NAV, TOGGLES ON MOBILE #}
|
||||
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
|
||||
<div class="wy-side-scroll">
|
||||
<div class="wy-side-nav-search" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}>
|
||||
{%- block sidebartitle %}
|
||||
|
||||
{# the logo helper function was removed in Sphinx 6 and deprecated since Sphinx 4 #}
|
||||
{# the master_doc variable was renamed to root_doc in Sphinx 4 (master_doc still exists in later Sphinx versions) #}
|
||||
{%- set _logo_url = logo_url|default(pathto('_static/' + (logo or ""), 1)) %}
|
||||
{%- set _root_doc = root_doc|default(master_doc) %}
|
||||
<a href="{{ pathto(_root_doc) }}"{% if not theme_logo_only %} class="icon icon-home"{% endif %}>
|
||||
{% if not theme_logo_only %}{{ project }}{% endif %}
|
||||
{%- if logo or logo_url %}
|
||||
<img src="{{ _logo_url }}" class="logo" alt="{{ _('Logo') }}"/>
|
||||
{%- endif %}
|
||||
</a>
|
||||
|
||||
{%- if READTHEDOCS or DEBUG %}
|
||||
{%- if theme_version_selector or theme_language_selector %}
|
||||
<div class="switch-menus">
|
||||
<div class="version-switch"></div>
|
||||
<div class="language-switch"></div>
|
||||
</div>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
|
||||
{%- include "searchbox.html" %}
|
||||
|
||||
{%- endblock %}
|
||||
</div>
|
||||
|
||||
{%- block navigation %}
|
||||
{#- Translators: This is an ARIA section label for the main navigation menu -#}
|
||||
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="{{ _('Navigation menu') }}">
|
||||
{%- block menu %}
|
||||
{%- set toctree = toctree(maxdepth=theme_navigation_depth|int,
|
||||
collapse=theme_collapse_navigation|tobool,
|
||||
includehidden=theme_includehidden|tobool,
|
||||
titles_only=theme_titles_only|tobool) %}
|
||||
{%- if toctree %}
|
||||
{{ toctree }}
|
||||
{%- else %}
|
||||
<!-- Local TOC -->
|
||||
<div class="local-toc">{{ toc }}</div>
|
||||
{%- endif %}
|
||||
{%- endblock %}
|
||||
</div>
|
||||
{%- endblock %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
|
||||
|
||||
{#- 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 -#}
|
||||
<nav class="wy-nav-top" aria-label="{{ _('Mobile navigation menu') }}" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}>
|
||||
{%- block mobile_nav %}
|
||||
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
|
||||
<a href="{{ pathto(master_doc) }}">{{ project }}</a>
|
||||
{%- endblock %}
|
||||
</nav>
|
||||
|
||||
<div class="wy-nav-content">
|
||||
{%- block content %}
|
||||
{%- if theme_style_external_links|tobool %}
|
||||
<div class="rst-content style-external-links">
|
||||
{%- else %}
|
||||
<div class="rst-content">
|
||||
{%- endif %}
|
||||
{% include "breadcrumbs.html" %}
|
||||
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
|
||||
{%- block document %}
|
||||
<div itemprop="articleBody">
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
{%- if self.comments()|trim %}
|
||||
<div class="articleComments">
|
||||
{%- block comments %}{% endblock %}
|
||||
</div>
|
||||
{%- endif%}
|
||||
</div>
|
||||
{%- endblock %}
|
||||
{% include "footer.html" %}
|
||||
</div>
|
||||
{%- endblock %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% include "versions.html" -%}
|
||||
|
||||
<script>
|
||||
jQuery(function () {
|
||||
SphinxRtdTheme.Navigation.enable({{ 'true' if theme_sticky_navigation|tobool else 'false' }});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#- Do not conflict with RTD insertion of analytics script #}
|
||||
{%- if not READTHEDOCS %}
|
||||
{%- if theme_analytics_id %}
|
||||
<!-- Theme Analytics -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id={{ theme_analytics_id }}"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', '{{ theme_analytics_id }}', {
|
||||
'anonymize_ip': {{ 'true' if theme_analytics_anonymize_ip|tobool else 'false' }},
|
||||
});
|
||||
</script>
|
||||
|
||||
{%- block footer %}
|
||||
<div class="footer">
|
||||
{%- if show_copyright %}
|
||||
{%- if hasdoc('copyright') %}
|
||||
{% trans path=pathto('copyright'), copyright=copyright|e %}© <a href="{{ path }}">Copyright</a> {{ copyright }}.{% endtrans %}
|
||||
{%- else %}
|
||||
{% trans copyright=copyright|e %}© Copyright {{ copyright }}.{% endtrans %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- if last_updated %}
|
||||
{% trans last_updated=last_updated|e %}Last updated on {{ last_updated }}.{% endtrans %}
|
||||
{%- endif %}
|
||||
{%- if show_sphinx %}
|
||||
{% trans sphinx_version=sphinx_version|e %}Created using <a href="http://sphinx-doc.org/">Sphinx</a> {{ sphinx_version }}.{% endtrans %}
|
||||
{%- endif %}
|
||||
</div>
|
||||
<p>asdf asdf asdf asdf 22</p>
|
||||
{%- endblock %}
|
||||
</body>
|
||||
</html>
|
||||
{%- endif %}
|
||||
|
||||
{%- block footer %} {% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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.*
|
||||
|
||||
+19
-20
@@ -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,17 +29,17 @@ dependencies = [
|
||||
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
|
||||
"babel",
|
||||
"BeautifulSoup4==4.14.*",
|
||||
"bleach==6.2.*",
|
||||
"celery==5.5.*",
|
||||
"bleach==6.3.*",
|
||||
"celery==5.6.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=44.0.0",
|
||||
"css-inline==0.18.*",
|
||||
"css-inline==0.19.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"dnspython==2.*",
|
||||
"Django[argon2]==4.2.*,>=4.2.26",
|
||||
"django-bootstrap3==25.2",
|
||||
"django-compressor==4.5.1",
|
||||
"django-countries==7.6.*",
|
||||
"django-bootstrap3==26.1",
|
||||
"django-compressor==4.6.0",
|
||||
"django-countries==8.2.*",
|
||||
"django-filter==25.1",
|
||||
"django-formset-js-improved==0.5.0.4",
|
||||
"django-formtools==2.5.1",
|
||||
@@ -50,22 +50,22 @@ dependencies = [
|
||||
"django-localflavor==5.0",
|
||||
"django-markup",
|
||||
"django-oauth-toolkit==2.3.*",
|
||||
"django-otp==1.6.*",
|
||||
"django-phonenumber-field==7.3.*",
|
||||
"django-otp==1.7.*",
|
||||
"django-phonenumber-field==8.4.*",
|
||||
"django-redis==6.0.*",
|
||||
"django-scopes==2.0.*",
|
||||
"django-statici18n==2.6.*",
|
||||
"djangorestframework==3.16.*",
|
||||
"dnspython==2.7.*",
|
||||
"dnspython==2.8.*",
|
||||
"drf_ujson2==1.7.*",
|
||||
"geoip2==5.*",
|
||||
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||
"isoweek",
|
||||
"jsonschema",
|
||||
"kombu==5.5.*",
|
||||
"kombu==5.6.*",
|
||||
"libsass==0.23.*",
|
||||
"lxml",
|
||||
"markdown==3.9", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
"markdown==3.10", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
|
||||
"mt-940==4.30.*",
|
||||
"oauthlib==3.3.*",
|
||||
@@ -75,31 +75,30 @@ dependencies = [
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.10.*",
|
||||
"phonenumberslite==9.0.*",
|
||||
"Pillow==11.3.*",
|
||||
"Pillow==12.1.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==6.33.*",
|
||||
"psycopg2-binary",
|
||||
"pycountry",
|
||||
"pycparser==2.23",
|
||||
"pycryptodome==3.23.*",
|
||||
"pypdf==6.4.*",
|
||||
"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.46.*",
|
||||
"sentry-sdk==2.49.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
"tlds>=2020041600",
|
||||
"tqdm==4.*",
|
||||
"ua-parser==1.0.*",
|
||||
"vat_moss_forked==2020.3.20.0.11.0",
|
||||
"vobject==0.9.*",
|
||||
"webauthn==2.7.*",
|
||||
"zeep==4.3.*"
|
||||
@@ -111,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",
|
||||
@@ -124,7 +123,7 @@ dev = [
|
||||
"pytest-mock==3.15.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.8.*",
|
||||
"pytest==8.4.*",
|
||||
"pytest==9.0.*",
|
||||
"responses",
|
||||
]
|
||||
|
||||
|
||||
@@ -795,6 +795,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'invoice_address_asked',
|
||||
'invoice_address_required',
|
||||
'invoice_address_vatid',
|
||||
'invoice_address_vatid_required_countries',
|
||||
'invoice_address_company_required',
|
||||
'invoice_address_beneficiary',
|
||||
'invoice_address_custom_field',
|
||||
@@ -943,6 +944,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'invoice_address_asked',
|
||||
'invoice_address_required',
|
||||
'invoice_address_vatid',
|
||||
'invoice_address_vatid_required_countries',
|
||||
'invoice_address_company_required',
|
||||
'invoice_address_beneficiary',
|
||||
'invoice_address_custom_field',
|
||||
|
||||
@@ -1601,7 +1601,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
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -74,6 +74,11 @@ class ExportersMixin:
|
||||
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
||||
def download(self, *args, **kwargs):
|
||||
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
||||
if not cf.allowed_for_session(self.request, "exporters-api"):
|
||||
return Response(
|
||||
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
|
||||
status=status.HTTP_410_GONE
|
||||
)
|
||||
if cf.file:
|
||||
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
|
||||
@@ -109,7 +114,8 @@ class ExportersMixin:
|
||||
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
cf = CachedFile(web_download=False)
|
||||
cf = CachedFile(web_download=True)
|
||||
cf.bind_to_session(self.request, "exporters-api")
|
||||
cf.date = now()
|
||||
cf.expires = now() + timedelta(hours=24)
|
||||
cf.save()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from 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):
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -364,7 +364,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,
|
||||
]
|
||||
@@ -515,7 +515,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:
|
||||
@@ -732,7 +732,7 @@ 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.pseudonymization_id,
|
||||
op.secret,
|
||||
@@ -797,7 +797,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:
|
||||
|
||||
@@ -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 = '<br/>'.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 = '<br/>'.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)
|
||||
|
||||
+50
-12
@@ -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 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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -64,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}",
|
||||
@@ -73,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}",
|
||||
@@ -120,7 +123,6 @@ class PeppolIdValidator:
|
||||
"9951": ".*",
|
||||
"9952": ".*",
|
||||
"9953": ".*",
|
||||
"9954": ".*",
|
||||
"9956": "0[0-9]{9}",
|
||||
"9957": ".*",
|
||||
"9959": ".*",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,6 +59,37 @@ class CachedFile(models.Model):
|
||||
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins
|
||||
session_key = models.TextField(null=True, blank=True) # only allow download in this session
|
||||
|
||||
def session_key_for_request(self, request, salt=None):
|
||||
from ...api.models import OAuthAccessToken, OAuthApplication
|
||||
from .devices import Device
|
||||
from .organizer import TeamAPIToken
|
||||
|
||||
if hasattr(request, "auth") and isinstance(request.auth, OAuthAccessToken):
|
||||
k = f'app:{request.auth.application.pk}'
|
||||
elif hasattr(request, "auth") and isinstance(request.auth, OAuthApplication):
|
||||
k = f'app:{request.auth.pk}'
|
||||
elif hasattr(request, "auth") and isinstance(request.auth, TeamAPIToken):
|
||||
k = f'token:{request.auth.pk}'
|
||||
elif hasattr(request, "auth") and isinstance(request.auth, Device):
|
||||
k = f'device:{request.auth.pk}'
|
||||
elif request.session.session_key:
|
||||
k = request.session.session_key
|
||||
else:
|
||||
raise ValueError("No auth method found to bind to")
|
||||
|
||||
if salt:
|
||||
k = f"{k}!{salt}"
|
||||
return k
|
||||
|
||||
def allowed_for_session(self, request, salt=None):
|
||||
return (
|
||||
not self.session_key or
|
||||
self.session_key_for_request(request, salt) == self.session_key
|
||||
)
|
||||
|
||||
def bind_to_session(self, request, salt=None):
|
||||
self.session_key = self.session_key_for_request(request, salt)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=CachedFile)
|
||||
def cached_file_delete(sender, instance, **kwargs):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -141,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
+13
-12
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}</{el}>'.format(
|
||||
el='span',
|
||||
title=lbl,
|
||||
plain=escape(sample.plain),
|
||||
),
|
||||
'<{el} class="placeholder placeholder-html" title="{title}">{html}</{el}>'.format(
|
||||
el='div' if sample.is_block else 'span',
|
||||
title=lbl,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+186
-13
@@ -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 <will@wbond.net>
|
||||
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")),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from 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 <time datetime='{html-datetime}'>{human-readable datetime}</time> 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("<time datetime='{}'>{}</time>", date_html, date_human)
|
||||
except AttributeError:
|
||||
return ''
|
||||
@@ -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)
|
||||
|
||||
@@ -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(?<![@.])(?:(?:{0}):/{{0,3}}(?:(?:\w+:)?\w+@)?)? # http://
|
||||
([\w-]+\.)+(?:{1})(?:\:[0-9]+)?(?!\.\w)\b # xx.yy.tld(:##)?
|
||||
(?:[/?][^\s\|\\\^`<>"]*)?
|
||||
# /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)
|
||||
|
||||
@@ -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'
|
||||
))
|
||||
|
||||
@@ -36,9 +36,8 @@ class DownloadView(TemplateView):
|
||||
def object(self) -> CachedFile:
|
||||
try:
|
||||
o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True)
|
||||
if o.session_key:
|
||||
if o.session_key != self.request.session.session_key:
|
||||
raise Http404()
|
||||
if not o.allowed_for_session(self.request):
|
||||
raise Http404()
|
||||
return o
|
||||
except (ValueError, ValidationError): # Invalid URLs
|
||||
raise Http404()
|
||||
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
@@ -1309,9 +1311,17 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
|
||||
mail_text_order_invoice = I18nFormField(
|
||||
label=_("Text"),
|
||||
required=False,
|
||||
widget=I18nMarkdownTextarea,
|
||||
help_text=_("This will only be used if the invoice is sent to a different email address or at a different time "
|
||||
"than the order confirmation."),
|
||||
widget=I18nTextarea, # no Markdown supported
|
||||
help_text=lazy(
|
||||
lambda: str(_(
|
||||
"This will only be used if the invoice is sent to a different email address or at a different time "
|
||||
"than the order confirmation."
|
||||
)) + " " + str(_(
|
||||
"Formatting is not supported, as some accounting departments process mail automatically and do not "
|
||||
"handle formatted emails properly."
|
||||
)),
|
||||
str
|
||||
)()
|
||||
)
|
||||
mail_subject_download_reminder = I18nFormField(
|
||||
label=_("Subject sent to order contact address"),
|
||||
@@ -1479,6 +1489,9 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
|
||||
'mail_subject_resend_all_links': ['event', 'orders'],
|
||||
'mail_attach_ical_description': ['event', 'event_or_subevent'],
|
||||
}
|
||||
plain_rendering = {
|
||||
'mail_text_order_invoice',
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = event = kwargs.get('obj')
|
||||
@@ -1497,7 +1510,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
|
||||
self.event.meta_values_cached = self.event.meta_values.select_related('property').all()
|
||||
|
||||
for k, v in self.base_context.items():
|
||||
self._set_field_placeholders(k, v, rich=k.startswith('mail_text_'))
|
||||
self._set_field_placeholders(k, v, rich=k.startswith('mail_text_') and k not in self.plain_rendering)
|
||||
|
||||
for k, v in list(self.fields.items()):
|
||||
if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
|
||||
@@ -1957,6 +1970,13 @@ class EventFooterLinkForm(I18nModelForm):
|
||||
class Meta:
|
||||
model = EventFooterLink
|
||||
fields = ('label', 'url')
|
||||
widgets = {
|
||||
"url": forms.URLInput(
|
||||
attrs={
|
||||
"placeholder": "https://..."
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class BaseEventFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet):
|
||||
|
||||
@@ -61,6 +61,10 @@ from pretix.base.models import (
|
||||
SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher,
|
||||
)
|
||||
from pretix.base.signals import register_payment_providers
|
||||
from pretix.base.timeframes import (
|
||||
DateFrameField,
|
||||
resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
|
||||
)
|
||||
from pretix.control.forms import SplitDateTimeField
|
||||
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
|
||||
from pretix.control.signals import order_search_filter_q
|
||||
@@ -1219,6 +1223,129 @@ class OrderPaymentSearchFilterForm(forms.Form):
|
||||
return qs
|
||||
|
||||
|
||||
class QuestionAnswerFilterForm(forms.Form):
|
||||
STATUS_VARIANTS = [
|
||||
("", _("All orders")),
|
||||
(Order.STATUS_PAID, _("Paid")),
|
||||
(Order.STATUS_PAID + 'v', _("Paid or confirmed")),
|
||||
(Order.STATUS_PENDING, _("Pending")),
|
||||
(Order.STATUS_PENDING + Order.STATUS_PAID, _("Pending or paid")),
|
||||
("o", _("Pending (overdue)")),
|
||||
(Order.STATUS_EXPIRED, _("Expired")),
|
||||
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _("Pending or expired")),
|
||||
(Order.STATUS_CANCELED, _("Canceled"))
|
||||
]
|
||||
|
||||
status = forms.ChoiceField(
|
||||
choices=STATUS_VARIANTS,
|
||||
required=False,
|
||||
label=_("Order status"),
|
||||
)
|
||||
item = forms.ChoiceField(
|
||||
choices=[],
|
||||
required=False,
|
||||
label=_("Products"),
|
||||
)
|
||||
subevent = forms.ModelChoiceField(
|
||||
queryset=SubEvent.objects.none(),
|
||||
required=False,
|
||||
empty_label=pgettext_lazy('subevent', 'All dates'),
|
||||
label=pgettext_lazy("subevent", "Date"),
|
||||
)
|
||||
date_range = DateFrameField(
|
||||
required=False,
|
||||
include_future_frames=True,
|
||||
label=_('Event date'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.event = kwargs.pop('event')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.initial['status'] = Order.STATUS_PENDING + Order.STATUS_PAID
|
||||
|
||||
choices = [('', _('All products'))]
|
||||
for i in self.event.items.prefetch_related('variations').all():
|
||||
variations = list(i.variations.all())
|
||||
if variations:
|
||||
choices.append((str(i.pk), _('{product} – Any variation').format(product=str(i))))
|
||||
for v in variations:
|
||||
choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (str(i), v.value)))
|
||||
else:
|
||||
choices.append((str(i.pk), str(i)))
|
||||
self.fields['item'].choices = choices
|
||||
|
||||
if self.event.has_subevents:
|
||||
self.fields["subevent"].queryset = self.event.subevents.all()
|
||||
self.fields['subevent'].widget = Select2(
|
||||
attrs={
|
||||
'data-model-select2': 'event',
|
||||
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
|
||||
'event': self.event.slug,
|
||||
'organizer': self.event.organizer.slug,
|
||||
}),
|
||||
'data-placeholder': pgettext_lazy('subevent', 'All dates')
|
||||
}
|
||||
)
|
||||
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
|
||||
else:
|
||||
del self.fields['subevent']
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
subevent = cleaned_data.get('subevent')
|
||||
date_range = cleaned_data.get('date_range')
|
||||
|
||||
if subevent is not None and date_range is not None:
|
||||
d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone)
|
||||
if (
|
||||
(d_start and not (d_start <= subevent.date_from)) or
|
||||
(d_end and not (subevent.date_from < d_end))
|
||||
):
|
||||
self.add_error('subevent', pgettext_lazy('subevent', "Date doesn't start in selected date range."))
|
||||
return cleaned_data
|
||||
|
||||
def filter_qs(self, opqs):
|
||||
fdata = self.cleaned_data
|
||||
|
||||
subevent = fdata.get('subevent', None)
|
||||
date_range = fdata.get('date_range', None)
|
||||
|
||||
if subevent is not None:
|
||||
opqs = opqs.filter(subevent=subevent)
|
||||
|
||||
if date_range is not None:
|
||||
d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone)
|
||||
opqs = opqs.filter(
|
||||
subevent__date_from__gte=d_start,
|
||||
subevent__date_from__lt=d_end
|
||||
)
|
||||
|
||||
s = fdata.get("status", Order.STATUS_PENDING + Order.STATUS_PAID)
|
||||
if s != "":
|
||||
if s == Order.STATUS_PENDING:
|
||||
opqs = opqs.filter(order__status=Order.STATUS_PENDING,
|
||||
order__expires__lt=now().replace(hour=0, minute=0, second=0))
|
||||
elif s == Order.STATUS_PENDING + Order.STATUS_PAID:
|
||||
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
|
||||
elif s == Order.STATUS_PAID + 'v':
|
||||
opqs = opqs.filter(
|
||||
Q(order__status=Order.STATUS_PAID) |
|
||||
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
|
||||
)
|
||||
elif s == Order.STATUS_PENDING + Order.STATUS_EXPIRED:
|
||||
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
|
||||
else:
|
||||
opqs = opqs.filter(order__status=s)
|
||||
|
||||
if s not in (Order.STATUS_CANCELED, ""):
|
||||
opqs = opqs.filter(canceled=False)
|
||||
if fdata.get("item", "") != "":
|
||||
i = fdata.get("item", "")
|
||||
opqs = opqs.filter(item_id__in=(i,))
|
||||
|
||||
return opqs
|
||||
|
||||
|
||||
class SubEventFilterForm(FilterForm):
|
||||
orders = {
|
||||
'date_from': 'date_from',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="col-md-2">
|
||||
{% bootstrap_field formset.empty_form.overwrite layout='inline' form_group_class="" %}
|
||||
</div>
|
||||
{{ f.value_map.as_hidden }}
|
||||
{{ formset.empty_form.value_map.as_hidden }}
|
||||
<div class="col-md-2 text-right flip">
|
||||
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
|
||||
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
{% bootstrap_field form.invoice_name_required layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_company_required layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_vatid layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_vatid_required_countries layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_beneficiary layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_not_asked_free layout="control" %}
|
||||
{% bootstrap_field form.invoice_address_custom_field layout="control" %}
|
||||
|
||||
@@ -20,35 +20,20 @@
|
||||
</div>
|
||||
<form class="panel-body filter-form" action="" method="get">
|
||||
<div class="row">
|
||||
<div class="col-lg-2 col-sm-6 col-xs-6">
|
||||
<select name="status" class="form-control">
|
||||
<option value="" {% if request.GET.status == "" %}selected="selected"{% endif %}>{% trans "All orders" %}</option>
|
||||
<option value="p" {% if request.GET.status == "p" %}selected="selected"{% endif %}>{% trans "Paid" %}</option>
|
||||
<option value="pv" {% if request.GET.status == "pv" %}selected="selected"{% endif %}>{% trans "Paid or confirmed" %}</option>
|
||||
<option value="n" {% if request.GET.status == "n" %}selected="selected"{% endif %}>{% trans "Pending" %}</option>
|
||||
<option value="np" {% if request.GET.status == "np" or "status" not in request.GET %}selected="selected"{% endif %}>{% trans "Pending or paid" %}</option>
|
||||
<option value="o" {% if request.GET.status == "o" %}selected="selected"{% endif %}>{% trans "Pending (overdue)" %}</option>
|
||||
<option value="e" {% if request.GET.status == "e" %}selected="selected"{% endif %}>{% trans "Expired" %}</option>
|
||||
<option value="ne" {% if request.GET.status == "ne" %}selected="selected"{% endif %}>{% trans "Pending or expired" %}</option>
|
||||
<option value="c" {% if request.GET.status == "c" %}selected="selected"{% endif %}>{% trans "Canceled" %}</option>
|
||||
</select>
|
||||
<div class="col-md-2 col-xs-6">
|
||||
{% bootstrap_field form.status %}
|
||||
</div>
|
||||
<div class="col-md-3 col-xs-6">
|
||||
{% bootstrap_field form.item %}
|
||||
</div>
|
||||
{% if has_subevents %}
|
||||
<div class="col-md-3 col-xs-6">
|
||||
{% bootstrap_field form.subevent %}
|
||||
</div>
|
||||
<div class="col-lg-5 col-sm-6 col-xs-6">
|
||||
<select name="item" class="form-control">
|
||||
<option value="">{% trans "All products" %}</option>
|
||||
{% for item in items %}
|
||||
<option value="{{ item.id }}"
|
||||
{% if request.GET.item|add:0 == item.id %}selected="selected"{% endif %}>
|
||||
{{ item.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="col-md-4 col-xs-6">
|
||||
{% bootstrap_field form.date_range %}
|
||||
</div>
|
||||
{% if request.event.has_subevents %}
|
||||
<div class="col-lg-5 col-sm-6 col-xs-6">
|
||||
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<button class="btn btn-primary btn-lg" type="submit">
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
<legend>{% trans "Customer accounts" %}</legend>
|
||||
{% bootstrap_field sform.customer_accounts layout="control" %}
|
||||
{% bootstrap_field sform.customer_accounts_native layout="control" %}
|
||||
{% bootstrap_field sform.customer_accounts_require_login_for_order_access layout="control" %}
|
||||
{% bootstrap_field sform.customer_accounts_link_by_email layout="control" %}
|
||||
{% bootstrap_field sform.name_scheme layout="control" %}
|
||||
{% bootstrap_field sform.name_scheme_titles layout="control" %}
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
{% if v.budget|default_if_none:"NONE" != "NONE" %}
|
||||
<br>
|
||||
<small class="text-muted">
|
||||
{{ v.budget_used_orders|money:request.event.currency }} / {{ v.budget|money:request.event.currency }}
|
||||
{{ v.budget_used|money:request.event.currency }} / {{ v.budget|money:request.event.currency }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -363,7 +363,7 @@ class Forgot(TemplateView):
|
||||
else:
|
||||
messages.info(request, _('If the address is registered to valid account, then we have sent you an email containing further instructions.'))
|
||||
|
||||
return redirect('control:auth.forgot')
|
||||
return redirect('control:auth.forgot')
|
||||
else:
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ from pretix.base.models import (
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.timeline import timeline_for_event
|
||||
from pretix.control.forms.event import CommentForm
|
||||
from pretix.control.signals import (
|
||||
event_dashboard_widgets, user_dashboard_widgets,
|
||||
)
|
||||
@@ -341,6 +340,8 @@ def welcome_wizard_widget(sender, **kwargs):
|
||||
|
||||
|
||||
def event_index(request, organizer, event):
|
||||
from pretix.control.forms.event import CommentForm
|
||||
|
||||
subevent = None
|
||||
if request.GET.get("subevent", "") != "" and request.event.has_subevents:
|
||||
i = request.GET.get("subevent", "")
|
||||
|
||||
@@ -98,7 +98,6 @@ from pretix.control.views.mailsetup import MailSettingsSetupView
|
||||
from pretix.control.views.user import RecentAuthenticationRequiredMixin
|
||||
from pretix.helpers.database import rolledback_transaction
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri, get_event_domain
|
||||
from pretix.plugins.stripe.payment import StripeSettingsHolder
|
||||
from pretix.presale.views.widget import (
|
||||
version_default as widget_version_default,
|
||||
)
|
||||
@@ -830,8 +829,8 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
|
||||
return locales
|
||||
|
||||
# get all supported placeholders with dummy values
|
||||
def placeholders(self, item):
|
||||
return get_sample_context(self.request.event, MailSettingsForm.base_context[item])
|
||||
def placeholders(self, item, rich=True):
|
||||
return get_sample_context(self.request.event, MailSettingsForm.base_context[item], rich=rich)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
preview_item = request.POST.get('item', '')
|
||||
@@ -852,6 +851,14 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
|
||||
msgs[self.supported_locale[idx]] = prefix_subject(self.request.event, format_map(
|
||||
bleach.clean(v), self.placeholders(preview_item), raise_on_missing=True
|
||||
), highlight=True)
|
||||
elif preview_item in MailSettingsForm.plain_rendering:
|
||||
msgs[self.supported_locale[idx]] = mark_safe(
|
||||
format_map(
|
||||
conditional_escape(v),
|
||||
self.placeholders(preview_item, rich=False),
|
||||
raise_on_missing=True
|
||||
).replace("\n", "<br />")
|
||||
)
|
||||
else:
|
||||
placeholders = self.placeholders(preview_item)
|
||||
msgs[self.supported_locale[idx]] = format_map(
|
||||
@@ -1666,6 +1673,8 @@ class QuickSetupView(FormView):
|
||||
'or take your event live to start selling!'))
|
||||
|
||||
if form.cleaned_data.get('payment_stripe__enabled', False):
|
||||
from pretix.plugins.stripe.payment import StripeSettingsHolder
|
||||
|
||||
self.request.session['payment_stripe_oauth_enable'] = True
|
||||
return redirect(StripeSettingsHolder(self.request.event).get_connect_url(self.request))
|
||||
|
||||
|
||||
@@ -188,6 +188,8 @@ class LicenseCheckView(StaffMemberRequiredMixin, FormView):
|
||||
return None, None
|
||||
try:
|
||||
for k, v in pkg.metadata.items():
|
||||
if k == "License-Expression":
|
||||
license = v
|
||||
if k == "License":
|
||||
license = v
|
||||
if k == "Home-page":
|
||||
|
||||
@@ -65,7 +65,7 @@ from pretix.api.serializers.item import (
|
||||
)
|
||||
from pretix.base.forms import I18nFormSet
|
||||
from pretix.base.models import (
|
||||
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, Order,
|
||||
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation,
|
||||
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
|
||||
SeatCategoryMapping, Voucher,
|
||||
)
|
||||
@@ -74,6 +74,7 @@ from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tickets import invalidate_cache
|
||||
from pretix.base.signals import quota_availability
|
||||
from pretix.control.forms.filter import QuestionAnswerFilterForm
|
||||
from pretix.control.forms.item import (
|
||||
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
|
||||
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
|
||||
@@ -660,46 +661,26 @@ class QuestionMixin:
|
||||
return ctx
|
||||
|
||||
|
||||
class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingView, DetailView):
|
||||
class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView):
|
||||
model = Question
|
||||
template_name = 'pretixcontrol/items/question.html'
|
||||
permission = 'can_change_items'
|
||||
template_name_field = 'question'
|
||||
|
||||
@cached_property
|
||||
def filter_form(self):
|
||||
return QuestionAnswerFilterForm(event=self.request.event, data=self.request.GET)
|
||||
|
||||
def get_answer_statistics(self):
|
||||
opqs = OrderPosition.objects.filter(
|
||||
order__event=self.request.event,
|
||||
)
|
||||
if self.filter_form.is_valid():
|
||||
opqs = self.filter_form.filter_qs(opqs)
|
||||
|
||||
qs = QuestionAnswer.objects.filter(
|
||||
question=self.object, orderposition__isnull=False,
|
||||
)
|
||||
|
||||
if self.request.GET.get("subevent", "") != "":
|
||||
opqs = opqs.filter(subevent=self.request.GET["subevent"])
|
||||
|
||||
s = self.request.GET.get("status", "np")
|
||||
if s != "":
|
||||
if s == 'o':
|
||||
opqs = opqs.filter(order__status=Order.STATUS_PENDING,
|
||||
order__expires__lt=now().replace(hour=0, minute=0, second=0))
|
||||
elif s == 'np':
|
||||
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
|
||||
elif s == 'pv':
|
||||
opqs = opqs.filter(
|
||||
Q(order__status=Order.STATUS_PAID) |
|
||||
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
|
||||
)
|
||||
elif s == 'ne':
|
||||
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 self.request.GET.get("item", "") != "":
|
||||
i = self.request.GET.get("item", "")
|
||||
opqs = opqs.filter(item_id__in=(i,))
|
||||
|
||||
qs = qs.filter(orderposition__in=opqs)
|
||||
op_cnt = opqs.filter(item__in=self.object.items.all()).count()
|
||||
|
||||
@@ -746,9 +727,11 @@ class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingV
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data()
|
||||
ctx['items'] = self.object.items.all()
|
||||
ctx['items'] = self.object.items.exists()
|
||||
ctx['has_subevents'] = self.request.event.has_subevents
|
||||
stats = self.get_answer_statistics()
|
||||
ctx['stats'], ctx['total'] = stats
|
||||
ctx['form'] = self.filter_form
|
||||
return ctx
|
||||
|
||||
def get_object(self, queryset=None) -> Question:
|
||||
|
||||
@@ -38,6 +38,7 @@ from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
@@ -85,6 +86,7 @@ class BaseImportView(TemplateView):
|
||||
filename='import.csv',
|
||||
type='text/csv',
|
||||
)
|
||||
cf.bind_to_session(request, "modelimport")
|
||||
cf.file.save('import.csv', request.FILES['file'])
|
||||
|
||||
if self.request.POST.get("charset") in ENCODINGS:
|
||||
@@ -137,7 +139,10 @@ class BaseProcessView(AsyncAction, FormView):
|
||||
|
||||
@cached_property
|
||||
def file(self):
|
||||
return get_object_or_404(CachedFile, pk=self.kwargs.get("file"), filename="import.csv")
|
||||
cf = get_object_or_404(CachedFile, pk=self.kwargs.get("file"), filename="import.csv")
|
||||
if not cf.allowed_for_session(self.request, "modelimport"):
|
||||
raise Http404()
|
||||
return cf
|
||||
|
||||
@cached_property
|
||||
def parsed(self):
|
||||
@@ -146,7 +151,7 @@ class BaseProcessView(AsyncAction, FormView):
|
||||
else:
|
||||
charset = None
|
||||
try:
|
||||
return parse_csv(self.file.file, 1024 * 1024, charset=charset)
|
||||
reader = parse_csv(self.file.file, 1024 * 1024, charset=charset)
|
||||
except UnicodeDecodeError:
|
||||
messages.warning(
|
||||
self.request,
|
||||
@@ -155,7 +160,16 @@ class BaseProcessView(AsyncAction, FormView):
|
||||
"Some characters were replaced with a placeholder."
|
||||
)
|
||||
)
|
||||
return parse_csv(self.file.file, 1024 * 1024, "replace", charset=charset)
|
||||
reader = parse_csv(self.file.file, 1024 * 1024, "replace", charset=charset)
|
||||
if reader and reader._had_duplicates:
|
||||
messages.warning(
|
||||
self.request,
|
||||
_(
|
||||
"Multiple columns of the CSV file have the same name and were renamed automatically. We "
|
||||
"recommend that you rename these in your source file to avoid problems during import."
|
||||
)
|
||||
)
|
||||
return reader
|
||||
|
||||
@cached_property
|
||||
def parsed_list(self):
|
||||
|
||||
@@ -3016,7 +3016,7 @@ class EventCancel(EventPermissionRequiredMixin, AsyncAction, FormView):
|
||||
return _('All orders have been canceled.')
|
||||
else:
|
||||
return _('The orders have been canceled. An error occurred with {count} orders, please '
|
||||
'check all uncanceled orders.').format(count=value)
|
||||
'check all uncanceled orders.').format(count=value["failed"])
|
||||
|
||||
def get_success_url(self, value):
|
||||
if settings.HAS_CELERY:
|
||||
@@ -3097,7 +3097,7 @@ class EventCancelConfirm(EventPermissionRequiredMixin, AsyncAction, FormView):
|
||||
return _('All orders have been canceled.')
|
||||
else:
|
||||
return _('The orders have been canceled. An error occurred with {count} orders, please '
|
||||
'check all uncanceled orders.').format(count=value)
|
||||
'check all uncanceled orders.').format(count=value["failed"])
|
||||
|
||||
def get_success_url(self, value):
|
||||
return reverse('control:event.cancel', kwargs={
|
||||
|
||||
@@ -2583,7 +2583,7 @@ class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
|
||||
def get_queryset(self):
|
||||
# technically, we'd also need to sort by pk since this is a paginated list, but in this case we just can't
|
||||
# bear the performance cost
|
||||
qs = self.request.organizer.all_logentries().select_related(
|
||||
qs = self.request.organizer.logentry_set.filter(event=None).select_related(
|
||||
'user', 'content_type', 'api_token', 'oauth_application', 'device'
|
||||
).order_by('-datetime')
|
||||
qs = qs.exclude(action_type__in=OVERVIEW_BANLIST)
|
||||
|
||||
@@ -247,7 +247,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
|
||||
cf = None
|
||||
if request.POST.get("background", "").strip():
|
||||
try:
|
||||
cf = CachedFile.objects.get(id=request.POST.get("background"))
|
||||
cf = CachedFile.objects.get(id=request.POST.get("background"), web_download=True)
|
||||
except CachedFile.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ from collections import OrderedDict
|
||||
from zipfile import ZipFile
|
||||
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.http import Http404
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import get_language, gettext_lazy as _
|
||||
@@ -94,6 +95,8 @@ class ShredDownloadView(RecentAuthenticationRequiredMixin, EventPermissionRequir
|
||||
cf = CachedFile.objects.get(pk=kwargs['file'])
|
||||
except CachedFile.DoesNotExist:
|
||||
raise ShredError(_("The download file could no longer be found on the server, please try to start again."))
|
||||
if not cf.allowed_for_session(self.request):
|
||||
raise Http404()
|
||||
|
||||
with ZipFile(cf.file.file, 'r') as zipfile:
|
||||
indexdata = json.loads(zipfile.read('index.json').decode())
|
||||
@@ -111,7 +114,7 @@ class ShredDownloadView(RecentAuthenticationRequiredMixin, EventPermissionRequir
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['shredders'] = self.shredders
|
||||
ctx['download_on_shred'] = any(shredder.require_download_confirmation for shredder in shredders)
|
||||
ctx['file'] = get_object_or_404(CachedFile, pk=kwargs.get("file"))
|
||||
ctx['file'] = cf
|
||||
return ctx
|
||||
|
||||
|
||||
|
||||
@@ -569,7 +569,7 @@ def category_select2(request, **kwargs):
|
||||
page = 1
|
||||
|
||||
qs = request.event.categories.filter(
|
||||
name__icontains=i18ncomp(query)
|
||||
Q(name__icontains=i18ncomp(query)) | Q(internal_name__icontains=query)
|
||||
).order_by('name')
|
||||
|
||||
total = qs.count()
|
||||
|
||||
@@ -87,7 +87,7 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView):
|
||||
|
||||
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries
|
||||
def get_queryset(self):
|
||||
qs = Voucher.annotate_budget_used_orders(self.request.event.vouchers.exclude(
|
||||
qs = Voucher.annotate_budget_used(self.request.event.vouchers.exclude(
|
||||
Exists(WaitingListEntry.objects.filter(voucher_id=OuterRef('pk')))
|
||||
).select_related(
|
||||
'item', 'variation', 'seat'
|
||||
|
||||
@@ -25,7 +25,9 @@ from django.utils import translation
|
||||
from django.utils.translation import gettext_noop
|
||||
from django_countries import Countries, collator
|
||||
from django_countries.fields import CountryField
|
||||
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
|
||||
from phonenumbers import (
|
||||
COUNTRY_CODE_TO_REGION_CODE, REGION_CODE_FOR_NON_GEO_ENTITY,
|
||||
)
|
||||
|
||||
from pretix.base.i18n import get_babel_locale, get_language_without_region
|
||||
|
||||
@@ -40,6 +42,10 @@ class CachedCountries(Countries):
|
||||
django-countries performs a unicode-aware sorting based on pyuca which is incredibly
|
||||
slow.
|
||||
"""
|
||||
# Starting in django-countries 8.1, django-countries implemented a similar caching, but only on object level.
|
||||
# We keep our caching for now for the added caching to the cache store. Unfortunately we can't really avoid
|
||||
# the double-caching on object level if we want the caches od be used in a sensible order. We could re-evaluate
|
||||
# and drop this in the future if it ever causes bugs or memory issues.
|
||||
cache_key = "countries:all:{}".format(get_language_without_region())
|
||||
if self.cache_subkey:
|
||||
cache_key += ":" + self.cache_subkey
|
||||
@@ -84,8 +90,6 @@ class FastCountryField(CountryField):
|
||||
*self._check_backend_specific_checks(**kwargs),
|
||||
*self._check_validators(),
|
||||
*self._check_deprecation_details(),
|
||||
*self._check_multiple(),
|
||||
*self._check_max_length_attribute(**kwargs),
|
||||
]
|
||||
|
||||
|
||||
@@ -107,9 +111,11 @@ def get_phone_prefixes_sorted_and_localized():
|
||||
val = []
|
||||
|
||||
locale = Locale(translation.to_locale(language))
|
||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
prefix = "+%d" % prefix
|
||||
for country_code in values:
|
||||
if country_code == REGION_CODE_FOR_NON_GEO_ENTITY:
|
||||
continue
|
||||
country_name = locale.territories.get(country_code)
|
||||
if country_name:
|
||||
val.append((prefix, "{} {}".format(country_name, prefix)))
|
||||
|
||||
@@ -229,7 +229,8 @@ def get_language_score(locale):
|
||||
if not catalog:
|
||||
score = 1
|
||||
else:
|
||||
score = len(list(catalog.items())) or 1
|
||||
source_strings = [k[1] if isinstance(k, tuple) else k for k in catalog.keys()]
|
||||
score = len(set(source_strings)) or 1
|
||||
return score
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import text_unidecode
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def dotdecimal(value):
|
||||
return str(value).replace(",", ".")
|
||||
|
||||
|
||||
def commadecimal(value):
|
||||
return str(value).replace(".", ",")
|
||||
|
||||
|
||||
def generate_payment_qr_codes(
|
||||
event,
|
||||
code,
|
||||
amount,
|
||||
bank_details_sepa_bic,
|
||||
bank_details_sepa_name,
|
||||
bank_details_sepa_iban,
|
||||
):
|
||||
out = []
|
||||
for method in [
|
||||
swiss_qrbill,
|
||||
czech_spayd,
|
||||
euro_epc_qr,
|
||||
euro_bezahlcode,
|
||||
]:
|
||||
data = method(
|
||||
event,
|
||||
code,
|
||||
amount,
|
||||
bank_details_sepa_bic,
|
||||
bank_details_sepa_name,
|
||||
bank_details_sepa_iban,
|
||||
)
|
||||
if data:
|
||||
out.append(data)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def euro_epc_qr(
|
||||
event,
|
||||
code,
|
||||
amount,
|
||||
bank_details_sepa_bic,
|
||||
bank_details_sepa_name,
|
||||
bank_details_sepa_iban,
|
||||
):
|
||||
if event.currency != 'EUR' or not bank_details_sepa_iban:
|
||||
return
|
||||
|
||||
return {
|
||||
"id": "girocode",
|
||||
"label": "EPC-QR",
|
||||
"qr_data": "\n".join(text_unidecode.unidecode(str(d or '')) for d in [
|
||||
"BCD", # Service Tag: ‘BCD’
|
||||
"002", # Version: V2
|
||||
"2", # Character set: ISO 8859-1
|
||||
"SCT", # Identification code: ‘SCT‘
|
||||
bank_details_sepa_bic, # AT-23 BIC of the Beneficiary Bank
|
||||
bank_details_sepa_name, # AT-21 Name of the Beneficiary
|
||||
bank_details_sepa_iban, # AT-20 Account number of the Beneficiary
|
||||
f"{event.currency}{dotdecimal(amount)}", # AT-04 Amount of the Credit Transfer in Euro
|
||||
"", # AT-44 Purpose of the Credit Transfer
|
||||
"", # AT-05 Remittance Information (Structured)
|
||||
code, # AT-05 Remittance Information (Unstructured)
|
||||
"", # Beneficiary to originator information
|
||||
"",
|
||||
]),
|
||||
}
|
||||
|
||||
|
||||
def euro_bezahlcode(
|
||||
event,
|
||||
code,
|
||||
amount,
|
||||
bank_details_sepa_bic,
|
||||
bank_details_sepa_name,
|
||||
bank_details_sepa_iban,
|
||||
):
|
||||
if not bank_details_sepa_iban or bank_details_sepa_iban[:2] != 'DE':
|
||||
return
|
||||
if event.currency != 'EUR':
|
||||
return
|
||||
|
||||
qr_data = "bank://singlepaymentsepa?" + urlencode({
|
||||
"name": str(bank_details_sepa_name),
|
||||
"iban": str(bank_details_sepa_iban),
|
||||
"bic": str(bank_details_sepa_bic),
|
||||
"amount": commadecimal(amount),
|
||||
"reason": str(code),
|
||||
"currency": str(event.currency),
|
||||
}, quote_via=quote)
|
||||
return {
|
||||
"id": "bezahlcode",
|
||||
"label": "BezahlCode",
|
||||
"qr_data": mark_safe(qr_data),
|
||||
"link": qr_data,
|
||||
"link_aria_label": _("Open BezahlCode in your banking app to start the payment process."),
|
||||
}
|
||||
|
||||
|
||||
def swiss_qrbill(
|
||||
event,
|
||||
code,
|
||||
amount,
|
||||
bank_details_sepa_bic,
|
||||
bank_details_sepa_name,
|
||||
bank_details_sepa_iban,
|
||||
):
|
||||
if not bank_details_sepa_iban or not bank_details_sepa_iban[:2] in ('CH', 'LI'):
|
||||
return
|
||||
if event.currency not in ('EUR', 'CHF'):
|
||||
return
|
||||
if not event.settings.invoice_address_from or not event.settings.invoice_address_from_country:
|
||||
return
|
||||
|
||||
data_fields = [
|
||||
'SPC',
|
||||
'0200',
|
||||
'1',
|
||||
bank_details_sepa_iban,
|
||||
'K',
|
||||
bank_details_sepa_name[:70],
|
||||
event.settings.invoice_address_from.replace('\n', ', ')[:70],
|
||||
(event.settings.invoice_address_from_zipcode + ' ' + event.settings.invoice_address_from_city)[:70],
|
||||
'',
|
||||
'',
|
||||
str(event.settings.invoice_address_from_country),
|
||||
'', # rfu
|
||||
'', # rfu
|
||||
'', # rfu
|
||||
'', # rfu
|
||||
'', # rfu
|
||||
'', # rfu
|
||||
'', # rfu
|
||||
str(amount),
|
||||
event.currency,
|
||||
'', # debtor address
|
||||
'', # debtor address
|
||||
'', # debtor address
|
||||
'', # debtor address
|
||||
'', # debtor address
|
||||
'', # debtor address
|
||||
'', # debtor address
|
||||
'NON',
|
||||
'', # structured reference
|
||||
code,
|
||||
'EPD',
|
||||
]
|
||||
|
||||
data_fields = [text_unidecode.unidecode(d or '') for d in data_fields]
|
||||
qr_data = '\r\n'.join(data_fields)
|
||||
return {
|
||||
"id": "qrbill",
|
||||
"label": "QR-bill",
|
||||
"html_prefix": mark_safe(
|
||||
'<svg class="banktransfer-swiss-cross" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.8 19.8">'
|
||||
'<path stroke="#fff" stroke-width="1.436" d="M.7.7h18.4v18.4H.7z"/><path fill="#fff" d="M8.3 4h3.3v11H8.3z"/>'
|
||||
'<path fill="#fff" d="M4.4 7.9h11v3.3h-11z"/></svg>'
|
||||
),
|
||||
"qr_data": qr_data,
|
||||
"css_class": "banktransfer-swiss-cross-overlay",
|
||||
}
|
||||
|
||||
|
||||
def czech_spayd(
|
||||
event,
|
||||
code,
|
||||
amount,
|
||||
bank_details_sepa_bic,
|
||||
bank_details_sepa_name,
|
||||
bank_details_sepa_iban,
|
||||
):
|
||||
if not bank_details_sepa_iban or not bank_details_sepa_iban[:2] in ('CZ', 'SK'):
|
||||
return
|
||||
if event.currency not in ('EUR', 'CZK'):
|
||||
return
|
||||
|
||||
qr_data = f"SPD*1.0*ACC:{bank_details_sepa_iban}*AM:{dotdecimal(amount)}*CC:{event.currency}*MSG:{code}"
|
||||
return {
|
||||
"id": "spayd",
|
||||
"label": "SPAYD",
|
||||
"qr_data": qr_data,
|
||||
}
|
||||
@@ -100,3 +100,11 @@ class FontFallbackParagraph(Paragraph):
|
||||
if (original_font.endswith("Bd") or original_font.endswith(" B")) and "bold" in styles:
|
||||
return family + " B"
|
||||
return family
|
||||
|
||||
|
||||
def register_ttf_font_if_new(name, path):
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
|
||||
if name not in pdfmetrics.getRegisteredFontNames():
|
||||
pdfmetrics.registerFont(TTFont(name, path))
|
||||
|
||||
+1992
-1871
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
|
||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -140,7 +140,7 @@ msgstr ""
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:267
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:284
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:317
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:341
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:343
|
||||
msgid "Confirming your payment …"
|
||||
msgstr ""
|
||||
|
||||
@@ -344,7 +344,7 @@ msgstr ""
|
||||
msgid "close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/addressform.js:98
|
||||
#: pretix/static/pretixbase/js/addressform.js:101
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:529
|
||||
msgid "required"
|
||||
msgstr ""
|
||||
|
||||
+2044
-1884
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
|
||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
||||
"PO-Revision-Date: 2021-09-15 11:22+0000\n"
|
||||
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
|
||||
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -144,7 +144,7 @@ msgstr "المتابعة"
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:267
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:284
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:317
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:341
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:343
|
||||
msgid "Confirming your payment …"
|
||||
msgstr "جاري تأكيد الدفع الخاص بك …"
|
||||
|
||||
@@ -359,7 +359,7 @@ msgstr "لا"
|
||||
msgid "close"
|
||||
msgstr "إغلاق"
|
||||
|
||||
#: pretix/static/pretixbase/js/addressform.js:98
|
||||
#: pretix/static/pretixbase/js/addressform.js:101
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:529
|
||||
msgid "required"
|
||||
msgstr "مطلوب"
|
||||
|
||||
+1992
-1871
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
|
||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -140,7 +140,7 @@ msgstr ""
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:267
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:284
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:317
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:341
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:343
|
||||
msgid "Confirming your payment …"
|
||||
msgstr ""
|
||||
|
||||
@@ -344,7 +344,7 @@ msgstr ""
|
||||
msgid "close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/addressform.js:98
|
||||
#: pretix/static/pretixbase/js/addressform.js:101
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:529
|
||||
msgid "required"
|
||||
msgstr ""
|
||||
|
||||
+2038
-1886
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
|
||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
||||
"PO-Revision-Date: 2025-10-31 17:00+0000\n"
|
||||
"Last-Translator: Núria Masclans <nuriamasclansserrat@gmail.com>\n"
|
||||
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
|
||||
@@ -141,7 +141,7 @@ msgstr "Continua"
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:267
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:284
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:317
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:341
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:343
|
||||
msgid "Confirming your payment …"
|
||||
msgstr "Confirmant el teu pagament…"
|
||||
|
||||
@@ -345,7 +345,7 @@ msgstr "No"
|
||||
msgid "close"
|
||||
msgstr "Tanca"
|
||||
|
||||
#: pretix/static/pretixbase/js/addressform.js:98
|
||||
#: pretix/static/pretixbase/js/addressform.js:101
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:529
|
||||
msgid "required"
|
||||
msgstr "requerit"
|
||||
|
||||
+2179
-2060
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,9 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
|
||||
"PO-Revision-Date: 2025-09-08 18:57+0000\n"
|
||||
"Last-Translator: Alois Pospíšil <alois.pospisil@gmail.com>\n"
|
||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
||||
"PO-Revision-Date: 2026-01-08 04:00+0000\n"
|
||||
"Last-Translator: Jiří Pastrňák <jiri@pastrnak.email>\n"
|
||||
"Language-Team: Czech <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
"cs/>\n"
|
||||
"Language: cs\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n"
|
||||
"X-Generator: Weblate 5.13\n"
|
||||
"X-Generator: Weblate 5.15.1\n"
|
||||
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
|
||||
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
|
||||
@@ -141,7 +141,7 @@ msgstr "Pokračovat"
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:267
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:284
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:317
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:341
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:343
|
||||
msgid "Confirming your payment …"
|
||||
msgstr "Potvrzuji vaši platbu …"
|
||||
|
||||
@@ -345,7 +345,7 @@ msgstr "Ne"
|
||||
msgid "close"
|
||||
msgstr "zavřít"
|
||||
|
||||
#: pretix/static/pretixbase/js/addressform.js:98
|
||||
#: pretix/static/pretixbase/js/addressform.js:101
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:529
|
||||
msgid "required"
|
||||
msgstr "povinný"
|
||||
@@ -416,7 +416,7 @@ msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:276
|
||||
msgid "If this takes longer than a few minutes, please contact us."
|
||||
msgstr ""
|
||||
msgstr "Pokud akce trvá déle než několik minut, kontaktujte nás."
|
||||
|
||||
#: pretix/static/pretixbase/js/asynctask.js:331
|
||||
msgid "Close message"
|
||||
@@ -734,7 +734,7 @@ msgstr ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:49
|
||||
msgid "Cart expired"
|
||||
msgstr "Nákupní košík vypršel"
|
||||
msgstr "Rezervace košíku vypršela"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:58
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:84
|
||||
@@ -753,27 +753,23 @@ msgstr[2] ""
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:83
|
||||
msgid "Your cart has expired."
|
||||
msgstr "Nákupní košík vypršel."
|
||||
msgstr "Platnost rezervace vašeho košíku vypršela."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:86
|
||||
#, fuzzy
|
||||
#| msgid ""
|
||||
#| "The items in your cart are no longer reserved for you. You can still "
|
||||
#| "complete your order as long as they’re available."
|
||||
msgid ""
|
||||
"The items in your cart are no longer reserved for you. You can still "
|
||||
"complete your order as long as they're available."
|
||||
msgstr ""
|
||||
"Produkty v nákupním košíku již nejsou pro vás rezervovány. Pokud je lístek "
|
||||
"stále dostupný, můžete objednávku dokončit."
|
||||
"Produkty v nákupním košíku již nejsou rezervovány. Svou objednávku se přesto "
|
||||
"můžete pokusit dokončit, některé položky však už nemusí být dostupné."
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:87
|
||||
msgid "Do you want to renew the reservation period?"
|
||||
msgstr ""
|
||||
msgstr "Chcete obnovit rezervaci košíku?"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/cart.js:90
|
||||
msgid "Renew reservation"
|
||||
msgstr ""
|
||||
msgstr "Obnovit rezervaci"
|
||||
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:194
|
||||
msgid "The organizer keeps %(currency)s %(amount)s"
|
||||
|
||||
+1996
-1871
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
|
||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -141,7 +141,7 @@ msgstr ""
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:267
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:284
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:317
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:341
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:343
|
||||
msgid "Confirming your payment …"
|
||||
msgstr ""
|
||||
|
||||
@@ -345,7 +345,7 @@ msgstr ""
|
||||
msgid "close"
|
||||
msgstr ""
|
||||
|
||||
#: pretix/static/pretixbase/js/addressform.js:98
|
||||
#: pretix/static/pretixbase/js/addressform.js:101
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:529
|
||||
msgid "required"
|
||||
msgstr ""
|
||||
|
||||
+2045
-1875
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
|
||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
||||
"PO-Revision-Date: 2024-07-10 15:00+0000\n"
|
||||
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n"
|
||||
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -145,7 +145,7 @@ msgstr "Fortsæt"
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:267
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:284
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:317
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:341
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:343
|
||||
msgid "Confirming your payment …"
|
||||
msgstr "Bekræfter din betaling …"
|
||||
|
||||
@@ -357,7 +357,7 @@ msgstr "Nej"
|
||||
msgid "close"
|
||||
msgstr "Luk"
|
||||
|
||||
#: pretix/static/pretixbase/js/addressform.js:98
|
||||
#: pretix/static/pretixbase/js/addressform.js:101
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:529
|
||||
#, fuzzy
|
||||
#| msgid "Cart expired"
|
||||
|
||||
+2049
-1896
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-11-20 10:37+0000\n"
|
||||
"POT-Creation-Date: 2026-01-05 12:13+0000\n"
|
||||
"PO-Revision-Date: 2025-10-29 09:24+0000\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/"
|
||||
@@ -141,7 +141,7 @@ msgstr "Fortfahren"
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:267
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:284
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:317
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:341
|
||||
#: pretix/plugins/stripe/static/pretixplugins/stripe/pretix-stripe.js:343
|
||||
msgid "Confirming your payment …"
|
||||
msgstr "Zahlung wird bestätigt …"
|
||||
|
||||
@@ -345,7 +345,7 @@ msgstr "Nein"
|
||||
msgid "close"
|
||||
msgstr "schließen"
|
||||
|
||||
#: pretix/static/pretixbase/js/addressform.js:98
|
||||
#: pretix/static/pretixbase/js/addressform.js:101
|
||||
#: pretix/static/pretixpresale/js/ui/main.js:529
|
||||
msgid "required"
|
||||
msgstr "erforderlich"
|
||||
|
||||
@@ -167,6 +167,7 @@ Footer
|
||||
Footer-Link
|
||||
Footer-Text
|
||||
Galicisch
|
||||
GBP
|
||||
Geocoding
|
||||
Geocoding-Daten
|
||||
Geo-Koordinaten
|
||||
@@ -255,6 +256,7 @@ NFC-Chip
|
||||
NFC-Chips
|
||||
NFC-Medien
|
||||
NFC-Zahlungsmitteln
|
||||
NIF
|
||||
Nr
|
||||
NREI
|
||||
number
|
||||
@@ -263,6 +265,7 @@ Objekt-IDs
|
||||
Offline-Scan
|
||||
OK
|
||||
Online-Banking
|
||||
Onlinebanking
|
||||
Onlinebanking-Zugangsdaten
|
||||
Open
|
||||
OpenCage-API-Key
|
||||
@@ -292,6 +295,7 @@ Peppol-Netzwerk
|
||||
Peppol-Teilnehmer-ID
|
||||
Peppol-Teilnehmer-IDs
|
||||
Personalisierung
|
||||
P.IVA
|
||||
PKCE-Erweiterung
|
||||
Platzhalterzeichen
|
||||
Play
|
||||
@@ -330,6 +334,9 @@ pretix-Versionen
|
||||
pretix-Widget
|
||||
Produkt-ID
|
||||
Produkt-Metadaten
|
||||
PromptPay
|
||||
PromptPay-QR-Code
|
||||
PromptPay-Zahlung
|
||||
Przelewy
|
||||
pt
|
||||
px
|
||||
@@ -449,6 +456,7 @@ Ticketing
|
||||
Ticketing-Firma
|
||||
Ticket-Output
|
||||
Ticket-QR-Code
|
||||
TIN
|
||||
TODO
|
||||
To-Do-Liste
|
||||
TOTP
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user