Compare commits

..
378 changed files with 207003 additions and 283638 deletions
+2 -2
View File
@@ -26,10 +26,10 @@ jobs:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.13 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.13 python-version: 3.11
- uses: actions/cache@v4 - uses: actions/cache@v4
with: with:
path: ~/.cache/pip path: ~/.cache/pip
+3 -3
View File
@@ -23,13 +23,13 @@ jobs:
name: Tests name: Tests
strategy: strategy:
matrix: matrix:
python-version: ["3.10", "3.11", "3.13"] python-version: ["3.9", "3.10", "3.11"]
database: [sqlite, postgres] database: [sqlite, postgres]
exclude: exclude:
- database: sqlite - database: sqlite
python-version: "3.10" python-version: "3.9"
- database: sqlite - database: sqlite
python-version: "3.11" python-version: "3.10"
services: services:
postgres: postgres:
image: postgres:15 image: postgres:15
+95 -79
View File
@@ -6,14 +6,10 @@
{%- else %} {%- else %}
{%- set titlesuffix = "" %} {%- set titlesuffix = "" %}
{%- endif %} {%- 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> <!DOCTYPE html>
<html class="writer-html5" lang="{{ lang_attr }}"{% if sphinx_version_info >= (7, 2) %} data-content_root="{{ content_root }}"{% endif %}> <!--[if IE 8]><html class="no-js lt-ie9" lang="en" > <![endif]-->
<!--[if gt IE 8]><!--> <html class="no-js" lang="en" > <!--<![endif]-->
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
{{ metatags }} {{ metatags }}
@@ -22,50 +18,59 @@
<title>{{ title|striptags|e }}{{ titlesuffix }}</title> <title>{{ title|striptags|e }}{{ titlesuffix }}</title>
{% endblock %} {% endblock %}
{#- CSS #}
{%- for css_file in css_files %} {#- CSS #}
{%- if css_file|attr("filename") %} {%- for css in css_files %}
{{ css_tag(css_file) }} {%- 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 %} />
{%- else %} {%- else %}
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" /> <link rel="stylesheet" href="{{ pathto(css, 1) }}" type="text/css" />
{%- endif %} {%- endif %}
{%- endfor %} {%- endfor %}
{#- FAVICON #} {%- for cssfile in extra_css_files %}
{%- if favicon_url %} <link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" />
<link rel="shortcut icon" href="{{ favicon_url }}"/> {%- endfor -%}
{%- endif %}
{#- CANONICAL URL (deprecated) #} {#- FAVICON
{%- if theme_canonical_url and not pageurl %} 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 %}
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/> <link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
{%- endif -%} {%- endif -%}
{#- CANONICAL URL #} {#- CANONICAL URL #}
{%- if pageurl %} {%- if pageurl %}
<link rel="canonical" href="{{ pageurl|e }}" /> <link rel="canonical" href="{{ pageurl|e }}" />
{%- endif -%} {%- endif -%}
{#- JAVASCRIPTS #} {#- JAVASCRIPTS #}
{%- block scripts %} {%- block scripts %}
{%- if not embedded %} <!--[if lt IE 9]>
{%- for scriptfile in script_files %} <script src="{{ pathto('_static/js/html5shiv.min.js', 1) }}"></script>
{{ js_tag(scriptfile) }} <![endif]-->
{%- endfor %} {%- 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 %}
<script src="{{ pathto('_static/js/theme.js', 1) }}"></script> <script src="{{ pathto('_static/js/theme.js', 1) }}"></script>
{%- if READTHEDOCS or DEBUG %}
<script src="{{ pathto('_static/js/versions.js', 1) }}"></script>
{%- endif %}
{#- OPENSEARCH #} {#- OPENSEARCH #}
{%- if use_opensearch %} {%- if use_opensearch %}
<link rel="search" type="application/opensearchdescription+xml" <link rel="search" type="application/opensearchdescription+xml"
title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}" title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}"
href="{{ pathto('_static/opensearch.xml', 1) }}"/> href="{{ pathto('_static/opensearch.xml', 1) }}"/>
{%- endif %} {%- endif %}
{%- endif %} {%- endif %}
{%- endblock %} {%- endblock %}
{%- block linktags %} {%- block linktags %}
{%- if hasdoc('about') %} {%- if hasdoc('about') %}
@@ -118,23 +123,23 @@
{% endblock %} {% endblock %}
</div> </div>
{%- block navigation %} <div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
{#- Translators: This is an ARIA section label for the main navigation menu -#} {% block menu %}
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="{{ _('Navigation menu') }}"> {#
{%- block menu %} The singlehtml builder doesn't handle this toctree call when the
{%- set toctree = toctree(maxdepth=theme_navigation_depth|int, toctree is empty. Skip building this for now.
collapse=theme_collapse_navigation|tobool, #}
includehidden=theme_includehidden|tobool, {% if 'singlehtml' not in builder %}
titles_only=theme_titles_only|tobool) %} {% set global_toc = toctree(maxdepth=theme_navigation_depth|int, collapse=theme_collapse_navigation, includehidden=True) %}
{%- if toctree %} {% endif %}
{{ toctree }} {% if global_toc %}
{%- else %} {{ global_toc }}
{% else %}
<!-- Local TOC --> <!-- Local TOC -->
<div class="local-toc">{{ toc }}</div> <div class="local-toc">{{ toc }}</div>
{%- endif %} {% endif %}
{%- endblock %} {% endblock %}
</div> </div>
{%- endblock %}
{% if theme_display_version %} {% if theme_display_version %}
{%- set nav_version = version %} {%- set nav_version = version %}
@@ -153,42 +158,53 @@
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap"> <section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
{# MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #} {# MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #}
<nav class="wy-nav-top" aria-label="{{ _('Mobile navigation menu') }}" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}> <nav class="wy-nav-top" role="navigation" aria-label="top navigation">
{%- block mobile_nav %} {% block mobile_nav %}
<i data-toggle="wy-nav-top" class="fa fa-bars"></i> <i data-toggle="wy-nav-top" class="fa fa-bars"></i>
<a href="{{ pathto(master_doc) }}">{{ project }}</a> <a href="{{ pathto('index') }}">{{ project }}</a>
{%- endblock %} {% endblock %}
</nav> </nav>
<div class="wy-nav-content">
{%- block content %} {# PAGE CONTENT #}
{%- if theme_style_external_links|tobool %} <div class="wy-nav-content">
<div class="rst-content style-external-links"> <div class="rst-content">
{%- else %} {% include "breadcrumbs.html" %}
<div class="rst-content"> <div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
{%- endif %} <div itemprop="articleBody" class="section">
{% include "breadcrumbs.html" %} {% block body %}{% endblock %}
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article"> </div>
{%- block document %} <div class="articleComments">
<div itemprop="articleBody"> {% block comments %}{% endblock %}
{% block body %}{% endblock %} </div>
</div> </div>
{%- if self.comments()|trim %} {% include "footer.html" %}
<div class="articleComments">
{%- block comments %}{% endblock %}
</div>
{%- endif%}
</div>
{%- endblock %}
{% include "footer.html" %}
</div>
{%- endblock %}
</div> </div>
</div>
</section> </section>
</div> </div>
{% include "versions.html" %} {% 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 #} {# RTD hosts this file, so just load on non RTD builds #}
{% if not READTHEDOCS %} {% if not READTHEDOCS %}
<script type="text/javascript" src="{{ pathto('_static/js/theme.js', 1) }}"></script> <script type="text/javascript" src="{{ pathto('_static/js/theme.js', 1) }}"></script>
@@ -198,7 +214,7 @@
{% if theme_sticky_navigation %} {% if theme_sticky_navigation %}
<script type="text/javascript"> <script type="text/javascript">
jQuery(function () { jQuery(function () {
SphinxRtdTheme.Navigation.enable({{ 'true' if theme_sticky_navigation|tobool else 'false' }}); SphinxRtdTheme.StickyNav.enable();
}); });
</script> </script>
{% endif %} {% endif %}
+161 -179
View File
@@ -1,86 +1,136 @@
{# TEMPLATE VAR SETTINGS #} {#
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 ' &raquo;' or reldelim1 %}
{%- set reldelim2 = reldelim2 is not defined and ' |' or reldelim2 %}
{%- set render_sidebar = (not embedded) and (not theme_nosidebar|tobool) and
(sidebars != []) %}
{%- set url_root = pathto('', 1) %} {%- set url_root = pathto('', 1) %}
{# XXX necessary? #}
{%- if url_root == '#' %}{% set url_root = '' %}{% endif %} {%- if url_root == '#' %}{% set url_root = '' %}{% endif %}
{%- if not embedded and docstitle %} {%- if not embedded and docstitle %}
{%- set titlesuffix = " &mdash; "|safe + docstitle|e %} {%- set titlesuffix = " &mdash; "|safe + docstitle|e %}
{%- else %} {%- else %}
{%- set titlesuffix = "" %} {%- set titlesuffix = "" %}
{%- endif %} {%- endif %}
{%- set lang_attr = 'en' if language == None else (language | replace('_', '-')) %}
{# Build sphinx_version_info tuple from sphinx_version string in pure Jinja #} {%- macro relbar() %}
{%- set (_ver_major, _ver_minor) = (sphinx_version.split('.') | list)[:2] | map('int') -%} <div class="related">
{%- set sphinx_version_info = (_ver_major, _ver_minor, -1) -%} <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 %}
<!DOCTYPE html> {%- macro sidebar() %}
<html class="writer-html5" lang="{{ lang_attr }}"{% if sphinx_version_info >= (7, 2) %} data-content_root="{{ content_root }}"{% endif %}> {%- if render_sidebar %}
<head> <div class="sphinxsidebar">
<meta charset="utf-8" /> <div class="sphinxsidebarwrapper">
{%- if READTHEDOCS and not embedded %} {%- block sidebarlogo %}
<meta name="readthedocs-addons-api-version" content="1"> {%- if logo %}
{%- endif %} <p class="logo"><a href="{{ pathto(master_doc) }}">
{{- metatags }} <img class="logo" src="{{ pathto('_static/' + logo, 1) }}" alt="Logo"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> </a></p>
{%- block htmltitle %} {%- endif %}
<title>{{ title|striptags|e }}{{ titlesuffix }}</title> {%- endblock %}
{%- 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 %}
{#- CSS #} {%- macro script() %}
{%- for css_file in css_files %} <script type="text/javascript">
{%- if css_file|attr("filename") %} var DOCUMENTATION_OPTIONS = {
{{ css_tag(css_file) }} URL_ROOT: '{{ url_root }}',
{%- else %} VERSION: '{{ release|e }}',
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" /> COLLAPSE_INDEX: false,
{%- endif %} FILE_SUFFIX: '{{ '' if no_search_suffix else file_suffix }}',
{%- endfor %} HAS_SOURCE: {{ has_source|lower }},
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
{# };
"extra_css_files" is an undocumented Read the Docs theme specific option. </script>
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 %} {%- for scriptfile in script_files %}
{{ js_tag(scriptfile) }} <script type="text/javascript" src="{{ pathto(scriptfile, 1) }}"></script>
{%- endfor %} {%- endfor %}
<script src="{{ pathto('_static/js/theme.js', 1) }}"></script> {%- endmacro %}
{%- if READTHEDOCS or DEBUG %} {%- macro css() %}
<script src="{{ pathto('_static/js/versions.js', 1) }}"></script> <link rel="stylesheet" href="{{ pathto('_static/' + style, 1) }}" type="text/css" />
{%- endif %} <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 %}
{#- OPENSEARCH #} <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() }}
{%- if use_opensearch %} {%- if use_opensearch %}
<link rel="search" type="application/opensearchdescription+xml" <link rel="search" type="application/opensearchdescription+xml"
title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}" title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}"
href="{{ pathto('_static/opensearch.xml', 1) }}"/> href="{{ pathto('_static/opensearch.xml', 1) }}"/>
{%- endif %} {%- endif %}
{%- endif %} {%- if favicon %}
{%- endblock %} <link rel="shortcut icon" href="{{ pathto('_static/' + favicon, 1) }}"/>
{%- endif %}
{%- block linktags %} {%- if theme_canonical_url %}
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
{%- endif %}
{%- endif %}
{%- block linktags %}
{%- if hasdoc('about') %} {%- if hasdoc('about') %}
<link rel="author" title="{{ _('About these documents') }}" href="{{ pathto('about') }}" /> <link rel="author" title="{{ _('About these documents') }}" href="{{ pathto('about') }}" />
{%- endif %} {%- endif %}
@@ -93,135 +143,67 @@
{%- if hasdoc('copyright') %} {%- if hasdoc('copyright') %}
<link rel="copyright" title="{{ _('Copyright') }}" href="{{ pathto('copyright') }}" /> <link rel="copyright" title="{{ _('Copyright') }}" href="{{ pathto('copyright') }}" />
{%- endif %} {%- 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 %} {%- if next %}
<link rel="next" title="{{ next.title|striptags|e }}" href="{{ next.link|e }}" /> <link rel="next" title="{{ next.title|striptags|e }}" href="{{ next.link|e }}" />
{%- endif %} {%- endif %}
{%- if prev %} {%- if prev %}
<link rel="prev" title="{{ prev.title|striptags|e }}" href="{{ prev.link|e }}" /> <link rel="prev" title="{{ prev.title|striptags|e }}" href="{{ prev.link|e }}" />
{%- endif %} {%- endif %}
{%- endblock %} {%- endblock %}
{%- block extrahead %} {% endblock %} {%- block extrahead %} {% endblock %}
</head> </head>
<body>
{%- block header %}{% endblock %}
<body class="wy-body-for-nav"> {%- block relbar1 %}{{ relbar() }}{% endblock %}
{%- block extrabody %} {% endblock %} {%- block content %}
<div class="wy-grid-for-nav"> {%- block sidebar1 %} {# possible location for sidebar #} {% endblock %}
{#- 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 #} <div class="document">
{# the master_doc variable was renamed to root_doc in Sphinx 4 (master_doc still exists in later Sphinx versions) #} {%- block document %}
{%- set _logo_url = logo_url|default(pathto('_static/' + (logo or ""), 1)) %} <div class="documentwrapper">
{%- set _root_doc = root_doc|default(master_doc) %} {%- if render_sidebar %}
<a href="{{ pathto(_root_doc) }}"{% if not theme_logo_only %} class="icon icon-home"{% endif %}> <div class="bodywrapper">
{% if not theme_logo_only %}{{ project }}{% endif %} {%- endif %}
{%- if logo or logo_url %} <div class="body">
<img src="{{ _logo_url }}" class="logo" alt="{{ _('Logo') }}"/> {% block body %} {% endblock %}
{%- 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> </div>
{%- endblock %} {%- if render_sidebar %}
{% include "footer.html" %}
</div> </div>
{%- endblock %} {%- endif %}
</div> </div>
</section> {%- endblock %}
</div>
{% include "versions.html" -%}
<script> {%- block sidebar2 %}{{ sidebar() }}{% endblock %}
jQuery(function () { <div class="clearer"></div>
SphinxRtdTheme.Navigation.enable({{ 'true' if theme_sticky_navigation|tobool else 'false' }}); </div>
}); {%- endblock %}
</script>
{#- Do not conflict with RTD insertion of analytics script #} {%- block relbar2 %}{{ relbar() }}{% endblock %}
{%- 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 %}&copy; <a href="{{ path }}">Copyright</a> {{ copyright }}.{% endtrans %}
{%- else %}
{% trans copyright=copyright|e %}&copy; Copyright {{ copyright }}.{% endtrans %}
{%- endif %}
{%- endif %} {%- 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>
{%- block footer %} {% endblock %}
</body>
</html>
+15 -1
View File
@@ -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" "rsa_pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh…nswIDAQAB\n-----END PUBLIC KEY-----\n"
} }
The ``rsa_pubkey`` is optional any only required for certain features such as working with reusable The ``rsa_pubkey`` is optional any only required for certain fatures such as working with reusable
media and NFC cryptography. media and NFC cryptography.
Every initialization token can only be used once. On success, you will receive a response containing Every initialization token can only be used once. On success, you will receive a response containing
@@ -208,6 +208,20 @@ Additionally, when creating a device through the user interface or API, a user c
the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security
policies for official pretix apps like pretixSCAN and pretixPOS. policies for official pretix apps like pretixSCAN and pretixPOS.
Removing a device
-----------------
If you want implement a way to to deprovision a device in your software, you can call the ``revoke`` endpoint to
invalidate your API key. There is no way to reverse this operation.
.. sourcecode:: http
POST /api/v1/device/revoke HTTP/1.1
Host: pretix.eu
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
This can also be done by the user through the web interface.
Event selection Event selection
--------------- ---------------
+1 -1
View File
@@ -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. 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 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 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 any object has changed in the meantime, you will receive back a full list ``If-Modified-Since`` header. If the 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 (if something it missing, this means the object has been deleted). If nothing happened, we'll send back a
``304 Not Modified`` return code. ``304 Not Modified`` return code.
+22 -22
View File
@@ -46,28 +46,28 @@ Endpoints
Vary: Accept Vary: Accept
Content-Type: application/json Content-Type: application/json
{ {
"count": 3, "count": 3,
"next": null, "next": null,
"previous": null, "previous": null,
"results": [ "results": [
{ {
"id": 2, "id": 2,
"start": "2025-08-14T22:00:00Z", "start": "2025-08-14T22:00:00Z",
"end": "2025-08-15T00:00:00Z" "end": "2025-08-15T00:00:00Z"
}, },
{ {
"id": 3, "id": 3,
"start": "2025-08-12T22:00:00Z", "start": "2025-08-12T22:00:00Z",
"end": "2025-08-13T22:00:00Z" "end": "2025-08-13T22:00:00Z"
}, },
{ {
"id": 14, "id": 14,
"start": "2025-08-15T22:00:00Z", "start": "2025-08-15T22:00:00Z",
"end": "2025-08-17T22:00:00Z" "end": "2025-08-17T22:00:00Z"
} }
] ]
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
-50
View File
@@ -1719,56 +1719,6 @@ List of all order positions
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/orderpositions/
Returns a list of all order positions within all events of a given organizer (with sufficient access permissions).
The supported query parameters and output format of this endpoint are almost identical to those of the list endpoint
within an event.
The only changes are that responses also contain the ``event`` attribute in each result and that the 'pdf_data'
parameter is not supported.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/orderpositions/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
X-Page-Generated: 2017-12-01T10:00:00Z
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id:": 23442
"event": "sampleconf",
"order": "ABC12",
"positionid": 1,
"canceled": false,
"item": 1345,
...
}
]
}
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
Fetching individual positions Fetching individual positions
----------------------------- -----------------------------
-3
View File
@@ -60,9 +60,6 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.added`` * ``pretix.event.added``
* ``pretix.event.changed`` * ``pretix.event.changed``
* ``pretix.event.deleted`` * ``pretix.event.deleted``
* ``pretix.giftcards.created``
* ``pretix.giftcards.modified``
* ``pretix.giftcards.transaction.*``
* ``pretix.voucher.added`` * ``pretix.voucher.added``
* ``pretix.voucher.changed`` * ``pretix.voucher.changed``
* ``pretix.voucher.deleted`` * ``pretix.voucher.deleted``
+1 -1
View File
@@ -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) 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 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 500.00). This becomes a problem when juristictions, data formats, or external systems expect this calculation (instead of 499.98). 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 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. does not allow the computation as created by pretix.
+8 -7
View File
@@ -1,8 +1,9 @@
sphinx==9.1.* sphinx==7.4.*
sphinx-rtd-theme~=3.1.0 jinja2==3.1.*
sphinxcontrib-httpdomain~=2.0.0 sphinx-rtd-theme
sphinxcontrib-images~=1.0.1 sphinxcontrib-httpdomain
sphinxcontrib-jquery~=4.1 sphinxcontrib-images
sphinxcontrib-spelling~=8.0.2 sphinxcontrib-jquery
sphinxemoji~=0.3.2 sphinxcontrib-spelling==8.*
sphinxemoji
pyenchant==3.3.* pyenchant==3.3.*
+8 -7
View File
@@ -1,9 +1,10 @@
-e ../ -e ../
sphinx==9.1.* sphinx==7.4.*
sphinx-rtd-theme~=3.1.0 jinja2==3.1.*
sphinxcontrib-httpdomain~=2.0.0 sphinx-rtd-theme
sphinxcontrib-images~=1.0.1 sphinxcontrib-httpdomain
sphinxcontrib-jquery~=4.1 sphinxcontrib-images
sphinxcontrib-spelling~=8.0.2 sphinxcontrib-jquery
sphinxemoji~=0.3.2 sphinxcontrib-spelling==8.*
sphinxemoji
pyenchant==3.3.* pyenchant==3.3.*
+24 -23
View File
@@ -3,7 +3,7 @@ name = "pretix"
dynamic = ["version"] dynamic = ["version"]
description = "Reinventing presales, one ticket at a time" description = "Reinventing presales, one ticket at a time"
readme = "README.rst" readme = "README.rst"
requires-python = ">=3.10" requires-python = ">=3.9"
license = {file = "LICENSE"} license = {file = "LICENSE"}
keywords = ["tickets", "web", "shop", "ecommerce"] keywords = ["tickets", "web", "shop", "ecommerce"]
authors = [ authors = [
@@ -29,19 +29,19 @@ dependencies = [
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab "arabic-reshaper==3.0.0", # Support for Arabic in reportlab
"babel", "babel",
"BeautifulSoup4==4.14.*", "BeautifulSoup4==4.14.*",
"bleach==6.3.*", "bleach==6.2.*",
"celery==5.6.*", "celery==5.5.*",
"chardet==5.2.*", "chardet==5.2.*",
"cryptography>=44.0.0", "cryptography>=44.0.0",
"css-inline==0.20.*", "css-inline==0.18.*",
"defusedcsv>=1.1.0", "defusedcsv>=1.1.0",
"dnspython==2.*", "dnspython==2.*",
"Django[argon2]==4.2.*,>=4.2.26", "Django[argon2]==4.2.*,>=4.2.26",
"django-bootstrap3==26.1", "django-bootstrap3==25.2",
"django-compressor==4.6.0", "django-compressor==4.5.1",
"django-countries==8.2.*", "django-countries==7.6.*",
"django-filter==25.1", "django-filter==25.1",
"django-formset-js-improved==0.5.0.5", "django-formset-js-improved==0.5.0.4",
"django-formtools==2.5.1", "django-formtools==2.5.1",
"django-hierarkey==2.0.*,>=2.0.1", "django-hierarkey==2.0.*,>=2.0.1",
"django-hijack==3.7.*", "django-hijack==3.7.*",
@@ -50,22 +50,22 @@ dependencies = [
"django-localflavor==5.0", "django-localflavor==5.0",
"django-markup", "django-markup",
"django-oauth-toolkit==2.3.*", "django-oauth-toolkit==2.3.*",
"django-otp==1.7.*", "django-otp==1.6.*",
"django-phonenumber-field==8.4.*", "django-phonenumber-field==7.3.*",
"django-redis==6.0.*", "django-redis==6.0.*",
"django-scopes==2.0.*", "django-scopes==2.0.*",
"django-statici18n==2.6.*", "django-statici18n==2.6.*",
"djangorestframework==3.16.*", "djangorestframework==3.16.*",
"dnspython==2.8.*", "dnspython==2.7.*",
"drf_ujson2==1.7.*", "drf_ujson2==1.7.*",
"geoip2==5.*", "geoip2==5.*",
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+ "importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
"isoweek", "isoweek",
"jsonschema", "jsonschema",
"kombu==5.6.*", "kombu==5.5.*",
"libsass==0.23.*", "libsass==0.23.*",
"lxml", "lxml",
"markdown==3.10.2", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3. "markdown==3.9", # 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 # We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*", "mt-940==4.30.*",
"oauthlib==3.3.*", "oauthlib==3.3.*",
@@ -73,32 +73,33 @@ dependencies = [
"packaging", "packaging",
"paypalrestsdk==1.13.*", "paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*", "paypal-checkout-serversdk==1.0.*",
"PyJWT==2.11.*", "PyJWT==2.10.*",
"phonenumberslite==9.0.*", "phonenumberslite==9.0.*",
"Pillow==12.1.*", "Pillow==11.3.*",
"pretix-plugin-build", "pretix-plugin-build",
"protobuf==7.34.*", "protobuf==6.33.*",
"psycopg2-binary", "psycopg2-binary",
"pycountry", "pycountry",
"pycparser==3.0", "pycparser==2.23",
"pycryptodome==3.23.*", "pycryptodome==3.23.*",
"pypdf==6.5.*", "pypdf==6.4.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab "python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*", "python-dateutil==2.9.*",
"pytz", "pytz",
"pytz-deprecation-shim==0.1.*", "pytz-deprecation-shim==0.1.*",
"pyuca", "pyuca",
"qrcode==8.2", "qrcode==8.2",
"redis==7.1.*", "redis==6.4.*",
"reportlab==4.4.*", "reportlab==4.4.*",
"requests==2.32.*", "requests==2.32.*",
"sentry-sdk==2.54.*", "sentry-sdk==2.46.*",
"sepaxml==2.7.*", "sepaxml==2.7.*",
"stripe==7.9.*", "stripe==7.9.*",
"text-unidecode==1.*", "text-unidecode==1.*",
"tlds>=2020041600", "tlds>=2020041600",
"tqdm==4.*", "tqdm==4.*",
"ua-parser==1.0.*", "ua-parser==1.0.*",
"vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*", "vobject==0.9.*",
"webauthn==2.7.*", "webauthn==2.7.*",
"zeep==4.3.*" "zeep==4.3.*"
@@ -110,10 +111,10 @@ dev = [
"aiohttp==3.13.*", "aiohttp==3.13.*",
"coverage", "coverage",
"coveralls", "coveralls",
"fakeredis==2.34.*", "fakeredis==2.32.*",
"flake8==7.3.*", "flake8==7.3.*",
"freezegun", "freezegun",
"isort==8.0.*", "isort==6.1.*",
"pep8-naming==0.15.*", "pep8-naming==0.15.*",
"potypo", "potypo",
"pytest-asyncio>=0.24", "pytest-asyncio>=0.24",
@@ -123,7 +124,7 @@ dev = [
"pytest-mock==3.15.*", "pytest-mock==3.15.*",
"pytest-sugar", "pytest-sugar",
"pytest-xdist==3.8.*", "pytest-xdist==3.8.*",
"pytest==9.0.*", "pytest==8.4.*",
"responses", "responses",
] ]
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2026.3.0.dev0" __version__ = "2025.11.0.dev0"
-3
View File
@@ -795,7 +795,6 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_address_asked', 'invoice_address_asked',
'invoice_address_required', 'invoice_address_required',
'invoice_address_vatid', 'invoice_address_vatid',
'invoice_address_vatid_required_countries',
'invoice_address_company_required', 'invoice_address_company_required',
'invoice_address_beneficiary', 'invoice_address_beneficiary',
'invoice_address_custom_field', 'invoice_address_custom_field',
@@ -806,7 +805,6 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_reissue_after_modify', 'invoice_reissue_after_modify',
'invoice_include_free', 'invoice_include_free',
'invoice_generate', 'invoice_generate',
'invoice_generate_only_business',
'invoice_period', 'invoice_period',
'invoice_numbers_consecutive', 'invoice_numbers_consecutive',
'invoice_numbers_prefix', 'invoice_numbers_prefix',
@@ -945,7 +943,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'invoice_address_asked', 'invoice_address_asked',
'invoice_address_required', 'invoice_address_required',
'invoice_address_vatid', 'invoice_address_vatid',
'invoice_address_vatid_required_countries',
'invoice_address_company_required', 'invoice_address_company_required',
'invoice_address_beneficiary', 'invoice_address_beneficiary',
'invoice_address_custom_field', 'invoice_address_custom_field',
+2 -34
View File
@@ -19,7 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import json
import logging import logging
import os import os
from collections import Counter, defaultdict from collections import Counter, defaultdict
@@ -192,7 +191,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
{"transmission_info": {r: "This field is required for the selected type of invoice transmission."}} {"transmission_info": {r: "This field is required for the selected type of invoice transmission."}}
) )
break # do not call else branch of for loop break # do not call else branch of for loop
elif t.is_exclusive(self.context["request"].event, data.get("country"), data.get("is_business")): elif t.exclusive:
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")): if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
raise ValidationError({ raise ValidationError({
"transmission_type": "The transmission type '%s' must be used for this country or address type." % ( "transmission_type": "The transmission type '%s' must be used for this country or address type." % (
@@ -637,14 +636,6 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
return entry return entry
class OrganizerOrderPositionSerializer(OrderPositionSerializer):
event = SlugRelatedField(slug_field='slug', read_only=True)
class Meta(OrderPositionSerializer.Meta):
fields = OrderPositionSerializer.Meta.fields + ('event',)
read_only_fields = OrderPositionSerializer.Meta.read_only_fields + ('event',)
class RequireAttentionField(serializers.Field): class RequireAttentionField(serializers.Field):
def to_representation(self, instance: OrderPosition): def to_representation(self, instance: OrderPosition):
return instance.require_checkin_attention return instance.require_checkin_attention
@@ -713,16 +704,6 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
if 'answers.question' in self.context['expand']: if 'answers.question' in self.context['expand']:
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True) self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
if 'addons' in self.context['expand']:
# Experimental feature, undocumented on purpose for now in case we need to remove it again
# for performance reasons
subl = CheckinListOrderPositionSerializer(read_only=True, many=True, context={
**self.context,
'expand': [v for v in self.context['expand'] if v != 'addons'],
'pdf_data': False,
})
self.fields['addons'] = subl
class OrderPaymentTypeField(serializers.Field): class OrderPaymentTypeField(serializers.Field):
# TODO: Remove after pretix 2.2 # TODO: Remove after pretix 2.2
@@ -1224,18 +1205,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
raise ValidationError('The given payment provider is not known.') raise ValidationError('The given payment provider is not known.')
return pp return pp
def validate_payment_info(self, info):
if info:
try:
obj = json.loads(info)
except ValueError:
raise ValidationError('payment_info must be valid JSON.')
if not isinstance(obj, dict):
# only objects are allowed
raise ValidationError('payment_info must be a JSON object.')
return info
def validate_expires(self, expires): def validate_expires(self, expires):
if expires < now(): if expires < now():
raise ValidationError('Expiration date must be in the future.') raise ValidationError('Expiration date must be in the future.')
@@ -1632,7 +1601,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.sales_channel, order.sales_channel,
[ [
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price, (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
cp.addon_to, cp.is_bundled, pos._voucher_discount) bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
for cp in order_positions for cp in order_positions
] ]
) )
@@ -1764,7 +1733,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
rounding_mode = self.context["event"].settings.tax_rounding rounding_mode = self.context["event"].settings.tax_rounding
changed = apply_rounding( changed = apply_rounding(
rounding_mode, rounding_mode,
ia,
self.context["event"].currency, self.context["event"].currency,
[*pos_map.values(), *fees] [*pos_map.values(), *fees]
) )
+8 -8
View File
@@ -33,7 +33,7 @@ from pretix.api.serializers.order import (
OrderFeeCreateSerializer, OrderPositionCreateSerializer, OrderFeeCreateSerializer, OrderPositionCreateSerializer,
) )
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
from pretix.base.services.orders import OrderChangeManager, OrderError from pretix.base.services.orders import OrderError
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -82,11 +82,11 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
return data return data
def create(self, validated_data): def create(self, validated_data):
ocm: OrderChangeManager = self.context['ocm'] ocm = self.context['ocm']
check_quotas = self.context.get('check_quotas', True) check_quotas = self.context.get('check_quotas', True)
try: try:
new_position = ocm.add_position( ocm.add_position(
item=validated_data['item'], item=validated_data['item'],
variation=validated_data.get('variation'), variation=validated_data.get('variation'),
price=validated_data.get('price'), price=validated_data.get('price'),
@@ -98,7 +98,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
) )
if self.context.get('commit', True): if self.context.get('commit', True):
ocm.commit(check_quotas=check_quotas) ocm.commit(check_quotas=check_quotas)
return new_position.position return validated_data['order'].positions.order_by('-positionid').first()
else: else:
return OrderPosition() # fake to appease DRF return OrderPosition() # fake to appease DRF
except OrderError as e: except OrderError as e:
@@ -131,7 +131,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
return data return data
def create(self, validated_data): def create(self, validated_data):
ocm: OrderChangeManager = self.context['ocm'] ocm = self.context['ocm']
try: try:
f = OrderFee( f = OrderFee(
@@ -146,7 +146,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
ocm.add_fee(f) ocm.add_fee(f)
if self.context.get('commit', True): if self.context.get('commit', True):
ocm.commit() ocm.commit()
return f return validated_data['order'].fees.order_by('-pk').first()
else: else:
return OrderFee() # fake to appease DRF return OrderFee() # fake to appease DRF
except OrderError as e: except OrderError as e:
@@ -310,7 +310,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
return data return data
def update(self, instance, validated_data): def update(self, instance, validated_data):
ocm: OrderChangeManager = self.context['ocm'] ocm = self.context['ocm']
check_quotas = self.context.get('check_quotas', True) check_quotas = self.context.get('check_quotas', True)
current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None
item = validated_data.get('item', instance.item) item = validated_data.get('item', instance.item)
@@ -399,7 +399,7 @@ class OrderFeeChangeSerializer(serializers.ModelSerializer):
) )
def update(self, instance, validated_data): def update(self, instance, validated_data):
ocm: OrderChangeManager = self.context['ocm'] ocm = self.context['ocm']
value = validated_data.get('value', instance.value) value = validated_data.get('value', instance.value)
try: try:
+19 -18
View File
@@ -49,7 +49,7 @@ from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_ORGANIZER,
) )
from pretix.base.services.mail import mail from pretix.base.services.mail import SendMailException, mail
from pretix.base.settings import validate_organizer_settings from pretix.base.settings import validate_organizer_settings
from pretix.helpers.urls import build_absolute_uri as build_global_uri from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
@@ -363,22 +363,24 @@ class TeamInviteSerializer(serializers.ModelSerializer):
) )
def _send_invite(self, instance): def _send_invite(self, instance):
mail( try:
instance.email, mail(
_('Account invitation'), instance.email,
'pretixcontrol/email/invitation.txt', _('pretix account invitation'),
{ 'pretixcontrol/email/invitation.txt',
'instance': settings.PRETIX_INSTANCE_NAME, {
'user': self, 'user': self,
'organizer': self.context['organizer'].name, 'organizer': self.context['organizer'].name,
'team': instance.team.name, 'team': instance.team.name,
'url': build_global_uri('control:auth.invite', kwargs={ 'url': build_global_uri('control:auth.invite', kwargs={
'token': instance.token 'token': instance.token
}) })
}, },
event=None, event=None,
locale=get_language_without_region() # TODO: expose? locale=get_language_without_region() # TODO: expose?
) )
except SendMailException:
pass # Already logged
def create(self, validated_data): def create(self, validated_data):
if 'email' in validated_data: if 'email' in validated_data:
@@ -441,7 +443,6 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'customer_accounts', 'customer_accounts',
'customer_accounts_native', 'customer_accounts_native',
'customer_accounts_link_by_email', 'customer_accounts_link_by_email',
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed', 'invoice_regenerate_allowed',
'contact_mail', 'contact_mail',
'imprint_url', 'imprint_url',
+1 -2
View File
@@ -67,7 +67,6 @@ orga_router.register(r'invoices', order.InvoiceViewSet)
orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportViewSet) orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportViewSet)
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters') orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
orga_router.register(r'transactions', order.OrganizerTransactionViewSet) orga_router.register(r'transactions', order.OrganizerTransactionViewSet)
orga_router.register(r'orderpositions', order.OrganizerOrderPositionViewSet, basename='orderpositions')
team_router = routers.DefaultRouter() team_router = routers.DefaultRouter()
team_router.register(r'members', organizer.TeamMemberViewSet) team_router.register(r'members', organizer.TeamMemberViewSet)
@@ -84,7 +83,7 @@ event_router.register(r'discounts', discount.DiscountViewSet)
event_router.register(r'quotas', item.QuotaViewSet) event_router.register(r'quotas', item.QuotaViewSet)
event_router.register(r'vouchers', voucher.VoucherViewSet) event_router.register(r'vouchers', voucher.VoucherViewSet)
event_router.register(r'orders', order.EventOrderViewSet) event_router.register(r'orders', order.EventOrderViewSet)
event_router.register(r'orderpositions', order.EventOrderPositionViewSet) event_router.register(r'orderpositions', order.OrderPositionViewSet)
event_router.register(r'transactions', order.TransactionViewSet) event_router.register(r'transactions', order.TransactionViewSet)
event_router.register(r'invoices', order.InvoiceViewSet) event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets') event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
+25 -43
View File
@@ -188,15 +188,11 @@ class CheckinListViewSet(viewsets.ModelViewSet):
clist = self.get_object() clist = self.get_object()
if serializer.validated_data.get('nonce'): if serializer.validated_data.get('nonce'):
if kwargs.get('position'): if kwargs.get('position'):
prev = kwargs['position'].all_checkins.filter( prev = kwargs['position'].all_checkins.filter(nonce=serializer.validated_data['nonce']).first()
nonce=serializer.validated_data['nonce'],
successful=False
).first()
else: else:
prev = clist.checkins.filter( prev = clist.checkins.filter(
nonce=serializer.validated_data['nonce'], nonce=serializer.validated_data['nonce'],
raw_barcode=serializer.validated_data['raw_barcode'], raw_barcode=serializer.validated_data['raw_barcode'],
successful=False
).first() ).first()
if prev: if prev:
# Ignore because nonce is already handled # Ignore because nonce is already handled
@@ -385,21 +381,15 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
qs = qs.filter(reduce(operator.or_, lists_qs)) qs = qs.filter(reduce(operator.or_, lists_qs))
prefetch_related = [
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question',
]
select_related = [
'item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat'
]
if pdf_data: if pdf_data:
qs = qs.prefetch_related( qs = qs.prefetch_related(
# Don't add to list, we don't want to propagate to addons Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related( Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
Prefetch( Prefetch(
'event', 'event',
@@ -414,39 +404,32 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
) )
) )
)) ))
).select_related(
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
) )
else:
qs = qs.prefetch_related(
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
if expand and 'subevent' in expand: if expand and 'subevent' in expand:
prefetch_related += [ qs = qs.prefetch_related(
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set', 'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
'subevent__seat_category_mappings', 'subevent__meta_values' 'subevent__seat_category_mappings', 'subevent__meta_values'
] )
if expand and 'item' in expand: if expand and 'item' in expand:
prefetch_related += [ qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values',
'item', 'item__addons', 'item__bundles', 'item__meta_values', 'item__variations').select_related('item__tax_rule')
'item__variations',
]
select_related.append('item__tax_rule')
if expand and 'variation' in expand: if expand and 'variation' in expand:
prefetch_related += [ qs = qs.prefetch_related('variation', 'variation__meta_values')
'variation', 'variation__meta_values',
]
if expand and 'addons' in expand:
prefetch_related += [
Prefetch('addons', OrderPosition.objects.prefetch_related(*prefetch_related).select_related(*select_related)),
]
else:
prefetch_related += [
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
]
if pdf_data:
select_related.remove("order") # Don't need it twice on this queryset
qs = qs.prefetch_related(*prefetch_related).select_related(*select_related)
return qs return qs
@@ -983,7 +966,6 @@ class CheckinRPCSearchView(ListAPIView):
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['expand'] = self.request.query_params.getlist('expand') ctx['expand'] = self.request.query_params.getlist('expand')
ctx['organizer'] = self.request.organizer
ctx['pdf_data'] = False ctx['pdf_data'] = False
return ctx return ctx
+1 -7
View File
@@ -74,11 +74,6 @@ class ExportersMixin:
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)') @action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
def download(self, *args, **kwargs): def download(self, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs['cfid']) 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: if cf.file:
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type) resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore") resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
@@ -114,8 +109,7 @@ class ExportersMixin:
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs()) serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
cf = CachedFile(web_download=True) cf = CachedFile(web_download=False)
cf.bind_to_session(self.request, "exporters-api")
cf.date = now() cf.date = now()
cf.expires = now() + timedelta(hours=24) cf.expires = now() + timedelta(hours=24)
cf.save() cf.save()
+2 -2
View File
@@ -106,7 +106,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property', 'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
'variations__meta_values', 'variations__meta_values__property', 'variations__meta_values', 'variations__meta_values__property',
'require_membership_types', 'variations__require_membership_types', 'require_membership_types', 'variations__require_membership_types',
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times' 'limit_sales_channels', 'variations__limit_sales_channels',
).all() ).all()
def perform_create(self, serializer): def perform_create(self, serializer):
@@ -567,7 +567,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
write_permission = 'can_change_items' write_permission = 'can_change_items'
def get_queryset(self): def get_queryset(self):
return self.request.event.quotas.select_related('subevent').prefetch_related('items', 'variations').all() return self.request.event.quotas.all()
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset()).distinct() queryset = self.filter_queryset(self.get_queryset()).distinct()
+24 -51
View File
@@ -57,10 +57,9 @@ from pretix.api.serializers.order import (
BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer, BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer,
OrderPaymentCreateSerializer, OrderPaymentSerializer, OrderPaymentCreateSerializer, OrderPaymentSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer, OrderPositionSerializer, OrderRefundCreateSerializer,
OrderRefundSerializer, OrderSerializer, OrganizerOrderPositionSerializer, OrderRefundSerializer, OrderSerializer, OrganizerTransactionSerializer,
OrganizerTransactionSerializer, PriceCalcSerializer, PrintLogSerializer, PriceCalcSerializer, PrintLogSerializer, RevokedTicketSecretSerializer,
RevokedTicketSecretSerializer, SimulatedOrderSerializer, SimulatedOrderSerializer, TransactionSerializer,
TransactionSerializer,
) )
from pretix.api.serializers.orderchange import ( from pretix.api.serializers.orderchange import (
BlockNameSerializer, OrderChangeOperationSerializer, BlockNameSerializer, OrderChangeOperationSerializer,
@@ -91,6 +90,7 @@ from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
regenerate_invoice, transmit_invoice, regenerate_invoice, transmit_invoice,
) )
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import ( from pretix.base.services.orders import (
OrderChangeManager, OrderError, _order_placed_email, OrderChangeManager, OrderError, _order_placed_email,
_order_placed_email_attendee, approve_order, cancel_order, deny_order, _order_placed_email_attendee, approve_order, cancel_order, deny_order,
@@ -439,6 +439,8 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e: except PaymentException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except SendMailException:
pass
return self.retrieve(request, [], **kwargs) return self.retrieve(request, [], **kwargs)
return Response( return Response(
@@ -632,7 +634,10 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
order = self.get_object() order = self.get_object()
if not order.email: if not order.email:
return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST)
order.resend_link(user=self.request.user, auth=self.request.auth) try:
order.resend_link(user=self.request.user, auth=self.request.auth)
except SendMailException:
return Response({'detail': _('There was an error sending the mail. Please try again later.')}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
return Response( return Response(
status=status.HTTP_204_NO_CONTENT status=status.HTTP_204_NO_CONTENT
@@ -1066,7 +1071,8 @@ with scopes_disabled():
} }
class OrderPositionViewSetMixin: class OrderPositionViewSet(viewsets.ModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.all.none() queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter) filter_backends = (DjangoFilterBackend, RichOrderingFilter)
ordering = ('order__datetime', 'positionid') ordering = ('order__datetime', 'positionid')
@@ -1087,7 +1093,8 @@ class OrderPositionViewSetMixin:
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['pdf_data'] = False ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
ctx['check_quotas'] = self.request.query_params.get('check_quotas', 'true').lower() == 'true' ctx['check_quotas'] = self.request.query_params.get('check_quotas', 'true').lower() == 'true'
return ctx return ctx
@@ -1096,8 +1103,9 @@ class OrderPositionViewSetMixin:
qs = OrderPosition.all qs = OrderPosition.all
else: else:
qs = OrderPosition.objects qs = OrderPosition.objects
qs = qs.filter(order__event__organizer=self.request.organizer)
if self.request.query_params.get('pdf_data', 'false').lower() == 'true' and getattr(self.request, 'event', None): qs = qs.filter(order__event=self.request.event)
if self.request.query_params.get('pdf_data', 'false').lower() == 'true':
prefetch_related_objects([self.request.organizer], 'meta_properties') prefetch_related_objects([self.request.organizer], 'meta_properties')
prefetch_related_objects( prefetch_related_objects(
[self.request.event], [self.request.event],
@@ -1152,9 +1160,9 @@ class OrderPositionViewSetMixin:
qs = qs.prefetch_related( qs = qs.prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.select_related("device")), Prefetch('checkins', queryset=Checkin.objects.select_related("device")),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')), Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question', 'order__event', 'order__event__organizer' 'answers', 'answers__options', 'answers__question',
).select_related( ).select_related(
'item', 'order', 'seat' 'item', 'order', 'order__event', 'order__event__organizer', 'seat'
) )
return qs return qs
@@ -1166,45 +1174,6 @@ class OrderPositionViewSetMixin:
return prov return prov
raise NotFound('Unknown output provider.') raise NotFound('Unknown output provider.')
class OrganizerOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrganizerOrderPositionSerializer
def get_queryset(self):
qs = super().get_queryset()
perm = self.permission if self.request.method in SAFE_METHODS else self.write_permission
if isinstance(self.request.auth, (TeamAPIToken, Device)):
auth_obj = self.request.auth
elif self.request.user.is_authenticated:
auth_obj = self.request.user
else:
raise PermissionDenied("Unknown authentication scheme")
qs = qs.filter(
order__event__in=auth_obj.get_events_with_permission(perm, request=self.request).filter(
organizer=self.request.organizer
)
)
return qs
class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet):
serializer_class = OrderPositionSerializer
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
return ctx
def get_queryset(self):
qs = super().get_queryset()
qs = qs.filter(order__event=self.request.event)
return qs
@action(detail=True, methods=['POST'], url_name='price_calc') @action(detail=True, methods=['POST'], url_name='price_calc')
def price_calc(self, request, *args, **kwargs): def price_calc(self, request, *args, **kwargs):
""" """
@@ -1647,6 +1616,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
) )
except Quota.QuotaExceededException: except Quota.QuotaExceededException:
pass pass
except SendMailException:
pass
serializer = OrderPaymentSerializer(r, context=serializer.context) serializer = OrderPaymentSerializer(r, context=serializer.context)
@@ -1684,6 +1655,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e: except PaymentException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except SendMailException:
pass
return self.retrieve(request, [], **kwargs) return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
@@ -2058,7 +2031,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
else: else:
order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id) order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id)
c = generate_cancellation(inv) c = generate_cancellation(inv)
if invoice_qualified(order): if inv.order.status != Order.STATUS_CANCELED:
inv = generate_invoice(order) inv = generate_invoice(order)
else: else:
inv = c inv = c
+8 -29
View File
@@ -249,24 +249,12 @@ class GiftCardViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
value = serializer.validated_data.pop('value') value = serializer.validated_data.pop('value')
inst = serializer.save(issuer=self.request.organizer) inst = serializer.save(issuer=self.request.organizer)
inst.log_action(
action='pretix.giftcards.created',
user=self.request.user,
auth=self.request.auth,
)
inst.transactions.create(value=value, acceptor=self.request.organizer) inst.transactions.create(value=value, acceptor=self.request.organizer)
inst.log_action( inst.log_action(
action='pretix.giftcards.transaction.manual', 'pretix.giftcards.transaction.manual',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data=merge_dicts( data=merge_dicts(self.request.data, {'id': inst.pk})
self.request.data,
{
'id': inst.pk,
'acceptor_id': self.request.organizer.id,
'acceptor_slug': self.request.organizer.slug
}
)
) )
@transaction.atomic() @transaction.atomic()
@@ -281,7 +269,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency, inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
testmode=serializer.instance.testmode) testmode=serializer.instance.testmode)
inst.log_action( inst.log_action(
action='pretix.giftcards.modified', 'pretix.giftcards.modified',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data=self.request.data, data=self.request.data,
@@ -294,14 +282,10 @@ class GiftCardViewSet(viewsets.ModelViewSet):
diff = value - old_value diff = value - old_value
inst.transactions.create(value=diff, acceptor=self.request.organizer) inst.transactions.create(value=diff, acceptor=self.request.organizer)
inst.log_action( inst.log_action(
action='pretix.giftcards.transaction.manual', 'pretix.giftcards.transaction.manual',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data={ data={'value': diff}
'value': diff,
'acceptor_id': self.request.organizer.id,
'acceptor_slug': self.request.organizer.slug
}
) )
return inst return inst
@@ -325,15 +309,10 @@ class GiftCardViewSet(viewsets.ModelViewSet):
}, status=status.HTTP_409_CONFLICT) }, status=status.HTTP_409_CONFLICT)
gc.transactions.create(value=value, text=text, info=info, acceptor=self.request.organizer) gc.transactions.create(value=value, text=text, info=info, acceptor=self.request.organizer)
gc.log_action( gc.log_action(
action='pretix.giftcards.transaction.manual', 'pretix.giftcards.transaction.manual',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data={ data={'value': value, 'text': text}
'value': value,
'text': text,
'acceptor_id': self.request.organizer.id,
'acceptor_slug': self.request.organizer.slug
}
) )
return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK) return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK)
@@ -742,7 +721,7 @@ class MembershipViewSet(viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
return Membership.objects.filter( return Membership.objects.filter(
customer__organizer=self.request.organizer customer__organizer=self.request.organizer
).select_related('customer') )
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
+1 -7
View File
@@ -19,7 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from django.db import transaction from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Q
from django.utils.timezone import now from django.utils.timezone import now
@@ -65,13 +64,8 @@ class VoucherViewSet(viewsets.ModelViewSet):
permission = 'can_view_vouchers' permission = 'can_view_vouchers'
write_permission = 'can_change_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): def get_queryset(self):
return Voucher.annotate_budget_used( return self.request.event.vouchers.select_related('seat').all()
self.request.event.vouchers
).select_related(
'item', 'quota', 'seat', 'variation'
)
@transaction.atomic() @transaction.atomic()
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
-44
View File
@@ -174,38 +174,6 @@ class ParametrizedEventWebhookEvent(ParametrizedWebhookEvent):
} }
class ParametrizedGiftcardWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
giftcard = logentry.content_object
if not giftcard:
return None
return {
'notification_id': logentry.pk,
'issuer_id': logentry.organizer_id,
'issuer_slug': logentry.organizer.slug,
'giftcard': giftcard.pk,
'action': logentry.action_type,
}
class ParametrizedGiftcardTransactionWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
giftcard = logentry.content_object
if not giftcard:
return None
return {
'notification_id': logentry.pk,
'issuer_id': logentry.organizer_id,
'issuer_slug': logentry.organizer.slug,
'acceptor_id': logentry.parsed_data.get('acceptor_id'),
'acceptor_slug': logentry.parsed_data.get('acceptor_slug'),
'giftcard': giftcard.pk,
'action': logentry.action_type,
}
class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent): class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry): def build_payload(self, logentry: LogEntry):
@@ -465,18 +433,6 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.customer.anonymized', 'pretix.customer.anonymized',
_('Customer account anonymized'), _('Customer account anonymized'),
), ),
ParametrizedGiftcardWebhookEvent(
'pretix.giftcards.created',
_('Gift card added'),
),
ParametrizedGiftcardWebhookEvent(
'pretix.giftcards.modified',
_('Gift card modified'),
),
ParametrizedGiftcardTransactionWebhookEvent(
'pretix.giftcards.transaction.*',
_('Gift card used in transaction'),
)
) )
+3 -8
View File
@@ -90,7 +90,6 @@ StaticMapping = namedtuple('StaticMapping', ('id', 'pretix_model', 'external_obj
class OutboundSyncProvider: class OutboundSyncProvider:
max_attempts = 5 max_attempts = 5
list_field_joiner = "," # set to None to keep native lists in properties
def __init__(self, event): def __init__(self, event):
self.event = event self.event = event
@@ -216,10 +215,7 @@ class OutboundSyncProvider:
try: try:
mapped_objects = self.sync_order(sq.order) mapped_objects = self.sync_order(sq.order)
actions_taken = [res and res.sync_info.get("action", "") for res_list in mapped_objects.values() for res in res_list] if not all(all(not res or res.sync_info.get("action", "") == "nothing_to_do" for res in res_list) for res_list in mapped_objects.values()):
should_write_logentry = any(action not in (None, "nothing_to_do") for action in actions_taken)
logger.info('Synced order %s to %s, actions: %r, log: %r', sq.order.code, sq.sync_provider, actions_taken, should_write_logentry)
if should_write_logentry:
sq.order.log_action("pretix.event.order.data_sync.success", { sq.order.log_action("pretix.event.order.data_sync.success", {
"provider": self.identifier, "provider": self.identifier,
"objects": { "objects": {
@@ -240,7 +236,7 @@ class OutboundSyncProvider:
sq.set_sync_error("exceeded", e.messages, e.full_message) sq.set_sync_error("exceeded", e.messages, e.full_message)
else: else:
logger.info( logger.info(
f"Could not sync order {sq.order.code} to {sq.sync_provider} " f"Could not sync order {sq.order.code} to {type(self).__name__} "
f"(transient error, attempt #{sq.failed_attempts}, next {sq.not_before})", f"(transient error, attempt #{sq.failed_attempts}, next {sq.not_before})",
exc_info=True, exc_info=True,
) )
@@ -285,8 +281,7 @@ class OutboundSyncProvider:
'Please update value mapping for field "{field_name}" - option "{val}" not assigned' 'Please update value mapping for field "{field_name}" - option "{val}" not assigned'
).format(field_name=key, val=val)]) ).format(field_name=key, val=val)])
if self.list_field_joiner: val = ",".join(val)
val = self.list_field_joiner.join(val)
return val return val
def get_properties(self, inputs: dict, property_mappings: List[dict]): def get_properties(self, inputs: dict, property_mappings: List[dict]):
+7 -12
View File
@@ -71,20 +71,15 @@ def assign_properties(
return out return out
def _add_to_list(out, field_name, current_value, new_item_input, list_sep): def _add_to_list(out, field_name, current_value, new_item, list_sep):
new_item = str(new_item)
if list_sep is not None: if list_sep is not None:
new_items = str(new_item_input).split(list_sep) new_item = new_item.replace(list_sep, "")
current_value = current_value.split(list_sep) if current_value else [] current_value = current_value.split(list_sep) if current_value else []
else: elif not isinstance(current_value, (list, tuple)):
new_items = [str(new_item_input)] current_value = [str(current_value)]
if not isinstance(current_value, (list, tuple)): if new_item not in current_value:
current_value = [str(current_value)] new_list = current_value + [new_item]
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: if list_sep is not None:
new_list = list_sep.join(new_list) new_list = list_sep.join(new_list)
out[field_name] = new_list out[field_name] = new_list
+7 -9
View File
@@ -39,7 +39,7 @@ from pretix.base.templatetags.rich_text import (
DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback, DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback,
markdown_compile_email, truelink_callback, markdown_compile_email, truelink_callback,
) )
from pretix.helpers.format import FormattedString, SafeFormatter, format_map from pretix.helpers.format import SafeFormatter, format_map
from pretix.base.services.placeholders import ( # noqa from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext get_available_placeholders, PlaceholderContext
@@ -141,7 +141,6 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
return markdown_compile_email(plaintext, context=context) return markdown_compile_email(plaintext, context=context)
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str: def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
apply_format_map = not isinstance(plain_body, FormattedString)
body_md = self.compile_markdown(plain_body, context) body_md = self.compile_markdown(plain_body, context)
if context: if context:
linker = bleach.Linker( linker = bleach.Linker(
@@ -150,13 +149,12 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback], callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
parse_email=True parse_email=True
) )
if apply_format_map: body_md = format_map(
body_md = format_map( body_md,
body_md, context=context,
context=context, mode=SafeFormatter.MODE_RICH_TO_HTML,
mode=SafeFormatter.MODE_RICH_TO_HTML, linkifier=linker
linkifier=linker )
)
htmlctx = { htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME, 'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL, 'site_url': settings.SITE_URL,
+6 -43
View File
@@ -39,8 +39,8 @@ from zoneinfo import ZoneInfo
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.db.models import ( from django.db.models import (
Case, CharField, Count, DateTimeField, Exists, F, IntegerField, Max, Min, Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
OuterRef, Q, Subquery, Sum, When, Q, Subquery, Sum, When,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.dispatch import receiver from django.dispatch import receiver
@@ -144,18 +144,6 @@ class OrderListExporter(MultiSheetListExporter):
d = OrderedDict(d) d = OrderedDict(d)
if not self.is_multievent and not self.event.has_subevents: if not self.is_multievent and not self.event.has_subevents:
del d['event_date_range'] del d['event_date_range']
if not self.is_multievent:
d["items"] = forms.ModelMultipleChoiceField(
label=_("Products"),
queryset=self.event.items.all(),
widget=forms.CheckboxSelectMultiple(
attrs={"class": "scrolling-multiple-choice"}
),
help_text=_("If none are selected, all products are included. Orders are included if they contain "
"at least one position of this product. The order totals etc. still include all products "
"contained in the order."),
required=False,
)
return d return d
def _get_all_payment_methods(self, qs): def _get_all_payment_methods(self, qs):
@@ -261,14 +249,6 @@ class OrderListExporter(MultiSheetListExporter):
pcnt=Subquery(s, output_field=IntegerField()) pcnt=Subquery(s, output_field=IntegerField())
).select_related('invoice_address', 'customer') ).select_related('invoice_address', 'customer')
if form_data.get('items'):
qs = qs.filter(
Exists(OrderPosition.all.filter(
order=OuterRef('pk'),
item__in=form_data["items"]
))
)
qs = self._date_filter(qs, form_data, rel='') qs = self._date_filter(qs, form_data, rel='')
if form_data['paid_only']: if form_data['paid_only']:
@@ -384,7 +364,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city, order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old, order.invoice_address.country_old,
order.invoice_address.state_for_address, order.invoice_address.state,
order.invoice_address.custom_field, order.invoice_address.custom_field,
order.invoice_address.vat_id, order.invoice_address.vat_id,
] ]
@@ -460,14 +440,6 @@ class OrderListExporter(MultiSheetListExporter):
if form_data['paid_only']: if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
if form_data.get('items'):
qs = qs.filter(
Exists(OrderPosition.all.filter(
order=OuterRef('order'),
item__in=form_data["items"]
))
)
qs = self._date_filter(qs, form_data, rel='order__') qs = self._date_filter(qs, form_data, rel='order__')
return qs return qs
@@ -543,7 +515,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city, order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old, order.invoice_address.country_old,
order.invoice_address.state_for_address, order.invoice_address.state,
order.invoice_address.vat_id, order.invoice_address.vat_id,
] ]
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
@@ -563,11 +535,6 @@ class OrderListExporter(MultiSheetListExporter):
if form_data['paid_only']: if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
if form_data.get('items'):
qs = qs.filter(
item__in=form_data["items"]
)
qs = self._date_filter(qs, form_data, rel='order__') qs = self._date_filter(qs, form_data, rel='order__')
return qs return qs
@@ -650,8 +617,6 @@ class OrderListExporter(MultiSheetListExporter):
_('Country'), _('Country'),
pgettext('address', 'State'), pgettext('address', 'State'),
_('Voucher'), _('Voucher'),
_('Voucher budget usage'),
_('Voucher tag'),
_('Pseudonymization ID'), _('Pseudonymization ID'),
_('Ticket secret'), _('Ticket secret'),
_('Seat ID'), _('Seat ID'),
@@ -767,10 +732,8 @@ class OrderListExporter(MultiSheetListExporter):
op.zipcode or '', op.zipcode or '',
op.city or '', op.city or '',
op.country if op.country else '', op.country if op.country else '',
op.state_for_address or '', op.state or '',
op.voucher.code if op.voucher else '', op.voucher.code if op.voucher else '',
op.voucher_budget_use if op.voucher_budget_use else '',
op.voucher.tag if op.voucher else '',
op.pseudonymization_id, op.pseudonymization_id,
op.secret, op.secret,
] ]
@@ -834,7 +797,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city, order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old, order.invoice_address.country_old,
order.invoice_address.state_for_address, order.invoice_address.state,
order.invoice_address.vat_id, order.invoice_address.vat_id,
] ]
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
+17 -36
View File
@@ -66,10 +66,8 @@ from geoip2.errors import AddressNotFoundError
from phonenumber_field.formfields import PhoneNumberField from phonenumber_field.formfields import PhoneNumberField
from phonenumber_field.phonenumber import PhoneNumber from phonenumber_field.phonenumber import PhoneNumber
from phonenumber_field.widgets import PhoneNumberPrefixWidget from phonenumber_field.widgets import PhoneNumberPrefixWidget
from phonenumbers import ( from phonenumbers import NumberParseException, national_significant_number
COUNTRY_CODE_TO_REGION_CODE, REGION_CODE_FOR_NON_GEO_ENTITY, from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
NumberParseException, national_significant_number,
)
from PIL import ImageOps from PIL import ImageOps
from pretix.base.forms.widgets import ( from pretix.base.forms.widgets import (
@@ -85,7 +83,7 @@ from pretix.base.invoicing.transmission import (
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
from pretix.base.models.tax import ask_for_vat_id from pretix.base.models.tax import ask_for_vat_id
from pretix.base.services.tax import ( from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id, VATIDFinalError, VATIDTemporaryError, validate_vat_id,
) )
from pretix.base.settings import ( from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
@@ -307,9 +305,7 @@ class WrappedPhonePrefixSelect(Select):
choices = [("", "---------")] choices = [("", "---------")]
if initial: 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: if initial in values:
self.initial = "+%d" % prefix self.initial = "+%d" % prefix
break break
@@ -441,9 +437,7 @@ def guess_phone_prefix_from_request(request, event):
def get_phone_prefix(country): def get_phone_prefix(country):
if country == REGION_CODE_FOR_NON_GEO_ENTITY: for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
return None
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
if country in values: if country in values:
return prefix return prefix
return None return None
@@ -890,18 +884,18 @@ class BaseQuestionsForm(forms.Form):
if not help_text: if not help_text:
if q.valid_date_min and q.valid_date_max: if q.valid_date_min and q.valid_date_max:
help_text = format_lazy( help_text = format_lazy(
_('Please enter a date between {min} and {max}.'), 'Please enter a date between {min} and {max}.',
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
) )
elif q.valid_date_min: elif q.valid_date_min:
help_text = format_lazy( help_text = format_lazy(
_('Please enter a date no earlier than {min}.'), 'Please enter a date no earlier than {min}.',
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
) )
elif q.valid_date_max: elif q.valid_date_max:
help_text = format_lazy( help_text = format_lazy(
_('Please enter a date no later than {max}.'), 'Please enter a date no later than {max}.',
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
) )
if initial and initial.answer: if initial and initial.answer:
@@ -939,18 +933,18 @@ class BaseQuestionsForm(forms.Form):
if not help_text: if not help_text:
if q.valid_datetime_min and q.valid_datetime_max: if q.valid_datetime_min and q.valid_datetime_max:
help_text = format_lazy( help_text = format_lazy(
_('Please enter a date and time between {min} and {max}.'), 'Please enter a date and time between {min} and {max}.',
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
) )
elif q.valid_datetime_min: elif q.valid_datetime_min:
help_text = format_lazy( help_text = format_lazy(
_('Please enter a date and time no earlier than {min}.'), 'Please enter a date and time no earlier than {min}.',
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
) )
elif q.valid_datetime_max: elif q.valid_datetime_max:
help_text = format_lazy( help_text = format_lazy(
_('Please enter a date and time no later than {max}.'), 'Please enter a date and time no later than {max}.',
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
) )
@@ -1171,11 +1165,13 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.fields['vat_id'].help_text = '<br/>'.join([ self.fields['vat_id'].help_text = '<br/>'.join([
str(_('Optional, but depending on the country you reside in we might need to charge you ' str(_('Optional, but depending on the country you reside in we might need to charge you '
'additional taxes if you do not enter it.')), 'additional taxes if you do not enter it.')),
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
]) ])
else: else:
self.fields['vat_id'].help_text = '<br/>'.join([ self.fields['vat_id'].help_text = '<br/>'.join([
str(_('Optional, but it might be required for you to claim tax benefits on your invoice ' str(_('Optional, but it might be required for you to claim tax benefits on your invoice '
'depending on your and the sellers country of residence.')), 'depending on your and the sellers country of residence.')),
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
]) ])
transmission_type_choices = [ transmission_type_choices = [
@@ -1362,24 +1358,13 @@ class BaseInvoiceAddressForm(forms.ModelForm):
"transmission method.")} "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: if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass # Skip re-validation if it is validated pass
elif self.validate_vat_id and vat_id_applicable: elif self.validate_vat_id and data.get('is_business') and ask_for_vat_id(data.get('country')) and data.get('vat_id'):
try: try:
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country'))) normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
self.instance.vat_id_validated = True self.instance.vat_id_validated = True
self.instance.vat_id = data['vat_id'] = normalized_id self.instance.vat_id = normalized_id
except VATIDFinalError as e: except VATIDFinalError as e:
if self.all_optional: if self.all_optional:
self.instance.vat_id_validated = False self.instance.vat_id_validated = False
@@ -1387,9 +1372,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
else: else:
raise ValidationError({"vat_id": e.message}) raise ValidationError({"vat_id": e.message})
except VATIDTemporaryError as e: 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 self.instance.vat_id_validated = False
if self.request and self.vat_warning: if self.request and self.vat_warning:
messages.warning(self.request, e.message) messages.warning(self.request, e.message)
@@ -1415,10 +1397,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if not data.get(r): if not data.get(r):
raise ValidationError({r: _("This field is required for the selected type of invoice transmission.")}) raise ValidationError({r: _("This field is required for the selected type of invoice transmission.")})
transmission_type.validate_invoice_address_data(data)
self.instance.transmission_type = transmission_type.identifier self.instance.transmission_type = transmission_type.identifier
self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data) self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data)
elif transmission_type.is_exclusive(self.event, data.get("country"), data.get("is_business")): elif transmission_type.exclusive:
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")): if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
raise ValidationError({ raise ValidationError({
"transmission_type": "The transmission type '%s' must be used for this country or address type." % ( "transmission_type": "The transmission type '%s' must be used for this country or address type." % (
+1 -10
View File
@@ -42,8 +42,6 @@ from django.utils.html import escape
from django.utils.timezone import get_current_timezone, now from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.helpers.format import PlainHtmlAlternativeString
def replace_arabic_numbers(inp): def replace_arabic_numbers(inp):
if not isinstance(inp, str): if not isinstance(inp, str):
@@ -63,18 +61,11 @@ def replace_arabic_numbers(inp):
return inp.translate(table) return inp.translate(table)
def format_placeholder_help_text(placeholder_name, sample_value):
if isinstance(sample_value, PlainHtmlAlternativeString):
sample_value = sample_value.plain
title = (_("Sample: %s") % sample_value) if sample_value else ""
return ('<button type="button" class="content-placeholder" title="%s">{%s}</button>' % (escape(title), escape(placeholder_name)))
def format_placeholders_help_text(placeholders, event=None): def format_placeholders_help_text(placeholders, event=None):
placeholders = [(k, v.render_sample(event) if event else v) for k, v in placeholders.items()] placeholders = [(k, v.render_sample(event) if event else v) for k, v in placeholders.items()]
placeholders.sort(key=lambda x: x[0]) placeholders.sort(key=lambda x: x[0])
phs = [ phs = [
format_placeholder_help_text(k, v) '<button type="button" class="content-placeholder" title="%s">{%s}</button>' % (escape(_("Sample: %s") % v) if v else "", escape(k))
for k, v in placeholders for k, v in placeholders
] ]
return _('Available placeholders: {list}').format( return _('Available placeholders: {list}').format(
+12 -50
View File
@@ -34,13 +34,14 @@
from contextlib import contextmanager from contextlib import contextmanager
from asgiref.local import Local
from babel import localedata from babel import localedata
from django.conf import settings from django.conf import settings
from django.utils import translation from django.utils import translation
from django.utils.formats import date_format, number_format from django.utils.formats import date_format, number_format
from django.utils.translation import gettext from django.utils.translation import gettext
from pretix.base.templatetags.money import money_filter
from i18nfield.fields import ( # noqa from i18nfield.fields import ( # noqa
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput, I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
) )
@@ -50,9 +51,6 @@ from i18nfield.strings import LazyI18nString # noqa
from i18nfield.utils import I18nJSONEncoder # noqa from i18nfield.utils import I18nJSONEncoder # noqa
_active_region = Local()
class LazyDate: class LazyDate:
def __init__(self, value): def __init__(self, value):
self.value = value self.value = value
@@ -88,8 +86,6 @@ class LazyCurrencyNumber:
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
from pretix.base.templatetags.money import money_filter
return money_filter(self.value, self.currency) return money_filter(self.value, self.currency)
@@ -109,41 +105,14 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
def get_babel_locale(): def get_babel_locale():
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal babel_locale = 'en'
# Also, this returns best-effort region information for number formatting etc # Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
current_language = translation.get_language() if translation.get_language():
current_region = getattr(_active_region, "value", None) if localedata.exists(translation.get_language()):
babel_locale = translation.get_language()
# Babel only accepts locales that exist on the system. We try combinations in the following order: elif localedata.exists(translation.get_language()[:2]):
# language-languageversion-region babel_locale = translation.get_language()[:2]
# language-region return babel_locale
# language-languageversion
# language
# fallback to system default
# fallback to english
try_locales = []
if current_language:
if "-" in current_language:
lng_parts = current_language.split("-")
if current_region:
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}_{current_region.upper()}")
try_locales.append(f"{lng_parts[0]}_{current_region.upper()}")
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].upper()}")
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}")
try_locales.append(f"{lng_parts[0]}")
else:
if current_region:
try_locales.append(f"{current_language}_{current_region.upper()}")
try_locales.append(f"{current_language}")
try_locales.append(settings.LANGUAGE_CODE)
for locale in try_locales:
if localedata.exists(locale):
return localedata.normalize_locale(locale)
return "en"
def get_language_without_region(lng=None): def get_language_without_region(lng=None):
@@ -163,10 +132,6 @@ def get_language_without_region(lng=None):
return lng return lng
def set_region(region):
_active_region.value = region
@contextmanager @contextmanager
def language(lng, region=None): def language(lng, region=None):
""" """
@@ -178,18 +143,15 @@ def language(lng, region=None):
formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region`` formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region``
attribute will be ignored. attribute will be ignored.
""" """
lng_before = translation.get_language() _lng = translation.get_language()
region_before = getattr(_active_region, "value", None)
lng = lng or settings.LANGUAGE_CODE lng = lng or settings.LANGUAGE_CODE
if '-' not in lng and region: if '-' not in lng and region:
lng += '-' + region.lower() lng += '-' + region.lower()
translation.activate(lng) translation.activate(lng)
_active_region.value = region
try: try:
yield yield
finally: finally:
translation.activate(lng_before) translation.activate(_lng)
_active_region.value = region_before
class LazyLocaleException(Exception): class LazyLocaleException(Exception):
+35 -19
View File
@@ -33,7 +33,8 @@ from pretix.base.invoicing.transmission import (
transmission_types, transmission_types,
) )
from pretix.base.models import Invoice, InvoiceAddress from pretix.base.models import Invoice, InvoiceAddress
from pretix.base.services.mail import mail from pretix.base.services.mail import SendMailException, mail, render_mail
from pretix.helpers.format import format_map
@transmission_types.new() @transmission_types.new()
@@ -132,26 +133,41 @@ class EmailTransmissionProvider(TransmissionProvider):
template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString) template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString)
subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString) subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString)
# Do not set to completed because that is done by the email sending task try:
outgoing_mail = mail( # Do not set to completed because that is done by the email sending task
[recipient], subject = format_map(subject, context)
subject, email_content = render_mail(template, context)
template, mail(
context=context, [recipient],
event=invoice.order.event, subject,
locale=invoice.order.locale, template,
order=invoice.order, context=context,
invoices=[invoice], event=invoice.order.event,
attach_tickets=False, locale=invoice.order.locale,
auto_email=True, order=invoice.order,
attach_ical=False, invoices=[invoice],
plain_text_only=True, attach_tickets=False,
no_order_links=True, auto_email=True,
) attach_ical=False,
if outgoing_mail: plain_text_only=True,
no_order_links=True,
)
except SendMailException:
raise
else:
invoice.order.log_action( invoice.order.log_action(
'pretix.event.order.email.invoice', 'pretix.event.order.email.invoice',
user=None, user=None,
auth=None, auth=None,
data=outgoing_mail.log_data() data={
'subject': subject,
'message': email_content,
'position': None,
'recipient': recipient,
'invoices': [invoice.pk],
'attach_tickets': False,
'attach_ical': False,
'attach_other_files': [],
'attach_cached_files': [],
}
) )
+1 -3
View File
@@ -36,11 +36,9 @@ class ItalianSdITransmissionType(TransmissionType):
identifier = "it_sdi" identifier = "it_sdi"
verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)") verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)")
public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)") public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)")
exclusive = True
enforce_transmission = True enforce_transmission = True
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
return str(country) == "IT"
def is_available(self, event, country: Country, is_business: bool): def is_available(self, event, country: Country, is_business: bool):
return str(country) == "IT" and super().is_available(event, country, is_business) return str(country) == "IT" and super().is_available(event, country, is_business)
+12 -15
View File
@@ -32,6 +32,7 @@ from itertools import groupby
from typing import Tuple from typing import Tuple
import bleach import bleach
import vat_moss.exchange_rates
from bidi import get_display from bidi import get_display
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.db.models import Sum from django.db.models import Sum
@@ -46,6 +47,7 @@ from reportlab.lib.styles import ParagraphStyle, StyleSheet1
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.pdfmetrics import stringWidth from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import ( from reportlab.platypus import (
BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate, BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate,
@@ -58,8 +60,7 @@ from pretix.base.services.currencies import SOURCE_NAMES
from pretix.base.signals import register_invoice_renderers from pretix.base.signals import register_invoice_renderers
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.helpers.reportlab import ( from pretix.helpers.reportlab import (
FontFallbackParagraph, ThumbnailingImageReader, register_ttf_font_if_new, FontFallbackParagraph, ThumbnailingImageReader, reshaper,
reshaper,
) )
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
@@ -148,10 +149,6 @@ class NumberedCanvas(Canvas):
self.restoreState() self.restoreState()
class InvoiceNotReadyException(Exception):
pass
class BaseInvoiceRenderer: class BaseInvoiceRenderer:
""" """
This is the base class for all invoice renderers. This is the base class for all invoice renderers.
@@ -238,25 +235,25 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
""" """
Register fonts with reportlab. By default, this registers the OpenSans font family Register fonts with reportlab. By default, this registers the OpenSans font family
""" """
register_ttf_font_if_new('OpenSans', finders.find('fonts/OpenSans-Regular.ttf')) pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf')))
register_ttf_font_if_new('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf')) pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf')))
register_ttf_font_if_new('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf')) pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf')))
register_ttf_font_if_new('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf')) pdfmetrics.registerFont(TTFont('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf')))
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd', pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
italic='OpenSansIt', boldItalic='OpenSansBI') italic='OpenSansIt', boldItalic='OpenSansBI')
for family, styles in get_fonts(event=self.event, pdf_support_required=True).items(): for family, styles in get_fonts(event=self.event, pdf_support_required=True).items():
register_ttf_font_if_new(family, finders.find(styles['regular']['truetype'])) pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
if family == self.event.settings.invoice_renderer_font: if family == self.event.settings.invoice_renderer_font:
self.font_regular = family self.font_regular = family
if 'bold' in styles: if 'bold' in styles:
self.font_bold = family + ' B' self.font_bold = family + ' B'
if 'italic' in styles: if 'italic' in styles:
register_ttf_font_if_new(family + ' I', finders.find(styles['italic']['truetype'])) pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
if 'bold' in styles: if 'bold' in styles:
register_ttf_font_if_new(family + ' B', finders.find(styles['bold']['truetype'])) pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
if 'bolditalic' in styles: if 'bolditalic' in styles:
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype'])) pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
def _normalize(self, text): def _normalize(self, text):
# reportlab does not support unicode combination characters # reportlab does not support unicode combination characters
@@ -1062,7 +1059,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def fmt(val): def fmt(val):
try: try:
return money_filter(val, self.invoice.foreign_currency_display) return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display)
except ValueError: except ValueError:
return localize(val) + ' ' + self.invoice.foreign_currency_display return localize(val) + ' ' + self.invoice.foreign_currency_display
+2 -16
View File
@@ -64,7 +64,7 @@ class PeppolIdValidator:
"0020": "[0-9]{9}", "0020": "[0-9]{9}",
"0201": "[0-9a-zA-Z]{6}", "0201": "[0-9a-zA-Z]{6}",
"0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}", "0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}",
"0208": "[01][0-9]{9}", "0208": "0[0-9]{9}",
"0209": ".*", "0209": ".*",
"0210": "[A-Z0-9]+", "0210": "[A-Z0-9]+",
"0211": "IT[0-9]{11}", "0211": "IT[0-9]{11}",
@@ -73,9 +73,6 @@ class PeppolIdValidator:
"0205": "[A-Z0-9]+", "0205": "[A-Z0-9]+",
"0221": "T[0-9]{13}", "0221": "T[0-9]{13}",
"0230": ".*", "0230": ".*",
"0244": "[0-9]{13}",
"0245": "[0-9]{10}",
"0246": "DE[0-9]{9}(-[0-9]{5})?(\\.[0-9A-Z]{1,8})?",
"9901": ".*", "9901": ".*",
"9902": "[1-9][0-9]{7}", "9902": "[1-9][0-9]{7}",
"9904": "DK[0-9]{8}", "9904": "DK[0-9]{8}",
@@ -123,6 +120,7 @@ class PeppolIdValidator:
"9951": ".*", "9951": ".*",
"9952": ".*", "9952": ".*",
"9953": ".*", "9953": ".*",
"9954": ".*",
"9956": "0[0-9]{9}", "9956": "0[0-9]{9}",
"9957": ".*", "9957": ".*",
"9959": ".*", "9959": ".*",
@@ -179,12 +177,6 @@ class PeppolTransmissionType(TransmissionType):
def is_available(self, event, country: Country, is_business: bool): def is_available(self, event, country: Country, is_business: bool):
return is_business and super().is_available(event, country, is_business) return is_business and super().is_available(event, country, is_business)
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
if is_business and str(country) == "BE" and event and event.settings.invoice_address_from_country == "BE":
# Peppol is required to be used for intra-Belgian B2B invoices
return True
return False
@property @property
def invoice_address_form_fields(self) -> dict: def invoice_address_form_fields(self) -> dict:
return { return {
@@ -204,12 +196,6 @@ class PeppolTransmissionType(TransmissionType):
} }
return base | {"transmission_peppol_participant_id"} return base | {"transmission_peppol_participant_id"}
def validate_invoice_address_data(self, address_data: dict):
# Special case Belgium: If a Belgian business ID is used as Peppol ID, it should match the VAT ID
if address_data.get("transmission_peppol_participant_id").startswith("0208:") and address_data.get("vat_id"):
if address_data["vat_id"].removeprefix("BE") != address_data["transmission_peppol_participant_id"].removeprefix("0208:"):
raise ValidationError({"transmission_peppol_participant_id": _("The Peppol participant ID does not match your VAT ID.")})
def pdf_watermark(self) -> str: def pdf_watermark(self) -> str:
return pgettext("peppol_invoice", "Visual copy") return pgettext("peppol_invoice", "Visual copy")
+11 -28
View File
@@ -21,10 +21,9 @@
# #
from typing import Optional from typing import Optional
from django.utils.translation import gettext_lazy as _
from django_countries.fields import Country from django_countries.fields import Country
from pretix.base.models import Invoice from pretix.base.models import Invoice, InvoiceAddress
from pretix.base.signals import EventPluginRegistry, Registry from pretix.base.signals import EventPluginRegistry, Registry
@@ -59,6 +58,15 @@ class TransmissionType:
""" """
return 100 return 100
@property
def exclusive(self) -> bool:
"""
If a transmission type is exclusive, no other type can be chosen if this type is
available. Use e.g. if a certain transmission type is legally required in a certain
jurisdiction.
"""
return False
@property @property
def enforce_transmission(self) -> bool: def enforce_transmission(self) -> bool:
""" """
@@ -74,22 +82,13 @@ class TransmissionType:
for provider, _ in providers for provider, _ in providers
) )
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
"""
If a transmission type is exclusive, no other type can be chosen if this type is
available. Use e.g. if a certain transmission type is legally required in a certain
jurisdiction. Event can be None in organizer-level contexts. Exclusiveness has no effect if
the type is not available.
"""
return False
def invoice_address_form_fields_required(self, country: Country, is_business: bool): def invoice_address_form_fields_required(self, country: Country, is_business: bool):
return set() return set()
def invoice_address_form_fields_visible(self, country: Country, is_business: bool) -> set: def invoice_address_form_fields_visible(self, country: Country, is_business: bool) -> set:
return set(self.invoice_address_form_fields.keys()) return set(self.invoice_address_form_fields.keys())
def validate_invoice_address_data(self, address_data: dict): def validate_address(self, ia: InvoiceAddress):
pass pass
@property @property
@@ -107,22 +106,6 @@ class TransmissionType:
def transmission_info_to_form_data(self, transmission_info: dict) -> dict: def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return transmission_info return transmission_info
def describe_info(self, transmission_info: dict, country: Country, is_business: bool):
form_data = self.transmission_info_to_form_data(transmission_info)
data = []
visible_field_keys = self.invoice_address_form_fields_visible(country, is_business)
for k, f in self.invoice_address_form_fields.items():
if k not in visible_field_keys:
continue
v = form_data.get(k)
if v is True:
v = _("Yes")
elif v is False:
v = _("No")
if v:
data.append((f.label, v))
return data
def pdf_watermark(self) -> Optional[str]: def pdf_watermark(self) -> Optional[str]:
""" """
Return a watermark that should be rendered across the PDF file. Return a watermark that should be rendered across the PDF file.
+7 -21
View File
@@ -294,28 +294,14 @@ def metric_values():
channel = app.broker_connection().channel() channel = app.broker_connection().channel()
if hasattr(channel, 'client') and channel.client is not None: if hasattr(channel, 'client') and channel.client is not None:
client = channel.client client = channel.client
priority_steps = settings.CELERY_BROKER_TRANSPORT_OPTIONS.get("priority_steps", [0])
sep = settings.CELERY_BROKER_TRANSPORT_OPTIONS.get("sep", ":")
for q in settings.CELERY_TASK_QUEUES: for q in settings.CELERY_TASK_QUEUES:
queue_lengths = [] llen = client.llen(q.name)
queue_delays = [] lfirst = client.lindex(q.name, -1)
for prio in priority_steps: metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = llen
if prio: if lfirst:
qname = f"{q.name}{sep}{prio}" ldata = json.loads(lfirst)
else: dt = time.time() - ldata.get('created', 0)
qname = q.name metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = dt
queue_length = client.llen(qname)
queue_lengths.append(queue_length)
oldest_queue_item = client.lindex(qname, -1)
if oldest_queue_item:
ldata = json.loads(oldest_queue_item)
oldest_item_age = time.time() - ldata.get('created', 0)
queue_delays.append(oldest_item_age)
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = sum(queue_lengths)
if queue_delays:
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = max(queue_delays)
else: else:
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0 metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0
+1 -5
View File
@@ -35,7 +35,7 @@ from django.utils.translation.trans_real import (
parse_accept_lang_header, parse_accept_lang_header,
) )
from pretix.base.i18n import get_language_without_region, set_region from pretix.base.i18n import get_language_without_region
from pretix.base.settings import global_settings_object from pretix.base.settings import global_settings_object
from pretix.multidomain.urlreverse import ( from pretix.multidomain.urlreverse import (
get_event_domain, get_organizer_domain, get_event_domain, get_organizer_domain,
@@ -92,14 +92,10 @@ class LocaleMiddleware(MiddlewareMixin):
) )
if '-' not in language and settings_holder.settings.region: if '-' not in language and settings_holder.settings.region:
language += '-' + settings_holder.settings.region language += '-' + settings_holder.settings.region
if settings_holder.settings.region:
set_region(settings_holder.settings.region)
else: else:
gs = global_settings_object(request) gs = global_settings_object(request)
if '-' not in language and gs.settings.region: if '-' not in language and gs.settings.region:
language += '-' + gs.settings.region language += '-' + gs.settings.region
if gs.settings.region:
set_region(gs.settings.region)
translation.activate(language) translation.activate(language)
request.LANGUAGE_CODE = get_language_without_region() request.LANGUAGE_CODE = get_language_without_region()
@@ -1,120 +0,0 @@
# Generated by Django 4.2.26 on 2026-01-22 13:44
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.mail
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0296_invoice_invoice_from_state"),
]
operations = [
migrations.CreateModel(
name="OutgoingMail",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("guid", models.UUIDField(db_index=True, default=uuid.uuid4)),
("status", models.CharField(default="queued", max_length=200)),
("created", models.DateTimeField(auto_now_add=True)),
("sent", models.DateTimeField(blank=True, null=True)),
("inflight_since", models.DateTimeField(blank=True, null=True)),
("retry_after", models.DateTimeField(blank=True, null=True)),
("error", models.TextField(null=True)),
("error_detail", models.TextField(null=True)),
("sensitive", models.BooleanField(default=False)),
("subject", models.TextField()),
("body_plain", models.TextField()),
("body_html", models.TextField(null=True)),
("sender", models.CharField(max_length=500)),
("headers", models.JSONField(default=dict)),
("to", models.JSONField(default=list)),
("cc", models.JSONField(default=list)),
("bcc", models.JSONField(default=list)),
("recipient_count", models.IntegerField()),
("should_attach_tickets", models.BooleanField(default=False)),
("should_attach_ical", models.BooleanField(default=False)),
("should_attach_other_files", models.JSONField(default=list)),
("actual_attachments", models.JSONField(default=list)),
(
"customer",
models.ForeignKey(
null=True,
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
related_name="outgoing_mails",
to="pretixbase.customer",
),
),
(
"event",
models.ForeignKey(
null=True,
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
related_name="outgoing_mails",
to="pretixbase.event",
),
),
(
"order",
models.ForeignKey(
null=True,
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
related_name="outgoing_mails",
to="pretixbase.order",
),
),
(
"orderposition",
models.ForeignKey(
null=True,
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
related_name="outgoing_mails",
to="pretixbase.orderposition",
),
),
(
"organizer",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="outgoing_mails",
to="pretixbase.organizer",
),
),
(
"should_attach_cached_files",
models.ManyToManyField(
related_name="outgoing_mails", to="pretixbase.cachedfile"
),
),
(
"should_attach_invoices",
models.ManyToManyField(
related_name="outgoing_mails", to="pretixbase.invoice"
),
),
(
"user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="outgoing_mails",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ("-created",),
},
),
]
-14
View File
@@ -47,19 +47,6 @@ class DataImportError(LazyLocaleException):
super().__init__(msg) 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): def parse_csv(file, length=None, mode="strict", charset=None):
file.seek(0) file.seek(0)
data = file.read(length) data = file.read(length)
@@ -83,7 +70,6 @@ def parse_csv(file, length=None, mode="strict", charset=None):
return None return None
reader = csv.DictReader(io.StringIO(data), dialect=dialect) reader = csv.DictReader(io.StringIO(data), dialect=dialect)
reader._had_duplicates = rename_duplicates(reader.fieldnames)
return reader return reader
+3 -3
View File
@@ -132,7 +132,7 @@ class AllowIgnoreQuotaColumn(BooleanColumnMixin, ImportColumn):
class PriceModeColumn(ImportColumn): class PriceModeColumn(ImportColumn):
identifier = 'price_mode' identifier = 'price_mode'
verbose_name = gettext_lazy('Price effect') verbose_name = gettext_lazy('Price mode')
default_value = None default_value = None
initial = 'static:none' initial = 'static:none'
@@ -147,7 +147,7 @@ class PriceModeColumn(ImportColumn):
elif value in reverse: elif value in reverse:
return reverse[value] return reverse[value]
else: else:
raise ValidationError(_("Could not parse {value} as a price effect, use one of {options}.").format( raise ValidationError(_("Could not parse {value} as a price mode, use one of {options}.").format(
value=value, options=', '.join(d.keys()) value=value, options=', '.join(d.keys())
)) ))
@@ -162,7 +162,7 @@ class ValueColumn(DecimalColumnMixin, ImportColumn):
def clean(self, value, previous_values): def clean(self, value, previous_values):
value = super().clean(value, previous_values) value = super().clean(value, previous_values)
if value and previous_values.get("price_mode") == "none": if value and previous_values.get("price_mode") == "none":
raise ValidationError(_("It is pointless to set a value without a price effect.")) raise ValidationError(_("It is pointless to set a value without a price mode."))
return value return value
def assign(self, value, obj: Voucher, **kwargs): def assign(self, value, obj: Voucher, **kwargs):
-1
View File
@@ -41,7 +41,6 @@ from .items import (
itempicture_upload_to, itempicture_upload_to,
) )
from .log import LogEntry from .log import LogEntry
from .mail import OutgoingMail
from .media import ReusableMedium from .media import ReusableMedium
from .memberships import Membership, MembershipType from .memberships import Membership, MembershipType
from .notifications import NotificationSetting from .notifications import NotificationSetting
+20 -23
View File
@@ -53,6 +53,7 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_otp.models import Device from django_otp.models import Device
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.helpers.urls import build_absolute_uri from pretix.helpers.urls import build_absolute_uri
@@ -334,25 +335,27 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return self.email return self.email
def send_security_notice(self, messages, email=None): def send_security_notice(self, messages, email=None):
from pretix.base.services.mail import mail from pretix.base.services.mail import SendMailException, mail
with language(self.locale): try:
msg = '- ' + '\n- '.join(str(m) for m in messages) with language(self.locale):
msg = '- ' + '\n- '.join(str(m) for m in messages)
mail( mail(
email or self.email, email or self.email,
_('Account information changed'), _('Account information changed'),
'pretixcontrol/email/security_notice.txt', 'pretixcontrol/email/security_notice.txt',
{ {
'user': self, 'user': self,
'messages': msg, 'messages': msg,
'url': build_absolute_uri('control:user.settings'), 'url': build_absolute_uri('control:user.settings')
'instance': settings.PRETIX_INSTANCE_NAME, },
}, event=None,
event=None, user=self,
user=self, locale=self.locale
locale=self.locale )
) except SendMailException:
pass # Already logged
def send_confirmation_code(self, session, reason, email=None, state=None): def send_confirmation_code(self, session, reason, email=None, state=None):
""" """
@@ -392,7 +395,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
'user': self, 'user': self,
'reason': msg, 'reason': msg,
'code': code, 'code': code,
'instance': settings.PRETIX_INSTANCE_NAME,
}, },
event=None, event=None,
user=self, user=self,
@@ -432,7 +434,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
mail( mail(
self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt', self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
{ {
'instance': settings.PRETIX_INSTANCE_NAME,
'user': self, 'user': self,
'url': (build_absolute_uri('control:auth.forgot.recover') 'url': (build_absolute_uri('control:auth.forgot.recover')
+ '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self))) + '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self)))
@@ -707,8 +708,6 @@ class U2FDevice(Device):
@property @property
def webauthndevice(self): def webauthndevice(self):
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
d = json.loads(self.json_data) d = json.loads(self.json_data)
return PublicKeyCredentialDescriptor(websafe_decode(d['keyHandle'])) return PublicKeyCredentialDescriptor(websafe_decode(d['keyHandle']))
@@ -738,8 +737,6 @@ class WebAuthnDevice(Device):
@property @property
def webauthndevice(self): def webauthndevice(self):
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id)) return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id))
@property @property
-33
View File
@@ -59,37 +59,6 @@ class CachedFile(models.Model):
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins 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 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) @receiver(post_delete, sender=CachedFile)
def cached_file_delete(sender, instance, **kwargs): def cached_file_delete(sender, instance, **kwargs):
@@ -130,8 +99,6 @@ class LoggingMixin:
organizer_id = self.event.organizer_id organizer_id = self.event.organizer_id
elif hasattr(self, 'organizer_id'): elif hasattr(self, 'organizer_id'):
organizer_id = self.organizer_id organizer_id = self.organizer_id
elif hasattr(self, 'issuer_id'):
organizer_id = self.issuer_id
if user and not user.is_authenticated: if user and not user.is_authenticated:
user = None user = None
+1 -25
View File
@@ -40,7 +40,6 @@ from i18nfield.fields import I18nCharField
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned from pretix.base.banlist import banned
from pretix.base.i18n import language
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField from pretix.base.models.fields import MultiStringField
from pretix.base.models.giftcards import GiftCardTransaction from pretix.base.models.giftcards import GiftCardTransaction
@@ -165,28 +164,6 @@ class Customer(LoggedModel):
self.attendee_profiles.all().delete() self.attendee_profiles.all().delete()
self.invoice_addresses.all().delete() self.invoice_addresses.all().delete()
def send_security_notice(self, message, email=None):
from pretix.base.services.mail import SendMailException, mail
from pretix.multidomain.urlreverse import build_absolute_uri
try:
with language(self.locale):
mail(
email or self.email,
self.organizer.settings.mail_subject_customer_security_notice,
self.organizer.settings.mail_text_customer_security_notice,
{
**self.get_email_context(),
'message': str(message),
'url': build_absolute_uri(self.organizer, 'presale:organizer.customer.index')
},
customer=self,
organizer=self.organizer,
locale=self.locale
)
except SendMailException:
pass # Already logged
@scopes_disabled() @scopes_disabled()
def assign_identifier(self): def assign_identifier(self):
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789')
@@ -316,7 +293,6 @@ class Customer(LoggedModel):
locale=self.locale, locale=self.locale,
customer=self, customer=self,
organizer=self.organizer, organizer=self.organizer,
sensitive=True,
) )
def usable_gift_cards(self, used_cards=[]): def usable_gift_cards(self, used_cards=[]):
@@ -373,7 +349,7 @@ class AttendeeProfile(models.Model):
def state_name(self): def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state)) sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd: if sd:
return _(sd.name) return sd.name
return self.state return self.state
@property @property
+1 -1
View File
@@ -86,7 +86,7 @@ class OrderSyncQueue(models.Model):
def set_sync_error(self, failure_mode, messages, full_message): def set_sync_error(self, failure_mode, messages, full_message):
logger.exception( logger.exception(
f"Could not sync order {self.order.code} to {self.sync_provider} ({failure_mode})" f"Could not sync order {self.order.code} to {type(self).__name__} ({failure_mode})"
) )
self.order.log_action(f"pretix.event.order.data_sync.failed.{failure_mode}", { self.order.log_action(f"pretix.event.order.data_sync.failed.{failure_mode}", {
"provider": self.sync_provider, "provider": self.sync_provider,
+5 -41
View File
@@ -37,7 +37,7 @@ from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
PositionInfo = namedtuple('PositionInfo', PositionInfo = namedtuple('PositionInfo',
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to', ['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'is_addon_to',
'voucher_discount']) 'voucher_discount'])
@@ -279,42 +279,6 @@ class Discount(LoggedModel):
for idx in condition_idx_group: for idx in condition_idx_group:
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)] 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): 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: if len(condition_idx_group) < self.condition_min_count:
return return
@@ -324,8 +288,8 @@ class Discount(LoggedModel):
if self.benefit_only_apply_to_cheapest_n_matches: if self.benefit_only_apply_to_cheapest_n_matches:
# sort by line_price # sort by line_price
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx)) 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, self._addon_idx(positions, idx), -idx)) benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only # Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3 # want to match multiples of 3
@@ -470,7 +434,7 @@ class Discount(LoggedModel):
for idx, p in positions.items(): for idx, p in positions.items():
subevent_to_idx[p.subevent_id].append(idx) subevent_to_idx[p.subevent_id].append(idx)
for v in subevent_to_idx.values(): for v in subevent_to_idx.values():
v.sort(key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx))) v.sort(key=lambda idx: positions[idx].line_price_gross)
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True) 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 # Build groups of exactly condition_min_count distinct subevents
@@ -494,7 +458,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 # 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 # and 2 from the end" scheme to optimize price distribution among groups
candidates = sorted(candidates, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx))) candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross)
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0): if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
candidate = candidates[0] candidate = candidates[0]
else: else:
+4 -5
View File
@@ -594,11 +594,10 @@ class Item(LoggedModel):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
verbose_name=_("Only show after sellout of"), 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 " help_text=_("If you select a product here, this product will only be shown when that product is "
"no longer available. This will happen either because the other product has sold out or because " "sold out. If combined with the option to hide sold-out products, this allows you to "
"the time is outside of the sales window for the other product. If combined with the option " "swap out products for more expensive ones once the cheaper option is sold out. There might "
"to hide sold-out products, this allows you to swap out products for more expensive ones once " "be a short period in which both products are visible while all tickets of the referenced "
"the cheaper option is sold out. There might be a short period in which both products are visible " "product are reserved, but not yet sold.")
"while all tickets of the referenced product are reserved, but not yet sold.")
) )
hidden_if_item_available_mode = models.CharField( hidden_if_item_available_mode = models.CharField(
choices=UNAVAIL_MODES, choices=UNAVAIL_MODES,
+1 -2
View File
@@ -141,9 +141,8 @@ class LogEntry(models.Model):
log_entry_type, meta = log_entry_types.get(action_type=self.action_type) log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
if log_entry_type: if log_entry_type:
sender = self.event if self.event else self.organizer
link_info = log_entry_type.get_object_link_info(self) link_info = log_entry_type.get_object_link_info(self)
if is_app_active(sender, meta['plugin']): if is_app_active(self.event, meta['plugin']):
return make_link(link_info, log_entry_type.object_link_wrapper) return make_link(link_info, log_entry_type.object_link_wrapper)
else: else:
return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False, return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False,
-239
View File
@@ -1,239 +0,0 @@
#
# 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/>.
#
import uuid
from django.core.mail import get_connection
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_scopes import scope, scopes_disabled
def CASCADE_IF_QUEUED(collector, field, sub_objs, using):
# If the email is still queued and the thing it is related to vanishes, the email can vanish as well
cascade_objs = [
o for o in sub_objs if o.status == OutgoingMail.STATUS_QUEUED
]
if cascade_objs:
models.CASCADE(collector, field, cascade_objs, using)
# In all other cases, set to NULL to keep the email on record
models.SET_NULL(collector, field, [o for o in sub_objs if o not in cascade_objs], using)
class OutgoingMail(models.Model):
STATUS_QUEUED = "queued"
STATUS_WITHHELD = "withheld"
STATUS_INFLIGHT = "inflight"
STATUS_AWAITING_RETRY = "awaiting_retry"
STATUS_FAILED = "failed"
STATUS_SENT = "sent"
STATUS_BOUNCED = "bounced"
STATUS_ABORTED = "aborted"
STATUS_CHOICES = (
(STATUS_QUEUED, _("queued")),
(STATUS_INFLIGHT, _("being sent")),
(STATUS_AWAITING_RETRY, _("awaiting retry")),
(STATUS_WITHHELD, _("withheld")), # for plugin use
(STATUS_FAILED, _("failed")),
(STATUS_ABORTED, _("aborted")),
(STATUS_SENT, _("sent")),
(STATUS_BOUNCED, _("bounced")), # for plugin use
)
STATUS_LIST_ABORTABLE = {
STATUS_QUEUED,
STATUS_WITHHELD,
STATUS_AWAITING_RETRY,
}
STATUS_LIST_RETRYABLE = {
STATUS_FAILED,
STATUS_WITHHELD,
}
# The GUID is a globally unique ID for the email added to a header of the email for later tracing
# in bug reports etc. We could theoretically also use this as a basis for the Message-ID header, but
# we currently don't since we are unsure if some intermediary SMTP servers have opinions on setting
# their own Message-ID headers.
guid = models.UUIDField(db_index=True, default=uuid.uuid4)
status = models.CharField(max_length=200, choices=STATUS_CHOICES, default=STATUS_QUEUED)
created = models.DateTimeField(auto_now_add=True)
# sent will be the time the email was sent or the email failed
sent = models.DateTimeField(null=True, blank=True)
inflight_since = models.DateTimeField(null=True, blank=True)
retry_after = models.DateTimeField(null=True, blank=True)
error = models.TextField(null=True, blank=True)
error_detail = models.TextField(null=True, blank=True)
# There is a conflict here between the different purposes of the model. As a system administrator,
# one wants *all* emails to be persisted as long as possible to debug issues. This means that if
# e.g. the event or order is deleted, we want SET_NULL behavior. However, in that case, the email
# would be an "orphan" forever and there's no way to remove the personal information.
# We try to find a middle-ground with the following behaviour:
# - The email is always deleted if the entire organizer or user is deleted
# - The email is always deleted if it has not yet been sent
# - The email is kept in all other cases
# This is only an acceptable trade-off since emails are stored for a short period only, and because
# orders and customers are never deleted during normal operation. If we ever make this a long-term
# storage / email archive, we'd need to find another way to make sure personal information is removed
# if personal information of orders etc is removed.
organizer = models.ForeignKey(
'pretixbase.Organizer',
on_delete=models.CASCADE,
related_name='outgoing_mails',
null=True, blank=True,
)
event = models.ForeignKey(
'pretixbase.Event',
on_delete=CASCADE_IF_QUEUED,
related_name='outgoing_mails',
null=True, blank=True,
)
order = models.ForeignKey(
'pretixbase.Order',
on_delete=CASCADE_IF_QUEUED,
related_name='outgoing_mails',
null=True, blank=True,
)
orderposition = models.ForeignKey(
'pretixbase.OrderPosition',
on_delete=CASCADE_IF_QUEUED,
related_name='outgoing_mails',
null=True, blank=True,
)
customer = models.ForeignKey(
'pretixbase.Customer',
on_delete=CASCADE_IF_QUEUED,
related_name='outgoing_mails',
null=True, blank=True,
)
user = models.ForeignKey(
'pretixbase.User',
on_delete=models.CASCADE,
related_name='outgoing_mails',
null=True, blank=True,
)
sensitive = models.BooleanField(default=False)
subject = models.TextField()
body_plain = models.TextField()
body_html = models.TextField(null=True)
sender = models.CharField(max_length=500)
headers = models.JSONField(default=dict)
to = models.JSONField(default=list)
cc = models.JSONField(default=list)
bcc = models.JSONField(default=list)
recipient_count = models.IntegerField()
# We don't store the actual invoices, tickets or calendar invites, so if the email is re-sent at a later time, a
# newer version of the files might be used. We accept that risk to save on storage and also because the new
# version might actually be more useful.
should_attach_invoices = models.ManyToManyField(
'pretixbase.Invoice',
related_name='outgoing_mails'
)
should_attach_tickets = models.BooleanField(default=False)
should_attach_ical = models.BooleanField(default=False)
# clean_cached_files makes sure not to delete these as long as the email is in a retryable state
should_attach_cached_files = models.ManyToManyField(
'pretixbase.CachedFile',
related_name='outgoing_mails',
)
# This is used to send files stored in settings. In most cases, these aren't short-lived and should still be there
# if the email is sent. Otherwise, they will be skipped. We accept that risk.
should_attach_other_files = models.JSONField(default=list)
# [{name, type size}] of the attachments we actually setn
actual_attachments = models.JSONField(default=list)
class Meta:
ordering = ('-created',)
def get_mail_backend(self):
if self.event:
return self.event.get_mail_backend()
elif self.organizer:
return self.organizer.get_mail_backend()
else:
return get_connection(fail_silently=False)
def scope_manager(self):
if self.organizer:
return scope(organizer=self.organizer) # noqa
else:
return scopes_disabled() # noqa
@property
def is_failed(self):
return self.status in (
OutgoingMail.STATUS_FAILED,
OutgoingMail.STATUS_AWAITING_RETRY,
OutgoingMail.STATUS_BOUNCED,
)
def save(self, *args, **kwargs):
if self.orderposition_id and not self.order_id:
self.order = self.orderposition.order
if self.order_id and not self.event_id:
self.event = self.order.event
if self.event_id and not self.organizer_id:
self.organizer = self.event.organizer
if self.customer_id and not self.organizer_id:
self.organizer = self.customer.organizer
self.recipient_count = len(self.to) + len(self.cc) + len(self.bcc)
super().save(*args, **kwargs)
def log_parameters(self):
if self.order:
error_log_action_type = 'pretix.event.order.email.error'
log_target = self.order
elif self.customer:
error_log_action_type = 'pretix.customer.email.error'
log_target = self.customer
elif self.user:
error_log_action_type = 'pretix.user.email.error'
log_target = self.user
else:
error_log_action_type = 'pretix.email.error'
log_target = None
return log_target, error_log_action_type
def log_data(self):
return {
"subject": self.subject,
"message": self.body_plain,
"to": self.to,
"cc": self.cc,
"bcc": self.bcc,
"invoices": [i.pk for i in self.should_attach_invoices.all()],
"attach_tickets": self.should_attach_tickets,
"attach_ical": self.should_attach_ical,
"attach_other_files": self.should_attach_other_files,
"attach_cached_files": [cf.filename for cf in self.should_attach_cached_files.all()],
"position": self.orderposition.positionid if self.orderposition else None,
}
+92 -40
View File
@@ -87,6 +87,7 @@ from pretix.base.timemachine import time_machine_now
from ...helpers import OF_SELF from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.format import format_map
from ...helpers.names import build_name from ...helpers.names import build_name
from ...testutils.middleware import debugflags_var from ...testutils.middleware import debugflags_var
from ._transactions import ( from ._transactions import (
@@ -1166,7 +1167,9 @@ class Order(LockModel, LoggedModel):
only be attached for this position and child positions, the link will only point to the only be attached for this position and child positions, the link will only point to the
position and the attendee email will be used if available. position and the attendee email will be used if available.
""" """
from pretix.base.services.mail import mail from pretix.base.services.mail import (
SendMailException, mail, render_mail,
)
if not self.email and not (position and position.attendee_email): if not self.email and not (position and position.attendee_email):
return return
@@ -1176,19 +1179,34 @@ class Order(LockModel, LoggedModel):
if position and position.attendee_email: if position and position.attendee_email:
recipient = position.attendee_email recipient = position.attendee_email
outgoing_mail = mail( try:
recipient, subject, template, context, email_content = render_mail(template, context)
self.event, self.locale, self, headers=headers, sender=sender, subject = format_map(subject, context)
invoices=invoices, attach_tickets=attach_tickets, mail(
position=position, auto_email=auto_email, attach_ical=attach_ical, recipient, subject, template, context,
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files, self.event, self.locale, self, headers=headers, sender=sender,
) invoices=invoices, attach_tickets=attach_tickets,
if outgoing_mail: position=position, auto_email=auto_email, attach_ical=attach_ical,
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
)
except SendMailException:
raise
else:
self.log_action( self.log_action(
log_entry_type, log_entry_type,
user=user, user=user,
auth=auth, auth=auth,
data=outgoing_mail.log_data(), data={
'subject': subject,
'message': email_content,
'position': position.positionid if position else None,
'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
'attach_ical': attach_ical,
'attach_other_files': attach_other_files,
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
}
) )
def resend_link(self, user=None, auth=None): def resend_link(self, user=None, auth=None):
@@ -1657,7 +1675,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
def state_name(self): def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state)) sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd: if sd:
return _(sd.name) return sd.name
return self.state return self.state
@property @property
@@ -2006,30 +2024,40 @@ class OrderPayment(models.Model):
transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False)) transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False))
def _send_paid_mail_attendee(self, position, user): def _send_paid_mail_attendee(self, position, user):
from pretix.base.services.mail import SendMailException
with language(self.order.locale, self.order.event.settings.region): with language(self.order.locale, self.order.event.settings.region):
email_template = self.order.event.settings.mail_text_order_paid_attendee email_template = self.order.event.settings.mail_text_order_paid_attendee
email_subject = self.order.event.settings.mail_subject_order_paid_attendee email_subject = self.order.event.settings.mail_subject_order_paid_attendee
email_context = get_email_context(event=self.order.event, order=self.order, position=position) email_context = get_email_context(event=self.order.event, order=self.order, position=position)
position.send_mail( try:
email_subject, email_template, email_context, position.send_mail(
'pretix.event.order.email.order_paid', user, email_subject, email_template, email_context,
invoices=[], 'pretix.event.order.email.order_paid', user,
attach_tickets=True, invoices=[],
attach_ical=self.order.event.settings.mail_attach_ical attach_tickets=True,
) attach_ical=self.order.event.settings.mail_attach_ical
)
except SendMailException:
logger.exception('Order paid email could not be sent')
def _send_paid_mail(self, invoice, user, mail_text): def _send_paid_mail(self, invoice, user, mail_text):
from pretix.base.services.mail import SendMailException
with language(self.order.locale, self.order.event.settings.region): with language(self.order.locale, self.order.event.settings.region):
email_template = self.order.event.settings.mail_text_order_paid email_template = self.order.event.settings.mail_text_order_paid
email_subject = self.order.event.settings.mail_subject_order_paid email_subject = self.order.event.settings.mail_subject_order_paid
email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text) email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
self.order.send_mail( try:
email_subject, email_template, email_context, self.order.send_mail(
'pretix.event.order.email.order_paid', user, email_subject, email_template, email_context,
invoices=[invoice] if invoice else [], 'pretix.event.order.email.order_paid', user,
attach_tickets=True, invoices=[invoice] if invoice else [],
attach_ical=self.order.event.settings.mail_attach_ical attach_tickets=True,
) attach_ical=self.order.event.settings.mail_attach_ical
)
except SendMailException:
logger.exception('Order paid email could not be sent')
@property @property
def refunded_amount(self): def refunded_amount(self):
@@ -2887,28 +2915,44 @@ class OrderPosition(AbstractPosition):
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download :param attach_tickets: Attach tickets of this order, if they are existing and ready to download
:param attach_ical: Attach relevant ICS files :param attach_ical: Attach relevant ICS files
""" """
from pretix.base.services.mail import mail from pretix.base.services.mail import (
SendMailException, mail, render_mail,
)
if not self.attendee_email: if not self.attendee_email:
return return
with language(self.order.locale, self.order.event.settings.region): with language(self.order.locale, self.order.event.settings.region):
recipient = self.attendee_email recipient = self.attendee_email
outgoing_mail = mail( try:
recipient, subject, template, context, email_content = render_mail(template, context)
self.event, self.order.locale, order=self.order, headers=headers, sender=sender, subject = format_map(subject, context)
position=self, mail(
invoices=invoices, recipient, subject, template, context,
attach_tickets=attach_tickets, self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
attach_ical=attach_ical, position=self,
attach_other_files=attach_other_files, invoices=invoices,
) attach_tickets=attach_tickets,
if outgoing_mail: attach_ical=attach_ical,
attach_other_files=attach_other_files,
)
except SendMailException:
raise
else:
self.order.log_action( self.order.log_action(
log_entry_type, log_entry_type,
user=user, user=user,
auth=auth, auth=auth,
data=outgoing_mail.log_data(), data={
'subject': subject,
'message': email_content,
'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
'attach_ical': attach_ical,
'attach_other_files': attach_other_files,
'attach_cached_files': [],
}
) )
def resend_link(self, user=None, auth=None): def resend_link(self, user=None, auth=None):
@@ -3436,7 +3480,7 @@ class InvoiceAddress(models.Model):
def state_name(self): def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state)) sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd: if sd:
return _(sd.name) return sd.name
return self.state return self.state
@property @property
@@ -3485,10 +3529,18 @@ class InvoiceAddress(models.Model):
def describe_transmission(self): def describe_transmission(self):
from pretix.base.invoicing.transmission import transmission_types from pretix.base.invoicing.transmission import transmission_types
data = [] data = []
t, __ = transmission_types.get(identifier=self.transmission_type) t, __ = transmission_types.get(identifier=self.transmission_type)
data.append((_("Transmission type"), t.public_name)) data.append((_("Transmission type"), t.public_name))
if self.transmission_info: form_data = t.transmission_info_to_form_data(self.transmission_info or {})
data += t.describe_info(self.transmission_info, self.country, self.is_business) for k, f in t.invoice_address_form_fields.items():
v = form_data.get(k)
if v is True:
v = _("Yes")
elif v is False:
v = _("No")
if v:
data.append((f.label, v))
return data return data
+1 -2
View File
@@ -22,6 +22,7 @@
import json import json
from collections import namedtuple from collections import namedtuple
import jsonschema
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@@ -37,8 +38,6 @@ from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent
@deconstructible @deconstructible
class SeatingPlanLayoutValidator: class SeatingPlanLayoutValidator:
def __call__(self, value): def __call__(self, value):
import jsonschema
if not isinstance(value, dict): if not isinstance(value, dict):
try: try:
val = json.loads(value) val = json.loads(value)
+1 -2
View File
@@ -23,6 +23,7 @@ import json
from decimal import Decimal from decimal import Decimal
from typing import Optional from typing import Optional
import jsonschema
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@@ -297,8 +298,6 @@ def cc_to_vat_prefix(country_code):
@deconstructible @deconstructible
class CustomRulesValidator: class CustomRulesValidator:
def __call__(self, value): def __call__(self, value):
import jsonschema
if not isinstance(value, dict): if not isinstance(value, dict):
try: try:
val = json.loads(value) val = json.loads(value)
+3 -3
View File
@@ -239,7 +239,7 @@ class Voucher(LoggedModel):
) )
) )
price_mode = models.CharField( price_mode = models.CharField(
verbose_name=_("Price effect"), verbose_name=_("Price mode"),
max_length=100, max_length=100,
choices=PRICE_MODES, choices=PRICE_MODES,
default='none' default='none'
@@ -623,7 +623,7 @@ class Voucher(LoggedModel):
return max(1, self.min_usages - self.redeemed) return max(1, self.min_usages - self.redeemed)
@classmethod @classmethod
def annotate_budget_used(cls, qs): def annotate_budget_used_orders(cls, qs):
opq = OrderPosition.objects.filter( opq = OrderPosition.objects.filter(
voucher_id=OuterRef('pk'), voucher_id=OuterRef('pk'),
voucher_budget_use__isnull=False, voucher_budget_use__isnull=False,
@@ -632,7 +632,7 @@ class Voucher(LoggedModel):
Order.STATUS_PENDING Order.STATUS_PENDING
] ]
).order_by().values('voucher_id').annotate(s=Sum('voucher_budget_use')).values('s') ).order_by().values('voucher_id').annotate(s=Sum('voucher_budget_use')).values('s')
return qs.annotate(budget_used=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=13, decimal_places=2)), Decimal('0.00'))) return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=13, decimal_places=2)), Decimal('0.00')))
def budget_used(self): def budget_used(self):
ops = OrderPosition.objects.filter( ops = OrderPosition.objects.filter(
+44 -40
View File
@@ -34,9 +34,9 @@ from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.email import get_email_context from pretix.base.email import get_email_context
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import User, Voucher from pretix.base.models import User, Voucher
from pretix.base.services.mail import mail 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 from ...helpers.names import build_name
from .base import LoggedModel from .base import LoggedModel
from .event import Event, SubEvent from .event import Event, SubEvent
@@ -158,7 +158,6 @@ class WaitingListEntry(LoggedModel):
if availability[1] is None or availability[1] < 1: if availability[1] is None or availability[1] < 1:
raise WaitingListException(_('This product is currently not available.')) raise WaitingListException(_('This product is currently not available.'))
event = self.event
ev = self.subevent or self.event ev = self.subevent or self.event
if ev.seat_category_mappings.filter(product=self.item).exists(): 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 # Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous
@@ -180,56 +179,50 @@ class WaitingListEntry(LoggedModel):
block_quota=True, block_quota=True,
item_id=self.item_id, item_id=self.item_id,
subevent_id=self.subevent_id, subevent_id=self.subevent_id,
waitinglistentries__isnull=False, waitinglistentries__isnull=False
seat__isnull=True
).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0 ).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0
free_seats = num_free_seats_for_product - num_valid_vouchers_for_product free_seats = num_free_seats_for_product - num_valid_vouchers_for_product
if free_seats < 1: if not free_seats:
raise WaitingListException(_('No seat with this product is currently available.')) 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: if '@' not in self.email:
raise WaitingListException(_('This entry is anonymized and can no longer be used.')) raise WaitingListException(_('This entry is anonymized and can no longer be used.'))
with transaction.atomic(): with transaction.atomic():
locked_wle = WaitingListEntry.objects.select_for_update(of=OF_SELF).get(pk=self.pk) e = self.email
locked_wle.event = event if self.name:
if locked_wle.voucher: e += ' / ' + self.name
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( v = Voucher.objects.create(
event=locked_wle.event, event=self.event,
max_usages=1, max_usages=1,
valid_until=now() + timedelta(hours=locked_wle.event.settings.waiting_list_hours), valid_until=now() + timedelta(hours=self.event.settings.waiting_list_hours),
item=locked_wle.item, item=self.item,
variation=locked_wle.variation, variation=self.variation,
tag='waiting-list', tag='waiting-list',
comment=_('Automatically created from waiting list entry for {email}').format( comment=_('Automatically created from waiting list entry for {email}').format(
email=e email=e
), ),
block_quota=True, block_quota=True,
subevent=locked_wle.subevent, subevent=self.subevent,
) )
v.log_action('pretix.voucher.added', { v.log_action('pretix.voucher.added', {
'item': locked_wle.item.pk, 'item': self.item.pk,
'variation': locked_wle.variation.pk if locked_wle.variation else None, 'variation': self.variation.pk if self.variation else None,
'tag': 'waiting-list', 'tag': 'waiting-list',
'block_quota': True, 'block_quota': True,
'valid_until': v.valid_until.isoformat(), 'valid_until': v.valid_until.isoformat(),
'max_usages': 1, 'max_usages': 1,
'subevent': locked_wle.subevent.pk if locked_wle.subevent else None, 'subevent': self.subevent.pk if self.subevent else None,
'source': 'waitinglist', 'source': 'waitinglist',
}, user=user, auth=auth) }, user=user, auth=auth)
v.log_action('pretix.voucher.added.waitinglist', { v.log_action('pretix.voucher.added.waitinglist', {
'email': locked_wle.email, 'email': self.email,
'waitinglistentry': locked_wle.pk, 'waitinglistentry': self.pk,
}, user=user, auth=auth) }, user=user, auth=auth)
locked_wle.voucher = v self.voucher = v
locked_wle.save() self.save()
self.refresh_from_db()
self.event = event
with language(self.locale, self.event.settings.region): with language(self.locale, self.event.settings.region):
self.send_mail( self.send_mail(
@@ -272,22 +265,33 @@ class WaitingListEntry(LoggedModel):
with language(self.locale, self.event.settings.region): with language(self.locale, self.event.settings.region):
recipient = self.email recipient = self.email
outgoing_mail = mail( try:
recipient, subject, template, context, email_content = render_mail(template, context)
self.event, subject = format_map(subject, context)
self.locale, mail(
headers=headers, recipient, subject, template, context,
sender=sender, self.event,
auto_email=auto_email, self.locale,
attach_other_files=attach_other_files, headers=headers,
attach_cached_files=attach_cached_files, sender=sender,
) auto_email=auto_email,
if outgoing_mail: attach_other_files=attach_other_files,
attach_cached_files=attach_cached_files,
)
except SendMailException:
raise
else:
self.log_action( self.log_action(
log_entry_type, log_entry_type,
user=user, user=user,
auth=auth, auth=auth,
data=outgoing_mail.log_data(), data={
'subject': subject,
'message': email_content,
'recipient': recipient,
'attach_other_files': attach_other_files,
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
}
) )
@staticmethod @staticmethod
+2 -20
View File
@@ -1231,8 +1231,8 @@ class ManualPayment(BasePaymentProvider):
def is_allowed(self, request: HttpRequest, total: Decimal=None): def is_allowed(self, request: HttpRequest, total: Decimal=None):
return 'pretix.plugins.manualpayment' in self.event.plugins and super().is_allowed(request, total) return 'pretix.plugins.manualpayment' in self.event.plugins and super().is_allowed(request, total)
def order_change_allowed(self, order: Order, request=None): def order_change_allowed(self, order: Order):
return 'pretix.plugins.manualpayment' in self.event.plugins and super().order_change_allowed(order, request) return 'pretix.plugins.manualpayment' in self.event.plugins and super().order_change_allowed(order)
@property @property
def public_name(self): def public_name(self):
@@ -1295,7 +1295,6 @@ class ManualPayment(BasePaymentProvider):
def format_map(self, order, payment): def format_map(self, order, payment):
return { return {
# Possible placeholder injection, we should make sure to never include user-controlled variables here
'order': order.code, 'order': order.code,
'amount': payment.amount, 'amount': payment.amount,
'currency': self.event.currency, 'currency': self.event.currency,
@@ -1647,14 +1646,6 @@ class GiftCardPayment(BasePaymentProvider):
'transaction_id': trans.pk, 'transaction_id': trans.pk,
} }
payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case) payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case)
gc.log_action(
action='pretix.giftcards.transaction.payment',
data={
'value': trans.value,
'acceptor_id': self.event.organizer.id,
'acceptor_slug': self.event.organizer.slug
}
)
except PaymentException as e: except PaymentException as e:
payment.fail(info={'error': str(e)}) payment.fail(info={'error': str(e)})
raise e raise e
@@ -1679,15 +1670,6 @@ class GiftCardPayment(BasePaymentProvider):
'transaction_id': trans.pk, 'transaction_id': trans.pk,
} }
refund.done() refund.done()
gc.log_action(
action='pretix.giftcards.transaction.refund',
data={
'value': refund.amount,
'acceptor_id': self.event.organizer.id,
'acceptor_slug': self.event.organizer.slug,
'text': refund.comment,
}
)
@receiver(register_payment_providers, dispatch_uid="payment_free") @receiver(register_payment_providers, dispatch_uid="payment_free")
+12 -13
View File
@@ -47,6 +47,7 @@ from collections import OrderedDict, defaultdict
from functools import partial from functools import partial
from io import BytesIO from io import BytesIO
import jsonschema
import pypdf import pypdf
import pypdf.generic import pypdf.generic
import reportlab.rl_config import reportlab.rl_config
@@ -71,7 +72,9 @@ from reportlab.lib.colors import Color
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.lib.styles import ParagraphStyle from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.pdfmetrics import getAscentDescent from reportlab.pdfbase.pdfmetrics import getAscentDescent
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph from reportlab.platypus import Paragraph
@@ -82,9 +85,7 @@ from pretix.base.signals import layout_image_variables, layout_text_variables
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format from pretix.base.templatetags.phone_format import phone_format
from pretix.helpers.daterange import datetimerange from pretix.helpers.daterange import datetimerange
from pretix.helpers.reportlab import ( from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper
ThumbnailingImageReader, register_ttf_font_if_new, reshaper,
)
from pretix.presale.style import get_fonts from pretix.presale.style import get_fonts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -794,19 +795,19 @@ class Renderer:
def _register_fonts(cls, event: Event = None): def _register_fonts(cls, event: Event = None):
if hasattr(cls, '_fonts_registered'): if hasattr(cls, '_fonts_registered'):
return return
register_ttf_font_if_new('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')) pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
register_ttf_font_if_new('Open Sans I', finders.find('fonts/OpenSans-Italic.ttf')) pdfmetrics.registerFont(TTFont('Open Sans I', finders.find('fonts/OpenSans-Italic.ttf')))
register_ttf_font_if_new('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')) pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')))
register_ttf_font_if_new('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')) pdfmetrics.registerFont(TTFont('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')))
for family, styles in get_fonts(event, pdf_support_required=True).items(): for family, styles in get_fonts(event, pdf_support_required=True).items():
register_ttf_font_if_new(family, finders.find(styles['regular']['truetype'])) pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
if 'italic' in styles: if 'italic' in styles:
register_ttf_font_if_new(family + ' I', finders.find(styles['italic']['truetype'])) pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
if 'bold' in styles: if 'bold' in styles:
register_ttf_font_if_new(family + ' B', finders.find(styles['bold']['truetype'])) pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
if 'bolditalic' in styles: if 'bolditalic' in styles:
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype'])) pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
cls._fonts_registered = True cls._fonts_registered = True
@@ -1310,8 +1311,6 @@ def _correct_page_media_box(page: pypdf.PageObject):
@deconstructible @deconstructible
class PdfLayoutValidator: class PdfLayoutValidator:
def __call__(self, value): def __call__(self, value):
import jsonschema
if not isinstance(value, dict): if not isinstance(value, dict):
try: try:
val = json.loads(value) val = json.loads(value)
+1 -1
View File
@@ -65,7 +65,7 @@ def get_all_plugins(*, event=None, organizer=None) -> List[type]:
if app.name in settings.PRETIX_PLUGINS_EXCLUDE: if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue continue
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT) level = getattr(app, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT: if level == PLUGIN_LEVEL_EVENT:
if event and hasattr(app, 'is_available'): if event and hasattr(app, 'is_available'):
if not app.is_available(event): if not app.is_available(event):
+32 -20
View File
@@ -36,7 +36,7 @@ from pretix.base.models import (
SubEvent, TaxRule, User, WaitingListEntry, SubEvent, TaxRule, User, WaitingListEntry,
) )
from pretix.base.services.locking import LockTimeoutException from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import mail from pretix.base.services.mail import SendMailException, mail
from pretix.base.services.orders import ( from pretix.base.services.orders import (
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund, OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
) )
@@ -45,6 +45,7 @@ from pretix.base.services.tax import split_fee_for_taxes
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.format import format_map
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,14 +53,17 @@ logger = logging.getLogger(__name__)
def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent): def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent):
with language(wle.locale, wle.event.settings.region): with language(wle.locale, wle.event.settings.region):
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event) email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
mail( try:
wle.email, mail(
str(subject), wle.email,
message, format_map(subject, email_context),
email_context, message,
wle.event, email_context,
locale=wle.locale wle.event,
) locale=wle.locale
)
except SendMailException:
logger.exception('Waiting list canceled email could not be sent')
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent, def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
@@ -72,28 +76,36 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount, email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
order=order, position_or_address=ia, event=order.event) order=order, position_or_address=ia, event=order.event)
order.send_mail( real_subject = format_map(subject, email_context)
subject, message, email_context, try:
'pretix.event.order.email.event_canceled', order.send_mail(
user, real_subject, message, email_context,
) 'pretix.event.order.email.event_canceled',
user,
)
except SendMailException:
logger.exception('Order canceled email could not be sent')
for p in positions: for p in positions:
if subevent and p.subevent_id != subevent.id: if subevent and p.subevent_id != subevent.id:
continue continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email: if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
real_subject = format_map(subject, email_context)
email_context = get_email_context(event_or_subevent=p.subevent or order.event, email_context = get_email_context(event_or_subevent=p.subevent or order.event,
event=order.event, event=order.event,
refund_amount=refund_amount, refund_amount=refund_amount,
position_or_address=p, position_or_address=p,
order=order, position=p) order=order, position=p)
order.send_mail( try:
subject, message, email_context, order.send_mail(
'pretix.event.order.email.event_canceled', real_subject, message, email_context,
position=p, 'pretix.event.order.email.event_canceled',
user=user position=p,
) user=user
)
except SendMailException:
logger.exception('Order canceled email could not be sent to attendee')
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
+93 -211
View File
@@ -97,10 +97,6 @@ class CartError(Exception):
super().__init__(msg) super().__init__(msg)
class CartPositionError(CartError):
pass
error_messages = { error_messages = {
'busy': gettext_lazy( 'busy': gettext_lazy(
'We were not able to process your request completely as the ' 'We were not able to process your request completely as the '
@@ -110,9 +106,6 @@ error_messages = {
'unknown_position': gettext_lazy('Unknown cart position.'), 'unknown_position': gettext_lazy('Unknown cart position.'),
'subevent_required': pgettext_lazy('subevent', 'No date was specified.'), 'subevent_required': pgettext_lazy('subevent', 'No date was specified.'),
'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'), '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( 'unavailable': gettext_lazy(
'Some of the products you selected are no longer available. ' 'Some of the products you selected are no longer available. '
'Please see below for details.' 'Please see below for details.'
@@ -265,138 +258,6 @@ def _get_voucher_availability(event, voucher_use_diff, now_dt, exclude_position_
return vouchers_ok, _voucher_depend_on_cart 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: class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas', AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price', 'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
@@ -433,7 +294,6 @@ class CartManager:
self._widget_data = widget_data or {} self._widget_data = widget_data or {}
self._sales_channel = sales_channel self._sales_channel = sales_channel
self.num_extended_positions = 0 self.num_extended_positions = 0
self.price_change_for_extended = False
if reservation_time: if reservation_time:
self._reservation_time = reservation_time self._reservation_time = reservation_time
@@ -561,14 +421,14 @@ class CartManager:
if cartsize > limit: if cartsize > limit:
raise CartError(error_messages['max_items'] % limit) raise CartError(error_messages['max_items'] % limit)
def _check_item_constraints(self, op): def _check_item_constraints(self, op, current_ops=[]):
if isinstance(op, (self.AddOperation, self.ExtendOperation)): if isinstance(op, (self.AddOperation, self.ExtendOperation)):
if not ( if not (
(isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or (isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or
(isinstance(op, self.ExtendOperation) and op.position.is_bundled) (isinstance(op, self.ExtendOperation) and op.position.is_bundled)
): ):
if op.item.require_voucher and op.voucher is None: if op.item.require_voucher and op.voucher is None:
if getattr(op, 'voucher_ignored', False): # todo?? if getattr(op, 'voucher_ignored', False):
raise CartError(error_messages['voucher_redeemed']) raise CartError(error_messages['voucher_redeemed'])
raise CartError(error_messages['voucher_required']) raise CartError(error_messages['voucher_required'])
@@ -580,39 +440,88 @@ class CartManager:
raise CartError(error_messages['voucher_redeemed']) raise CartError(error_messages['voucher_redeemed'])
raise CartError(error_messages['voucher_required']) raise CartError(error_messages['voucher_required'])
if op.seat and op.count > 1: 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:
raise CartError('Invalid request: A seat can only be bought once.') raise CartError('Invalid request: A seat can only be bought once.')
if isinstance(op, self.AddOperation): if op.subevent:
is_addon = op.addon_to tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
is_bundled = op.addon_to == "FAKE" if tlv:
else: term_last = make_aware(datetime.combine(
is_addon = op.position.addon_to tlv.datetime(op.subevent).date(),
is_bundled = op.position.is_bundled 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'])
try: if isinstance(op, self.AddOperation):
_check_position_constraints( if op.item.category and op.item.category.is_addon and not (op.addon_to and op.addon_to != 'FAKE'):
event=self.event, raise CartError(error_messages['addon_only'])
item=op.item,
variation=op.variation, if op.item.require_bundling and not op.addon_to == 'FAKE':
voucher=op.voucher, raise CartError(error_messages['bundled_only'])
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], def _get_price(self, item: Item, variation: Optional[ItemVariation],
voucher: Optional[Voucher], custom_price: Optional[Decimal], voucher: Optional[Voucher], custom_price: Optional[Decimal],
@@ -632,7 +541,7 @@ class CartManager:
else: else:
raise e raise e
def _extend_expired_positions(self): def extend_expired_positions(self):
requires_seat = Exists( requires_seat = Exists(
SeatCategoryMapping.objects.filter( SeatCategoryMapping.objects.filter(
Q(product=OuterRef('item')) Q(product=OuterRef('item'))
@@ -695,14 +604,10 @@ class CartManager:
quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price, quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price,
price_after_voucher=price_after_voucher, price_after_voucher=price_after_voucher,
) )
try: self._check_item_constraints(op)
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: if cp.voucher:
self._voucher_use_diff[cp.voucher] += 1 self._voucher_use_diff[cp.voucher] += 2
self._operations.append(op) self._operations.append(op)
return err return err
@@ -892,7 +797,7 @@ class CartManager:
custom_price_input_is_net=False, custom_price_input_is_net=False,
voucher_ignored=False, voucher_ignored=False,
) )
self._check_item_constraints(bop) self._check_item_constraints(bop, operations)
bundled.append(bop) bundled.append(bop)
listed_price = get_listed_price(item, variation, subevent) listed_price = get_listed_price(item, variation, subevent)
@@ -931,7 +836,7 @@ class CartManager:
custom_price_input_is_net=self.event.settings.display_net_prices, custom_price_input_is_net=self.event.settings.display_net_prices,
voucher_ignored=voucher_ignored, voucher_ignored=voucher_ignored,
) )
self._check_item_constraints(op) self._check_item_constraints(op, operations)
operations.append(op) operations.append(op)
self._quota_diff.update(quota_diff) self._quota_diff.update(quota_diff)
@@ -1070,7 +975,7 @@ class CartManager:
custom_price_input_is_net=self.event.settings.display_net_prices, custom_price_input_is_net=self.event.settings.display_net_prices,
voucher_ignored=False, voucher_ignored=False,
) )
self._check_item_constraints(op) self._check_item_constraints(op, operations)
operations.append(op) operations.append(op)
# Check constraints on the add-on combinations # Check constraints on the add-on combinations
@@ -1267,9 +1172,7 @@ class CartManager:
op.position.delete() op.position.delete()
elif isinstance(op, (self.AddOperation, self.ExtendOperation)): elif isinstance(op, (self.AddOperation, self.ExtendOperation)):
if isinstance(op, self.ExtendOperation) and (op.position.pk in deleted_positions or not op.position.pk): # Create a CartPosition for as much items as we can
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 requested_count = quota_available_count = voucher_available_count = op.count
if op.seat: if op.seat:
@@ -1440,8 +1343,6 @@ class CartManager:
addons.delete() addons.delete()
op.position.delete() op.position.delete()
elif available_count == 1: 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.expires = self._expiry
op.position.max_extend = self._max_expiry_extend op.position.max_extend = self._max_expiry_extend
op.position.listed_price = op.listed_price op.position.listed_price = op.listed_price
@@ -1460,11 +1361,6 @@ class CartManager:
deleted_positions.add(op.position.pk) deleted_positions.add(op.position.pk)
addons.delete() addons.delete()
op.position.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: else:
raise AssertionError("ExtendOperation cannot affect more than one item") raise AssertionError("ExtendOperation cannot affect more than one item")
elif isinstance(op, self.VoucherOperation): elif isinstance(op, self.VoucherOperation):
@@ -1528,7 +1424,7 @@ class CartManager:
self._sales_channel.identifier, self._sales_channel.identifier,
[ [
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher) bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in positions for cp in positions
] ]
) )
@@ -1543,24 +1439,15 @@ class CartManager:
return diff 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): def commit(self):
self._check_presale_dates() self._check_presale_dates()
self._check_max_cart_size() self._check_max_cart_size()
err = self._delete_out_of_timeframe() 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() err = err or self._check_min_per_voucher()
self._extend_expiry_of_valid_existing_positions() self._extend_expiry_of_valid_existing_positions()
self._remove_parents_if_bundles_are_removed()
err = self._perform_operations() or err err = self._perform_operations() or err
self.recompute_final_prices_and_taxes() self.recompute_final_prices_and_taxes()
@@ -1639,7 +1526,7 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment
if fee.tax_rule and not fee.tax_rule.pk: if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate fee.tax_rule = None # TODO: deprecate
apply_rounding(event.settings.tax_rounding, invoice_address, event.currency, [*positions, *fees]) apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees])
if total != 0 and payments: if total != 0 and payments:
@@ -1679,7 +1566,7 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment
fees.append(pf) fees.append(pf)
# Re-apply rounding as grand total has changed # Re-apply rounding as grand total has changed
apply_rounding(event.settings.tax_rounding, invoice_address, event.currency, [*positions, *fees]) apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees])
# Re-calculate to_pay as grand total has changed # Re-calculate to_pay as grand total has changed
@@ -1816,12 +1703,7 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en',
try: try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm.commit() cm.commit()
return { return {"success": cm.num_extended_positions, "expiry": cm._expiry, "max_expiry_extend": cm._max_expiry_extend}
"success": cm.num_extended_positions,
"expiry": cm._expiry,
"max_expiry_extend": cm._max_expiry_extend,
"price_changed": cm.price_change_for_extended,
}
except LockTimeoutException: except LockTimeoutException:
self.retry() self.retry()
except (MaxRetriesExceededError, LockTimeoutException): except (MaxRetriesExceededError, LockTimeoutException):
+2 -14
View File
@@ -23,12 +23,11 @@ from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.db.models import Exists, OuterRef
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.models import CachedCombinedTicket, CachedTicket, OutgoingMail from pretix.base.models import CachedCombinedTicket, CachedTicket
from pretix.base.models.customers import CustomerSSOGrant from pretix.base.models.customers import CustomerSSOGrant
from ..models import CachedFile, CartPosition, InvoiceAddress from ..models import CachedFile, CartPosition, InvoiceAddress
@@ -50,18 +49,7 @@ def clean_cart_positions(sender, **kwargs):
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled() @scopes_disabled()
def clean_cached_files(sender, **kwargs): def clean_cached_files(sender, **kwargs):
has_queued_email = Exists( for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()):
OutgoingMail.objects.filter(
should_attach_cached_files__pk=OuterRef("pk"),
status__in=(
OutgoingMail.STATUS_QUEUED,
OutgoingMail.STATUS_INFLIGHT,
OutgoingMail.STATUS_AWAITING_RETRY,
OutgoingMail.STATUS_FAILED,
),
)
)
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()).exclude(has_queued_email):
cf.delete() cf.delete()
+1 -1
View File
@@ -121,7 +121,7 @@ class CrossSellingService:
self.sales_channel, self.sales_channel,
[ [
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled, bool(cp.addon_to), cp.is_bundled,
cp.listed_price - cp.price_after_voucher) cp.listed_price - cp.price_after_voucher)
for cp in self.cartpositions for cp in self.cartpositions
], ],
+3 -15
View File
@@ -51,7 +51,6 @@ from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.invoicing.pdf import InvoiceNotReadyException
from pretix.base.invoicing.transmission import ( from pretix.base.invoicing.transmission import (
get_transmission_types, transmission_providers, get_transmission_types, transmission_providers,
) )
@@ -505,7 +504,7 @@ def generate_invoice(order: Order, trigger_pdf=True):
return invoice return invoice
@app.task(base=TransactionAwareTask, throws=(InvoiceNotReadyException,)) @app.task(base=TransactionAwareTask)
def invoice_pdf_task(invoice: int): def invoice_pdf_task(invoice: int):
with scopes_disabled(): with scopes_disabled():
i = Invoice.objects.get(pk=invoice) i = Invoice.objects.get(pk=invoice)
@@ -522,20 +521,9 @@ def invoice_pdf_task(invoice: int):
def invoice_qualified(order: Order): def invoice_qualified(order: Order):
if order.total == Decimal('0.00'): if order.total == Decimal('0.00') or order.require_approval or \
order.sales_channel.identifier not in order.event.settings.get('invoice_generate_sales_channels'):
return False return False
if order.require_approval:
return False
if order.sales_channel.identifier not in order.event.settings.invoice_generate_sales_channels:
return False
if order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
return False
if order.event.settings.invoice_generate_only_business:
try:
ia = order.invoice_address
return ia.is_business
except InvoiceAddress.DoesNotExist:
return False
return True return True
File diff suppressed because it is too large Load Diff
+9 -23
View File
@@ -19,8 +19,6 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import uuid
import css_inline import css_inline
from django.conf import settings from django.conf import settings
from django.template.loader import get_template from django.template.loader import get_template
@@ -28,9 +26,7 @@ from django.utils.timezone import override
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import LogEntry, NotificationSetting, User
LogEntry, NotificationSetting, OutgoingMail, User,
)
from pretix.base.notifications import Notification, get_all_notification_types from pretix.base.notifications import Notification, get_all_notification_types
from pretix.base.services.mail import mail_send_task from pretix.base.services.mail import mail_send_task
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
@@ -157,26 +153,16 @@ def send_notification_mail(notification: Notification, user: User):
tpl_plain = get_template('pretixbase/email/notification.txt') tpl_plain = get_template('pretixbase/email/notification.txt')
body_plain = tpl_plain.render(ctx) body_plain = tpl_plain.render(ctx)
guid = uuid.uuid4() mail_send_task.apply_async(kwargs={
m = OutgoingMail.objects.create( 'to': [user.email],
guid=guid, 'subject': '[{}] {}: {}'.format(
user=user,
to=[user.email],
subject='[{}] {}: {}'.format(
settings.PRETIX_INSTANCE_NAME, settings.PRETIX_INSTANCE_NAME,
notification.event.settings.mail_prefix or notification.event.slug.upper(), notification.event.settings.mail_prefix or notification.event.slug.upper(),
notification.title notification.title
), ),
body_plain=body_plain, 'body': body_plain,
body_html=body_html, 'html': body_html,
sender=settings.MAIL_FROM_NOTIFICATIONS, 'sender': settings.MAIL_FROM_NOTIFICATIONS,
headers={ 'headers': {},
'X-Auto-Response-Suppress': 'OOF, NRN, AutoReply, RN', 'user': user.pk
'Auto-Submitted': 'auto-generated',
'X-Mailer': 'pretix',
'X-PX-Correlation': str(guid),
},
)
mail_send_task.apply_async(kwargs={
'outgoing_mail': m.pk,
}) })
+229 -253
View File
@@ -81,7 +81,7 @@ from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.payment import GiftCardPayment, PaymentException from pretix.base.payment import GiftCardPayment, PaymentException
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
from pretix.base.secrets import assign_ticket_secret from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import cart, tickets from pretix.base.services import tickets
from pretix.base.services.invoices import ( from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified, generate_cancellation, generate_invoice, invoice_qualified,
invoice_transmission_separately, order_invoice_transmission_separately, invoice_transmission_separately, order_invoice_transmission_separately,
@@ -90,6 +90,7 @@ from pretix.base.services.invoices import (
from pretix.base.services.locking import ( from pretix.base.services.locking import (
LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects, LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects,
) )
from pretix.base.services.mail import SendMailException
from pretix.base.services.memberships import ( from pretix.base.services.memberships import (
create_membership, validate_memberships_in_order, create_membership, validate_memberships_in_order,
) )
@@ -129,9 +130,6 @@ class OrderError(Exception):
error_messages = { 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( 'unavailable': gettext_lazy(
'Some of the products you selected were no longer available. ' 'Some of the products you selected were no longer available. '
'Please see below for details.' 'Please see below for details.'
@@ -184,6 +182,14 @@ 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.' '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.'), '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_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.'), '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.'), 'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'),
@@ -247,16 +253,6 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
for gc in position.issued_gift_cards.all(): for gc in position.issued_gift_cards.all():
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk) gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
gc.transactions.create(value=position.price, order=order, acceptor=order.event.organizer) gc.transactions.create(value=position.price, order=order, acceptor=order.event.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
user=user,
auth=auth,
data={
'value': position.price,
'acceptor_id': order.event.organizer.id,
'acceptor_slug': order.event.organizer.slug
}
)
break break
for m in position.granted_memberships.all(): for m in position.granted_memberships.all():
@@ -447,27 +443,33 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
email_attendee_subject = order.event.settings.mail_subject_order_approved_attendee email_attendee_subject = order.event.settings.mail_subject_order_approved_attendee
email_context = get_email_context(event=order.event, order=order) email_context = get_email_context(event=order.event, order=order)
order.send_mail( try:
email_subject, email_template, email_context, order.send_mail(
'pretix.event.order.email.order_approved', user, email_subject, email_template, email_context,
attach_tickets=True, 'pretix.event.order.email.order_approved', user,
attach_ical=order.event.settings.mail_attach_ical and ( attach_tickets=True,
not order.event.settings.mail_attach_ical_paid_only or attach_ical=order.event.settings.mail_attach_ical and (
order.total == Decimal('0.00') or not order.event.settings.mail_attach_ical_paid_only or
order.valid_if_pending order.total == Decimal('0.00') or
), order.valid_if_pending
invoices=[invoice] if invoice and transmit_invoice_mail else [] ),
) invoices=[invoice] if invoice and transmit_invoice_mail else []
)
except SendMailException:
logger.exception('Order approved email could not be sent')
if email_attendees: if email_attendees:
for p in order.positions.all(): for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email: if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
email_attendee_context = get_email_context(event=order.event, order=order, position=p) email_attendee_context = get_email_context(event=order.event, order=order, position=p)
p.send_mail( try:
email_attendee_subject, email_attendee_template, email_attendee_context, p.send_mail(
'pretix.event.order.email.order_approved', user, email_attendee_subject, email_attendee_template, email_attendee_context,
attach_tickets=True, 'pretix.event.order.email.order_approved', user,
) attach_tickets=True,
)
except SendMailException:
logger.exception('Order approved email could not be sent to attendee')
return order.pk return order.pk
@@ -504,10 +506,13 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
email_template = order.event.settings.mail_text_order_denied email_template = order.event.settings.mail_text_order_denied
email_subject = order.event.settings.mail_subject_order_denied email_subject = order.event.settings.mail_subject_order_denied
email_context = get_email_context(event=order.event, order=order, comment=comment) email_context = get_email_context(event=order.event, order=order, comment=comment)
order.send_mail( try:
email_subject, email_template, email_context, order.send_mail(
'pretix.event.order.email.order_denied', user email_subject, email_template, email_context,
) 'pretix.event.order.email.order_denied', user
)
except SendMailException:
logger.exception('Order denied email could not be sent')
return order.pk return order.pk
@@ -558,15 +563,6 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
) )
else: else:
gc.transactions.create(value=-position.price, order=order, acceptor=order.event.organizer) gc.transactions.create(value=-position.price, order=order, acceptor=order.event.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
user=user,
data={
'value': -position.price,
'acceptor_id': order.event.organizer.id,
'acceptor_slug': order.event.organizer.slug
}
)
for m in position.granted_memberships.all(): for m in position.granted_memberships.all():
m.canceled = True m.canceled = True
@@ -669,11 +665,14 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
email_template = order.event.settings.mail_text_order_canceled email_template = order.event.settings.mail_text_order_canceled
email_subject = order.event.settings.mail_subject_order_canceled email_subject = order.event.settings.mail_subject_order_canceled
email_context = get_email_context(event=order.event, order=order, comment=comment or "") email_context = get_email_context(event=order.event, order=order, comment=comment or "")
order.send_mail( try:
email_subject, email_template, email_context, order.send_mail(
'pretix.event.order.email.order_canceled', user, email_subject, email_template, email_context,
invoices=transmit_invoices_mail, 'pretix.event.order.email.order_canceled', user,
) invoices=transmit_invoices_mail,
)
except SendMailException:
logger.exception('Order canceled email could not be sent')
for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING)): for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING)):
try: try:
@@ -745,37 +744,12 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
deleted_positions.add(cp.pk) deleted_positions.add(cp.pk)
cp.delete() cp.delete()
sorted_positions = list(sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk))) sorted_positions = sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk))
for cp in sorted_positions: for cp in sorted_positions:
cp._cached_quotas = list(cp.quotas) 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 # 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): 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. # No need to perform any locking if the cart positions still guarantee everything long enough.
full_lock_required = any( full_lock_required = any(
@@ -800,12 +774,15 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
# Check availability # Check availability
for i, cp in enumerate(sorted_positions): for i, cp in enumerate(sorted_positions):
if cp.pk in deleted_positions or not cp.pk: if cp.pk in deleted_positions:
continue 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 quotas = cp._cached_quotas
# Product per order limits
products_seen[cp.item] += 1 products_seen[cp.item] += 1
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order: if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
err = error_messages['max_items_per_product'] % { err = error_messages['max_items_per_product'] % {
@@ -815,7 +792,6 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
delete(cp) delete(cp)
break break
# Voucher availability
if cp.voucher: if cp.voucher:
v_usages[cp.voucher] += 1 v_usages[cp.voucher] += 1
if cp.voucher not in v_avail: if cp.voucher not in v_avail:
@@ -830,14 +806,48 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
delete(cp) delete(cp)
continue continue
# Check duplicate seats in order if cp.subevent and cp.subevent.presale_start and time_machine_now_dt < cp.subevent.presale_start:
if cp.seat in seats_seen: err = err or error_messages['some_subevent_not_started']
err = err or error_messages['seat_invalid']
delete(cp) delete(cp)
break 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:
err = err or error_messages['seat_invalid']
delete(cp)
break
if cp.seat: if cp.seat:
seats_seen.add(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 # 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. # 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): if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel.identifier):
@@ -845,13 +855,34 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
delete(cp) delete(cp)
continue continue
# Check useful quota configuration if cp.expires >= now_dt and not cp.voucher:
# Other checks are not necessary
continue
if len(quotas) == 0: if len(quotas) == 0:
err = err or error_messages['unavailable'] err = err or error_messages['unavailable']
delete(cp) delete(cp)
continue 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 quota_ok = True
ignore_all_quotas = cp.expires >= now_dt or ( ignore_all_quotas = cp.expires >= now_dt or (
cp.voucher and ( cp.voucher and (
cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None) cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)
@@ -883,7 +914,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
}) })
# Check prices # Check prices
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
old_total = sum(cp.price for cp in sorted_positions) old_total = sum(cp.price for cp in sorted_positions)
for i, cp in enumerate(sorted_positions): for i, cp in enumerate(sorted_positions):
if cp.listed_price is None: if cp.listed_price is None:
@@ -914,13 +945,13 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
delete(cp) delete(cp)
continue continue
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
discount_results = apply_discounts( discount_results = apply_discounts(
event, event,
sales_channel.identifier, sales_channel.identifier,
[ [
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher) bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in sorted_positions for cp in sorted_positions
] ]
) )
@@ -974,7 +1005,7 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
fee.tax_rule = None # TODO: deprecate fee.tax_rule = None # TODO: deprecate
# Apply rounding to get final total in case no payment fees will be added # Apply rounding to get final total in case no payment fees will be added
apply_rounding(event.settings.tax_rounding, address, event.currency, [*positions, *fees]) apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees])
payments_assigned = Decimal("0.00") payments_assigned = Decimal("0.00")
@@ -1001,7 +1032,7 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
p['fee'] = pf p['fee'] = pf
# Re-apply rounding as grand total has changed # Re-apply rounding as grand total has changed
apply_rounding(event.settings.tax_rounding, address, event.currency, [*positions, *fees]) apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees])
# Re-calculate to_pay as grand total has changed # Re-calculate to_pay as grand total has changed
@@ -1114,40 +1145,46 @@ def _order_placed_email(event: Event, order: Order, email_template, subject_temp
log_entry: str, invoice, payments: List[OrderPayment], is_free=False): log_entry: str, invoice, payments: List[OrderPayment], is_free=False):
email_context = get_email_context(event=event, order=order, payments=payments) email_context = get_email_context(event=event, order=order, payments=payments)
order.send_mail( try:
subject_template, email_template, email_context, order.send_mail(
log_entry, subject_template, email_template, email_context,
invoices=[invoice] if invoice else [], log_entry,
attach_tickets=True, invoices=[invoice] if invoice else [],
attach_ical=event.settings.mail_attach_ical and ( attach_tickets=True,
not event.settings.mail_attach_ical_paid_only or attach_ical=event.settings.mail_attach_ical and (
is_free or not event.settings.mail_attach_ical_paid_only or
order.valid_if_pending is_free or
), order.valid_if_pending
attach_other_files=[a for a in [ ),
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):] attach_other_files=[a for a in [
] if a], event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
) ] if a],
)
except SendMailException:
logger.exception('Order received email could not be sent')
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, subject_template, def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, subject_template,
log_entry: str, is_free=False): log_entry: str, is_free=False):
email_context = get_email_context(event=event, order=order, position=position) email_context = get_email_context(event=event, order=order, position=position)
position.send_mail( try:
subject_template, email_template, email_context, position.send_mail(
log_entry, subject_template, email_template, email_context,
invoices=[], log_entry,
attach_tickets=True, invoices=[],
attach_ical=event.settings.mail_attach_ical and ( attach_tickets=True,
not event.settings.mail_attach_ical_paid_only or attach_ical=event.settings.mail_attach_ical and (
is_free or not event.settings.mail_attach_ical_paid_only or
order.valid_if_pending is_free or
), order.valid_if_pending
attach_other_files=[a for a in [ ),
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):] attach_other_files=[a for a in [
] if a], event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
) ] if a],
)
except SendMailException:
logger.exception('Order received email could not be sent to attendee')
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str], def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
@@ -1476,10 +1513,13 @@ def send_expiry_warnings(sender, **kwargs):
email_template = settings.mail_text_order_pending_warning email_template = settings.mail_text_order_pending_warning
email_subject = settings.mail_subject_order_pending_warning email_subject = settings.mail_subject_order_pending_warning
o.send_mail( try:
email_subject, email_template, email_context, o.send_mail(
'pretix.event.order.email.expire_warning_sent' email_subject, email_template, email_context,
) 'pretix.event.order.email.expire_warning_sent'
)
except SendMailException:
logger.exception('Reminder email could not be sent')
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@@ -1540,11 +1580,14 @@ def send_download_reminders(sender, **kwargs):
email_template = event.settings.mail_text_download_reminder email_template = event.settings.mail_text_download_reminder
email_subject = event.settings.mail_subject_download_reminder email_subject = event.settings.mail_subject_download_reminder
email_context = get_email_context(event=event, order=o) email_context = get_email_context(event=event, order=o)
o.send_mail( try:
email_subject, email_template, email_context, o.send_mail(
'pretix.event.order.email.download_reminder_sent', email_subject, email_template, email_context,
attach_tickets=True 'pretix.event.order.email.download_reminder_sent',
) attach_tickets=True
)
except SendMailException:
logger.exception('Reminder email could not be sent')
if event.settings.mail_send_download_reminder_attendee: if event.settings.mail_send_download_reminder_attendee:
for p in positions: for p in positions:
@@ -1558,11 +1601,14 @@ def send_download_reminders(sender, **kwargs):
email_template = event.settings.mail_text_download_reminder_attendee email_template = event.settings.mail_text_download_reminder_attendee
email_subject = event.settings.mail_subject_download_reminder_attendee email_subject = event.settings.mail_subject_download_reminder_attendee
email_context = get_email_context(event=event, order=o, position=p) email_context = get_email_context(event=event, order=o, position=p)
o.send_mail( try:
email_subject, email_template, email_context, o.send_mail(
'pretix.event.order.email.download_reminder_sent', email_subject, email_template, email_context,
attach_tickets=True, position=p 'pretix.event.order.email.download_reminder_sent',
) attach_tickets=True, position=p
)
except SendMailException:
logger.exception('Reminder email could not be sent to attendee')
def notify_user_changed_order(order, user=None, auth=None, invoices=[]): def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
@@ -1570,10 +1616,13 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
email_template = order.event.settings.mail_text_order_changed email_template = order.event.settings.mail_text_order_changed
email_context = get_email_context(event=order.event, order=order) email_context = get_email_context(event=order.event, order=order)
email_subject = order.event.settings.mail_subject_order_changed email_subject = order.event.settings.mail_subject_order_changed
order.send_mail( try:
email_subject, email_template, email_context, order.send_mail(
'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True, email_subject, email_template, email_context,
) 'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True,
)
except SendMailException:
logger.exception('Order changed email could not be sent')
class OrderChangeManager: class OrderChangeManager:
@@ -1618,7 +1667,7 @@ class OrderChangeManager:
MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership')) MembershipOperation = namedtuple('MembershipOperation', ('position', 'membership'))
CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff')) CancelOperation = namedtuple('CancelOperation', ('position', 'price_diff'))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership', AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat', 'membership',
'valid_from', 'valid_until', 'is_bundled', 'result')) 'valid_from', 'valid_until', 'is_bundled'))
SplitOperation = namedtuple('SplitOperation', ('position',)) SplitOperation = namedtuple('SplitOperation', ('position',))
FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff')) FeeValueOperation = namedtuple('FeeValueOperation', ('fee', 'value', 'price_diff'))
AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff')) AddFeeOperation = namedtuple('AddFeeOperation', ('fee', 'price_diff'))
@@ -1629,19 +1678,6 @@ class OrderChangeManager:
ChangeValidUntilOperation = namedtuple('ChangeValidUntilOperation', ('position', 'valid_until')) ChangeValidUntilOperation = namedtuple('ChangeValidUntilOperation', ('position', 'valid_until'))
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked')) AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked')) RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
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): def __init__(self, order: Order, user=None, auth=None, notify=True, reissue_invoice=True, allow_blocked_seats=False):
self.order = order self.order = order
@@ -1793,12 +1829,13 @@ class OrderChangeManager:
positions = self.order.positions.select_related('item', 'item__tax_rule') positions = self.order.positions.select_related('item', 'item__tax_rule')
ia = self._invoice_address ia = self._invoice_address
tax_rules = self._current_tax_rules() tax_rules = self._current_tax_rules()
self._operations.append(self.ForceRecomputeOperation())
for pos in positions: for pos in positions:
tax_rule = tax_rules.get(pos.pk, pos.tax_rule) tax_rule = tax_rules.get(pos.pk, pos.tax_rule)
if not tax_rule: if not tax_rule:
continue continue
if not pos.price:
continue
try: try:
new_rate = tax_rule.tax_rate_for(ia) new_rate = tax_rule.tax_rate_for(ia)
@@ -1815,9 +1852,7 @@ class OrderChangeManager:
override_tax_rate=new_rate, override_tax_code=new_code) override_tax_rate=new_rate, override_tax_code=new_code)
self._totaldiff_guesstimate += new_tax.gross - pos.price self._totaldiff_guesstimate += new_tax.gross - pos.price
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price)) self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
if pos.price: self._invoice_dirty = True
# We do not consider the invoice dirty if only 0€-valued taxes are changed
self._invoice_dirty = True
def cancel_fee(self, fee: OrderFee): def cancel_fee(self, fee: OrderFee):
self._totaldiff_guesstimate -= fee.value self._totaldiff_guesstimate -= fee.value
@@ -1848,7 +1883,7 @@ class OrderChangeManager:
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None, def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: OrderPosition = None,
subevent: SubEvent = None, seat: Seat = None, membership: Membership = None, subevent: SubEvent = None, seat: Seat = None, membership: Membership = None,
valid_from: datetime = None, valid_until: datetime = None) -> 'OrderChangeManager.AddPositionResult': valid_from: datetime = None, valid_until: datetime = None):
if isinstance(seat, str): if isinstance(seat, str):
if not seat: if not seat:
seat = None seat = None
@@ -1907,11 +1942,8 @@ class OrderChangeManager:
self._quotadiff.update(new_quotas) self._quotadiff.update(new_quotas)
if seat: if seat:
self._seatdiff.update([seat]) self._seatdiff.update([seat])
result = self.AddPositionResult()
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership, self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat, membership,
valid_from, valid_until, is_bundled, result)) valid_from, valid_until, is_bundled))
return result
def split(self, position: OrderPosition): def split(self, position: OrderPosition):
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
@@ -2084,43 +2116,6 @@ class OrderChangeManager:
) )
item_counts[item] += 1 item_counts[item] += 1
# Detect removed add-ons and create RemoveOperations
for cp, al in list(current_addons.items()):
for k, v in al.items():
input_num = input_addons[cp.id].get(k, 0)
current_num = len(current_addons[cp].get(k, []))
if input_num < current_num:
for a in current_addons[cp][k][:current_num - input_num]:
if a.canceled:
continue
is_unavailable = (
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
# not mean it should be unremovable for others.
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or (
not a.item.all_sales_channels and
not a.item.limit_sales_channels.contains(self.order.sales_channel)
)
)
if is_unavailable:
# "Re-select" add-on
selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1
continue
if a.checkins.filter(list__consider_tickets_used=True).exists():
raise OrderError(
error_messages['addon_already_checked_in'] % {
'addon': str(a.item.name),
}
)
self.cancel(a)
item_counts[a.item] -= 1
# Check constraints on the add-on combinations # Check constraints on the add-on combinations
for op in toplevel_op: for op in toplevel_op:
item = op.item item = op.item
@@ -2153,6 +2148,41 @@ class OrderChangeManager:
} }
) )
# Detect removed add-ons and create RemoveOperations
for cp, al in list(current_addons.items()):
for k, v in al.items():
input_num = input_addons[cp.id].get(k, 0)
current_num = len(current_addons[cp].get(k, []))
if input_num < current_num:
for a in current_addons[cp][k][:current_num - input_num]:
if a.canceled:
continue
is_unavailable = (
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
# not mean it should be unremovable for others.
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or (
not item.all_sales_channels and
not item.limit_sales_channels.contains(self.order.sales_channel)
)
)
if is_unavailable:
continue
if a.checkins.filter(list__consider_tickets_used=True).exists():
raise OrderError(
error_messages['addon_already_checked_in'] % {
'addon': str(a.item.name),
}
)
self.cancel(a)
item_counts[a.item] -= 1
for item, count in item_counts.items(): for item, count in item_counts.items():
if count == 0: if count == 0:
continue continue
@@ -2453,16 +2483,6 @@ class OrderChangeManager:
)) ))
else: else:
gc.transactions.create(value=-position.price, order=self.order, acceptor=self.order.event.organizer) gc.transactions.create(value=-position.price, order=self.order, acceptor=self.order.event.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
user=self.user,
auth=self.auth,
data={
'value': -position.price,
'acceptor_id': self.order.event.organizer.id,
'acceptor_slug': self.order.event.organizer.slug
}
)
for m in position.granted_memberships.with_usages().all(): for m in position.granted_memberships.with_usages().all():
m.canceled = True m.canceled = True
@@ -2480,16 +2500,6 @@ class OrderChangeManager:
)) ))
else: else:
gc.transactions.create(value=-opa.position.price, order=self.order, acceptor=self.order.event.organizer) gc.transactions.create(value=-opa.position.price, order=self.order, acceptor=self.order.event.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
user=self.user,
auth=self.auth,
data={
'value': -opa.position.price,
'acceptor_id': self.order.event.organizer.id,
'acceptor_slug': self.order.event.organizer.slug
}
)
for m in opa.granted_memberships.with_usages().all(): for m in opa.granted_memberships.with_usages().all():
m.canceled = True m.canceled = True
@@ -2552,7 +2562,6 @@ class OrderChangeManager:
'valid_from': op.valid_from.isoformat() if op.valid_from else None, 'valid_from': op.valid_from.isoformat() if op.valid_from else None,
'valid_until': op.valid_until.isoformat() if op.valid_until else None, 'valid_until': op.valid_until.isoformat() if op.valid_until else None,
}) })
op.result._position = pos
elif isinstance(op, self.SplitOperation): elif isinstance(op, self.SplitOperation):
position = position_cache.setdefault(op.position.pk, op.position) position = position_cache.setdefault(op.position.pk, op.position)
split_positions.append(position) split_positions.append(position)
@@ -2652,10 +2661,6 @@ class OrderChangeManager:
except BlockedTicketSecret.DoesNotExist: except BlockedTicketSecret.DoesNotExist:
pass pass
# todo: revoke list handling # todo: revoke list handling
elif isinstance(op, self.ForceRecomputeOperation):
self.order.log_action('pretix.event.order.changed.recomputed', user=self.user, auth=self.auth, data={})
else:
raise TypeError(f"Unknown operation {type(op)}")
for p in secret_dirty: for p in secret_dirty:
assign_ticket_secret( assign_ticket_secret(
@@ -2710,10 +2715,7 @@ class OrderChangeManager:
fees.append(new_fee) fees.append(new_fee)
changed_by_rounding = set(apply_rounding( changed_by_rounding = set(apply_rounding(
self.order.tax_rounding_mode, self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
self._invoice_address,
self.event.currency,
[p for p in split_positions if not p.canceled] + fees
)) ))
split_order.total = sum([p.price for p in split_positions if not p.canceled]) split_order.total = sum([p.price for p in split_positions if not p.canceled])
@@ -2735,10 +2737,7 @@ class OrderChangeManager:
fee.delete() fee.delete()
changed_by_rounding |= set(apply_rounding( changed_by_rounding |= set(apply_rounding(
self.order.tax_rounding_mode, self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees
self._invoice_address,
self.event.currency,
[p for p in split_positions if not p.canceled] + fees
)) ))
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees]) split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
@@ -2855,12 +2854,7 @@ class OrderChangeManager:
if fee_changed: if fee_changed:
fees = list(self.order.fees.all()) fees = list(self.order.fees.all())
changed = apply_rounding( changed = apply_rounding(self.order.tax_rounding_mode, self.order.event.currency, [*positions, *fees])
self.order.tax_rounding_mode,
self._invoice_address,
self.order.event.currency,
[*positions, *fees]
)
for l in changed: for l in changed:
if isinstance(l, OrderPosition): if isinstance(l, OrderPosition):
l.save(update_fields=[ l.save(update_fields=[
@@ -3158,10 +3152,7 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
customer=order.customer, customer=order.customer,
testmode=order.testmode testmode=order.testmode
) )
giftcard.log_action( giftcard.log_action('pretix.giftcards.created', data={})
action='pretix.giftcards.created',
data={}
)
r = order.refunds.create( r = order.refunds.create(
order=order, order=order,
payment=None, payment=None,
@@ -3299,12 +3290,8 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
positions = list(order.positions.all()) positions = list(order.positions.all())
fees = list(order.fees.all()) fees = list(order.fees.all())
try:
ia = order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
rounding_changed = set(apply_rounding( rounding_changed = set(apply_rounding(
order.tax_rounding_mode, ia, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]] order.tax_rounding_mode, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]]
)) ))
total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk) total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk)
pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid) pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid)
@@ -3329,7 +3316,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
fee = None fee = None
rounding_changed |= set(apply_rounding( rounding_changed |= set(apply_rounding(
order.tax_rounding_mode, ia, order.event.currency, [*positions, *fees] order.tax_rounding_mode, order.event.currency, [*positions, *fees]
)) ))
for l in rounding_changed: for l in rounding_changed:
if isinstance(l, OrderPosition): if isinstance(l, OrderPosition):
@@ -3448,18 +3435,7 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs):
currency=sender.currency, issued_in=p, testmode=order.testmode, currency=sender.currency, issued_in=p, testmode=order.testmode,
expires=sender.organizer.default_gift_card_expiry, expires=sender.organizer.default_gift_card_expiry,
) )
gc.log_action( gc.transactions.create(value=p.price - issued, order=order, acceptor=sender.organizer)
action='pretix.giftcards.created',
)
trans = gc.transactions.create(value=p.price - issued, order=order, acceptor=sender.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
data={
'value': trans.value,
'acceptor_id': order.event.organizer.id,
'acceptor_slug': order.event.organizer.slug
}
)
any_giftcards = True any_giftcards = True
p.secret = gc.secret p.secret = gc.secret
p.save(update_fields=['secret']) p.save(update_fields=['secret'])
+1 -12
View File
@@ -24,7 +24,6 @@ import logging
from datetime import timedelta from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from django.db.models import Prefetch, prefetch_related_objects
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape, mark_safe from django.utils.html import escape, mark_safe
@@ -36,7 +35,6 @@ from pretix.base.forms.widgets import format_placeholders_help_text
from pretix.base.i18n import ( from pretix.base.i18n import (
LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber, LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber,
) )
from pretix.base.models import EventMetaValue
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
from pretix.base.signals import ( from pretix.base.signals import (
@@ -754,11 +752,6 @@ def base_placeholders(sender, **kwargs):
name_scheme['sample'][f] name_scheme['sample'][f]
)) ))
prefetch_related_objects(
[sender],
Prefetch('meta_values', queryset=EventMetaValue.objects.select_related("property"), to_attr="meta_values_cached")
)
prefetch_related_objects([sender.organizer], Prefetch('meta_properties'))
for k, v in sender.meta_data.items(): for k, v in sender.meta_data.items():
ph.append(MarkdownTextPlaceholder( ph.append(MarkdownTextPlaceholder(
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k], 'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
@@ -808,11 +801,7 @@ def get_sample_context(event, context_parameters, rich=True):
sample = v.render_sample(event) sample = v.render_sample(event)
if isinstance(sample, PlainHtmlAlternativeString): if isinstance(sample, PlainHtmlAlternativeString):
context_dict[k] = PlainHtmlAlternativeString( context_dict[k] = PlainHtmlAlternativeString(
'<{el} class="placeholder" title="{title}">{plain}</{el}>'.format( sample.plain,
el='span',
title=lbl,
plain=escape(sample.plain),
),
'<{el} class="placeholder placeholder-html" title="{title}">{html}</{el}>'.format( '<{el} class="placeholder placeholder-html" title="{title}">{html}</{el}>'.format(
el='div' if sample.is_block else 'span', el='div' if sample.is_block else 'span',
title=lbl, title=lbl,
+5 -14
View File
@@ -174,9 +174,7 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
:param event: Event the cart belongs to :param event: Event the cart belongs to
:param sales_channel: Sales channel the cart was created with :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, addon_to_id, is_bundled, voucher_discount)`` :param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, 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 :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. 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 The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list
@@ -198,9 +196,9 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk') ).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs: for discount in discount_qs:
result = discount.apply({ result = discount.apply({
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount) idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount)
for for
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount) idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)
in enumerate(positions) in enumerate(positions)
if not is_bundled and idx not in new_prices if not is_bundled and idx not in new_prices
}, collect_potential_discounts) }, collect_potential_discounts)
@@ -211,8 +209,7 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)] return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_only_business", "sum_by_net_keep_gross"], def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep_gross"], currency: str,
invoice_address: Optional[InvoiceAddress], currency: str,
lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list: lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
""" """
Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode
@@ -227,17 +224,11 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_only
When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be
adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant. adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant.
:param rounding_mode: One of ``"line"``, ``"sum_by_net"``, ``"sum_by_net_only_business"``, or ``"sum_by_net_keep_gross"``. :param rounding_mode: One of ``"line"``, ``"sum_by_net"``, or ``"sum_by_net_keep_gross"``.
:param invoice_address: The invoice address, or ``None``
:param currency: Currency that will be used to determine rounding precision :param currency: Currency that will be used to determine rounding precision
:param lines: List of order/cart contents :param lines: List of order/cart contents
:return: Collection of ``lines`` members that have been changed and may need to be persisted to the database. :return: Collection of ``lines`` members that have been changed and may need to be persisted to the database.
""" """
if rounding_mode == "sum_by_net_only_business":
if invoice_address and invoice_address.is_business:
rounding_mode = "sum_by_net"
else:
rounding_mode = "line"
def _key(line): def _key(line):
return (line.tax_rate, line.tax_code or "") return (line.tax_rate, line.tax_code or "")
+19 -17
View File
@@ -48,7 +48,7 @@ from django.utils.translation import gettext_lazy as _
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import CachedFile, Event, User, cachedfile_name from pretix.base.models import CachedFile, Event, User, cachedfile_name
from pretix.base.services.mail import mail from pretix.base.services.mail import SendMailException, mail
from pretix.base.services.tasks import ProfiledEventTask from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.shredder import ShredError from pretix.base.shredder import ShredError
from pretix.celery_app import app from pretix.celery_app import app
@@ -171,19 +171,21 @@ def shred(self, event: Event, fileid: str, confirm_code: str, user: int=None, lo
if user: if user:
with language(user.locale): with language(user.locale):
mail( try:
user.email, mail(
_('Data shredding completed'), user.email,
'pretixbase/email/shred_completed.txt', _('Data shredding completed'),
{ 'pretixbase/email/shred_completed.txt',
'instance': settings.PRETIX_INSTANCE_NAME, {
'user': user, 'user': user,
'organizer': event.organizer.name, 'organizer': event.organizer.name,
'event': str(event.name), 'event': str(event.name),
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'), 'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
'shredders': ', '.join([str(s.verbose_name) for s in shredders]) 'shredders': ', '.join([str(s.verbose_name) for s in shredders])
}, },
event=None, event=None,
user=user, user=user,
locale=user.locale, locale=user.locale,
) )
except SendMailException:
pass # Already logged
+1 -10
View File
@@ -112,8 +112,7 @@ def dictsum(*dicts) -> dict:
def order_overview( def order_overview(
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False, 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]]]: ) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
items = event.items.all().select_related( items = event.items.all().select_related(
'category', # for re-grouping 'category', # for re-grouping
@@ -206,21 +205,13 @@ def order_overview(
for l in states.keys(): for l in states.keys():
var.num[l] = num[l].get((item.id, variid), (0, 0, 0)) 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.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(): for l in states.keys():
item.num[l] = tuplesum(var.num[l] for var in item.all_variations) 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) 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: else:
for l in states.keys(): for l in states.keys():
item.num[l] = num[l].get((item.id, None), (0, 0, 0)) 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.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')) nonecat = ItemCategory(name=_('Uncategorized'))
# Regroup those by category # Regroup those by category
+13 -186
View File
@@ -27,6 +27,7 @@ from decimal import Decimal
from xml.etree import ElementTree from xml.etree import ElementTree
import requests import requests
import vat_moss.id
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from zeep import Client, Transport from zeep import Client, Transport
@@ -41,142 +42,14 @@ logger = logging.getLogger(__name__)
error_messages = { error_messages = {
'unavailable': _( 'unavailable': _(
'Your VAT ID could not be checked, as the VAT checking service of ' 'Your VAT ID could not be checked, as the VAT checking service of '
'your country is currently not available. We will therefore need to ' 'your country is currently not available. We will therefore '
'charge you the same tax rate as if you did not enter a VAT ID.' 'need to charge VAT on your invoice. You can get the tax amount '
'back via the VAT reimbursement process.'
), ),
'invalid': _('This VAT ID is not valid. Please re-check your input.'), 'invalid': _('This VAT ID is not valid. Please re-check your input.'),
'country_mismatch': _('Your VAT ID does not match the selected country.'), '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): class VATIDError(Exception):
def __init__(self, message): def __init__(self, message):
@@ -191,57 +64,13 @@ class VATIDTemporaryError(VATIDError):
pass 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): def _validate_vat_id_NO(vat_id, country_code):
# Inspired by vat_moss library # 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: try:
vat_id = normalize_vat_id(vat_id, country_code) vat_id = vat_moss.id.normalize(vat_id)
except ValueError: except ValueError:
raise VATIDFinalError(error_messages['invalid']) raise VATIDFinalError(error_messages['invalid'])
@@ -275,7 +104,7 @@ def _validate_vat_id_NO(vat_id, country_code):
def _validate_vat_id_EU(vat_id, country_code): def _validate_vat_id_EU(vat_id, country_code):
# Inspired by vat_moss library # Inspired by vat_moss library
try: try:
vat_id = normalize_vat_id(vat_id, country_code) vat_id = vat_moss.id.normalize(vat_id)
except ValueError: except ValueError:
raise VATIDFinalError(error_messages['invalid']) raise VATIDFinalError(error_messages['invalid'])
@@ -283,10 +112,11 @@ def _validate_vat_id_EU(vat_id, country_code):
raise VATIDFinalError(error_messages['invalid']) raise VATIDFinalError(error_messages['invalid'])
number = vat_id[2:] number = vat_id[2:]
if vat_id[:2] != cc_to_vat_prefix(country_code): if vat_id[:2] != cc_to_vat_prefix(country_code):
raise VATIDFinalError(error_messages['country_mismatch']) raise VATIDFinalError(error_messages['country_mismatch'])
if not re.match(VAT_ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], number): if not re.match(vat_moss.id.ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], number):
raise VATIDFinalError(error_messages['invalid']) raise VATIDFinalError(error_messages['invalid'])
# We are relying on the country code of the normalized VAT-ID and not the user/InvoiceAddress-provided # We are relying on the country code of the normalized VAT-ID and not the user/InvoiceAddress-provided
@@ -345,12 +175,9 @@ def _validate_vat_id_EU(vat_id, country_code):
def _validate_vat_id_CH(vat_id, country_code): def _validate_vat_id_CH(vat_id, country_code):
if vat_id[:3] != 'CHE': if vat_id[:3] != 'CHE':
raise VATIDFinalError(error_messages['country_mismatch']) raise VATIDFinalError(_('Your VAT ID does not match the selected country.'))
try: vat_id = re.sub('[^A-Z0-9]', '', vat_id.replace('HR', '').replace('MWST', ''))
vat_id = normalize_vat_id(vat_id, country_code)
except ValueError:
raise VATIDFinalError(error_messages['invalid'])
try: try:
transport = Transport( transport = Transport(
cache=SqliteCache(os.path.join(settings.CACHE_DIR, "validate_vat_id_ch_zeep_cache.db")), cache=SqliteCache(os.path.join(settings.CACHE_DIR, "validate_vat_id_ch_zeep_cache.db")),
+3 -3
View File
@@ -39,7 +39,7 @@ def vouchers_send(event: Event, vouchers: list, subject: str, message: str, reci
with language(event.settings.locale): with language(event.settings.locale):
email_context = get_email_context(event=event, name=r.get('name') or '', email_context = get_email_context(event=event, name=r.get('name') or '',
voucher_list=[v.code for v in voucher_list]) voucher_list=[v.code for v in voucher_list])
outgoing_mail = mail( mail(
r['email'], r['email'],
subject, subject,
LazyI18nString(message), LazyI18nString(message),
@@ -60,8 +60,8 @@ def vouchers_send(event: Event, vouchers: list, subject: str, message: str, reci
data={ data={
'recipient': r['email'], 'recipient': r['email'],
'name': r.get('name'), 'name': r.get('name'),
'subject': outgoing_mail.subject, 'subject': subject,
'message': outgoing_mail.body_plain, 'message': message,
}, },
save=False save=False
)) ))
-5
View File
@@ -113,11 +113,6 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
lock_objects(quotas, shared_lock_objects=[event]) lock_objects(quotas, shared_lock_objects=[event])
for wle in qs: 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: if (wle.item_id, wle.variation_id, wle.subevent_id) in gone:
continue continue
ev = (wle.subevent or event) ev = (wle.subevent or event)
+4 -78
View File
@@ -76,12 +76,11 @@ from pretix.base.validators import multimail_validate
from pretix.control.forms import ( from pretix.control.forms import (
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget, ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
) )
from pretix.helpers.countries import CachedCountries, pycountry_add from pretix.helpers.countries import CachedCountries
ROUNDING_MODES = ( ROUNDING_MODES = (
('line', _('Compute taxes for every line individually')), ('line', _('Compute taxes for every line individually')),
('sum_by_net', _('Compute taxes based on net total')), ('sum_by_net', _('Compute taxes based on net total')),
('sum_by_net_only_business', _('For business customers, compute taxes based on net total. For individuals, use line-based rounding')),
('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')), ('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')),
# We could also have sum_by_gross, but we're not aware of any use-cases for it # We could also have sum_by_gross, but we're not aware of any use-cases for it
) )
@@ -181,19 +180,6 @@ DEFAULTS = {
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}), 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': { 'customer_accounts_link_by_email': {
'default': 'False', 'default': 'False',
'type': bool, 'type': bool,
@@ -643,40 +629,13 @@ DEFAULTS = {
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Ask for VAT ID"), label=_("Ask for VAT ID"),
help_text=format_lazy( help_text=format_lazy(
_("Only works if an invoice address is asked for. VAT ID is only requested from business customers " _("Only works if an invoice address is asked for. VAT ID is never required and only requested from "
"in the following countries: {countries}."), "business customers in the following countries: {countries}"),
countries=lazy(lambda *args: ', '.join(sorted(gettext(Country(cc).name) for cc in VAT_ID_COUNTRIES)), str)() 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'}), 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': { 'invoice_address_explanation_text': {
'default': '', 'default': '',
'type': LazyI18nString, 'type': LazyI18nString,
@@ -1226,15 +1185,6 @@ DEFAULTS = {
'default': json.dumps(['web']), 'default': json.dumps(['web']),
'type': list 'type': list
}, },
'invoice_generate_only_business': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Only issue invoices to business customers"),
)
},
'invoice_address_from': { 'invoice_address_from': {
'default': '', 'default': '',
'type': str, 'type': str,
@@ -2948,28 +2898,6 @@ If you did not request a new password, please ignore this email.
Best regards, Best regards,
Your {organizer} team""")) # noqa: W291
},
'mail_subject_customer_security_notice': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Changes to your account at {organizer}")),
},
'mail_text_customer_security_notice': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
the following change has been made to your account at {organizer}:
{message}
You can review and change your account settings here:
{url}
If this change was not performed by you, please contact us immediately.
Best regards,
Your {organizer} team""")) # noqa: W291 Your {organizer} team""")) # noqa: W291
}, },
'smtp_use_custom': { 'smtp_use_custom': {
@@ -3959,7 +3887,7 @@ COUNTRIES_WITH_STATE_IN_ADDRESS = {
'MX': (['State', 'Federal district', 'Federal entity'], 'short'), 'MX': (['State', 'Federal district', 'Federal entity'], 'short'),
'US': (['State', 'Outlying area', 'District'], 'short'), 'US': (['State', 'Outlying area', 'District'], 'short'),
'IT': (['Province', 'Free municipal consortium', 'Metropolitan city', 'Autonomous province', 'IT': (['Province', 'Free municipal consortium', 'Metropolitan city', 'Autonomous province',
'Decentralized regional entity'], 'short'), 'Free municipal consortium', 'Decentralized regional entity'], 'short'),
} }
COUNTRY_STATE_LABEL = { COUNTRY_STATE_LABEL = {
# Countries in which the "State" field should not be called "State" # Countries in which the "State" field should not be called "State"
@@ -3967,8 +3895,6 @@ COUNTRY_STATE_LABEL = {
'JP': pgettext_lazy('address', 'Prefecture'), 'JP': pgettext_lazy('address', 'Prefecture'),
'IT': pgettext_lazy('address', 'Province'), 'IT': pgettext_lazy('address', 'Province'),
} }
# Workaround for https://github.com/pretix/pretix/issues/5796
pycountry_add(pycountry.subdivisions, code="IT-AO", country_code="IT", name="Valle d'Aosta", parent="23", parent_code="IT-23", type="Province")
settings_hierarkey = Hierarkey(attribute_name='settings') settings_hierarkey = Hierarkey(attribute_name='settings')
+2 -6
View File
@@ -51,7 +51,7 @@ from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.i18n import LazyLocaleException from pretix.base.i18n import LazyLocaleException
from pretix.base.models import ( from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment, CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer, OrderPosition, OrderRefund, QuestionAnswer,
) )
from pretix.base.services.invoices import invoice_pdf_task from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.signals import register_data_shredders from pretix.base.signals import register_data_shredders
@@ -329,10 +329,6 @@ class EmailAddressShredder(BaseDataShredder):
sleep_time=2, sleep_time=2,
) )
slow_delete(
OutgoingMail.objects.filter(event=self.event)
)
for o in _progress_helper(qs_orders, progress_callback, qs_op_cnt, total): for o in _progress_helper(qs_orders, progress_callback, qs_op_cnt, total):
changed = bool(o.email) or bool(o.customer) changed = bool(o.email) or bool(o.customer)
o.email = None o.email = None
@@ -363,7 +359,7 @@ class EmailAddressShredder(BaseDataShredder):
le.save(update_fields=['data', 'shredded']) le.save(update_fields=['data', 'shredded'])
else: else:
shred_log_fields(le, banlist=[ shred_log_fields(le, banlist=[
'recipient', 'message', 'subject', 'full_mail', 'old_email', 'new_email', 'bcc', 'cc', 'recipient', 'message', 'subject', 'full_mail', 'old_email', 'new_email'
]) ])
+2 -10
View File
@@ -944,40 +944,32 @@ As with all event-plugin signals, the ``sender`` keyword argument will contain t
email_filter = EventPluginSignal() email_filter = EventPluginSignal()
""" """
Arguments: ``message``, ``order``, ``user``, ``outgoing_mail`` Arguments: ``message``, ``order``, ``user``
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
return a (possibly modified) copy of the message object passed to you. return a (possibly modified) copy of the message object passed to you.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event. As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
The ``message`` argument will contain an ``EmailMultiAlternatives`` object. The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
The ``outgoing_mail`` argument will contain the ``OutgoingMail`` model instance. Note that the ``message`` object
might have newer information if a previous plugin already modified the email.
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
it will be ``None``. it will be ``None``.
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
well, otherwise it will be ``None``. well, otherwise it will be ``None``.
You can raise ``WithholdMailException`` to prevent the email from being sent, e.g. when implementing rate limiting.
""" """
global_email_filter = GlobalSignal() global_email_filter = GlobalSignal()
""" """
Arguments: ``message``, ``order``, ``user``, ``customer``, ``organizer``, ``outgoing_mail`` Arguments: ``message``, ``order``, ``user``, ``customer``, ``organizer``
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
return a (possibly modified) copy of the message object passed to you. return a (possibly modified) copy of the message object passed to you.
This signal is called on all events and even if there is no known event. ``sender`` is an event or None. This signal is called on all events and even if there is no known event. ``sender`` is an event or None.
The ``message`` argument will contain an ``EmailMultiAlternatives`` object. The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
The ``outgoing_mail`` argument will contain the ``OutgoingMail`` model instance. Note that the ``message`` object
might have newer information if a previous plugin already modified the email.
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
it will be ``None``. it will be ``None``.
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
well, otherwise it will be ``None``. well, otherwise it will be ``None``.
You can raise ``WithholdMailException`` to prevent the email from being sent, e.g. when implementing rate limiting.
""" """
+188
View File
@@ -0,0 +1,188 @@
from ast import literal_eval
from collections import namedtuple
from datetime import datetime
from typing import Union
from django import forms
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import Event
from pretix.control.forms import SplitDateTimeField
from pretix.control.forms.widgets import Select2
SubEventSelection = namedtuple(
typename='SubEventSelection',
field_names=['selection', 'subevents', 'start', 'end', ],
defaults=['subevent', None, None, None],
)
subeventselectionparts = namedtuple(
typename='subeventselectionparts',
field_names=['selection', 'subevents', 'start', 'end']
)
class SubEventSelectionWrapper:
def __init__(self, data: Union[None, SubEventSelection]):
self.data = data
def get_queryset(self, event: Event):
if self.data.selection == 'subevent':
if self.data.subevents is None:
return event.subevents.all()
else:
return event.subevents.filter(pk=self.data.subevents)
elif self.data.selection == 'timerange':
if self.data.start and self.data.end:
return event.subevents.filter(date_from__lte=self.data.start,
date_from__gte=self.data.end)
elif self.data.start:
return event.subevents.filter(date_from__gte=self.data.start)
elif self.data.end:
return event.subevents.filter(date_from__lte=self.data.end)
return event.subevents.all()
def to_string(self) -> str:
if self.data:
if self.data.selection == 'subevent':
return 'SUBEVENT/pk/{}'.format(self.data.subevents.pk)
elif self.data.selection == 'timerange':
if self.data.start and self.data.end:
return 'SUBEVENT/range/{}/{}'.format(self.data.start.isoformat(), self.data.end.isoformat())
elif self.data.start:
return 'SUBEVENT/from/{}'.format(self.data.start)
elif self.data.end:
return 'SUBEVENT/to/{}'.format(self.data.end)
return 'SUBEVENT'
@classmethod
def from_string(cls, input: str):
data = SubEventSelection(selection='subevent')
if input.startswith('SUBEVENT'):
parts = input.split('/')
if len(parts) == 1:
data = SubEventSelection(selection='subevent')
elif parts[1] == 'pk':
data = SubEventSelection(
selection='subevent',
subevents=literal_eval(parts[2])
)
elif parts[1] == 'range':
data = SubEventSelection(
selection="timerange",
start=datetime.fromisoformat(parts[2]),
end=datetime.fromisoformat(parts[3]),
)
elif parts[1] == 'from':
data = SubEventSelection(
selection="timerange",
start=datetime.fromisoformat(parts[2]),
)
elif parts[1] == 'to':
data = SubEventSelection(
selection="timerange",
end=datetime.fromisoformat(parts[3]),
)
return SubEventSelectionWrapper(
data=data
)
class SubeventSelectionWidget(forms.MultiWidget):
template_name = 'pretixcontrol/forms/widgets/subeventselection.html'
parts = SubEventSelection
def __init__(self, event: Event, status_choices, subevent_choices, *args, **kwargs):
widgets = subeventselectionparts(
selection=forms.RadioSelect(
choices=status_choices,
),
subevents=Select2(
attrs={
'class': 'simple-subevent-choice',
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'All dates')
},
),
start=SplitDateTimePickerWidget(),
end=SplitDateTimePickerWidget(),
)
widgets.subevents.choices = subevent_choices
super().__init__(widgets=widgets, *args, **kwargs)
def decompress(self, value):
if isinstance(value, str):
value = SubEventSelectionWrapper.from_string(value)
if isinstance(value, subeventselectionparts):
return value
return subeventselectionparts(selection='subevent', start=None, end=None, subevents=None)
class SubeventSelectionField(forms.MultiValueField):
widget = SubeventSelectionWidget
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
choices = [
("subevent", _("Subevent")),
("timerange", _("Timerange"))
]
fields = SubEventSelection(
selection=forms.ChoiceField(
choices=choices,
required=True,
),
subevents=forms.ModelChoiceField(
required=False,
queryset=self.event.subevents,
empty_label=pgettext_lazy('subevent', 'All dates')
),
start=SplitDateTimeField(
required=False,
),
end=SplitDateTimeField(
required=False,
),
)
kwargs['widget'] = SubeventSelectionWidget(
event=self.event,
status_choices=choices,
subevent_choices=fields.subevents.widget.choices,
)
super().__init__(
fields=fields, require_all_fields=False, *args, **kwargs
)
def compress(self, data_list):
if not data_list:
return None
return SubEventSelectionWrapper(data=SubEventSelection(*data_list)).to_string()
def clean(self, value):
data = subeventselectionparts(*value)
if data.selection == "timerange":
if (data.start != ["", ""] and data.end != ["", ""]) and data.end < data.start:
raise ValidationError(_("The end date must be after the start date."))
if (data.start == ["", ""]) and (data.end == ["", ""]):
raise ValidationError(_('At least one of start and end must be specified.'))
return super().clean(value)
+3
View File
@@ -8,6 +8,9 @@
<h1>{% trans "Not found" %}</h1> <h1>{% trans "Not found" %}</h1>
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p> <p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
<p>{{ exception }}</p> <p>{{ exception }}</p>
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
</p>
{% if request.user.is_staff and not staff_session %} {% if request.user.is_staff and not staff_session %}
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post"> <form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
<p> <p>
-3
View File
@@ -12,9 +12,6 @@
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}"> <link rel="icon" href="{% static "pretixbase/img/favicon.ico" %}">
{% block custom_header %}{% endblock %} {% block custom_header %}{% endblock %}
{% if css_theme %}
<link rel="stylesheet" type="text/css" href="{{ css_theme }}" />
{% endif %}
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -13,5 +13,5 @@ Start time: {{ start_time }} (new data added after this time might not have been
Best regards, Best regards,
Your {{ instance }} team Your pretix team
{% endblocktrans %} {% endblocktrans %}
@@ -1,34 +0,0 @@
#
# 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 django import template
from django.utils.html import mark_safe
register = template.Library()
@register.filter("anon_email")
def anon_email(value):
"""Replaces @ with [at] and . with [dot] for anonymization."""
if not isinstance(value, str):
return value
value = value.replace("@", "[at]").replace(".", "[dot]")
return mark_safe(''.join(['&#{0};'.format(ord(char)) for char in value]))
-65
View File
@@ -1,65 +0,0 @@
#
# 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 isnt 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 ''
+8 -6
View File
@@ -26,8 +26,7 @@ from babel.numbers import format_currency
from django import template from django import template
from django.conf import settings from django.conf import settings
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.utils import translation
from pretix.base.i18n import get_babel_locale
register = template.Library() register = template.Library()
@@ -60,10 +59,13 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
if hide_currency: if hide_currency:
return floatformat(value, f"{places}g") return floatformat(value, f"{places}g")
try: locale_parts = translation.get_language().split("-", 1)
locale = Locale(get_babel_locale()) locale = locale_parts[0]
except UnknownLocaleError: if len(locale_parts) > 1 and len(locale_parts[1]) == 2:
locale = "en" try:
locale = Locale(locale_parts[0], locale_parts[1].upper())
except UnknownLocaleError:
pass
try: try:
return format_currency(value, arg, locale=locale) return format_currency(value, arg, locale=locale)
+5 -29
View File
@@ -32,14 +32,13 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
import html
import re import re
import urllib.parse import urllib.parse
import bleach import bleach
import markdown import markdown
from bleach import DEFAULT_CALLBACKS, html5lib_shim from bleach import DEFAULT_CALLBACKS
from bleach.linkifier import build_email_re from bleach.linkifier import build_email_re, build_url_re
from django import template from django import template
from django.conf import settings from django.conf import settings
from django.core import signing from django.core import signing
@@ -125,23 +124,6 @@ ALLOWED_ATTRIBUTES = {
ALLOWED_PROTOCOLS = {'http', 'https', 'mailto', 'tel'} 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))) 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))) EMAIL_RE = SimpleLazyObject(lambda: build_email_re(tlds=sorted(tld_set, key=len, reverse=True)))
@@ -156,7 +138,7 @@ def safelink_callback(attrs, new=False):
Makes sure that all links to a different domain are passed through a redirection handler Makes sure that all links to a different domain are passed through a redirection handler
to ensure there's no passing of referers with secrets inside them. to ensure there's no passing of referers with secrets inside them.
""" """
url = html.unescape(attrs.get((None, 'href'), '/')) url = attrs.get((None, 'href'), '/')
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'): if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
signer = signing.Signer(salt='safe-redirect') signer = signing.Signer(salt='safe-redirect')
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url)) attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
@@ -351,14 +333,8 @@ def markdown_compile_email(source, allowed_tags=None, allowed_attributes=ALLOWED
# This is a workaround to fix placeholders in URL targets # This is a workaround to fix placeholders in URL targets
def context_callback(attrs, new=False): def context_callback(attrs, new=False):
if (None, "href") in attrs and "{" in attrs[None, "href"]: if (None, "href") in attrs and "{" in attrs[None, "href"]:
# Do not use MODE_RICH_TO_HTML to avoid recursive linkification. # 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 attrs[None, "href"] = escape(format_map(attrs[None, "href"], context=context, mode=SafeFormatter.MODE_RICH_TO_PLAIN))
# to &amp;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 return attrs
context_callbacks.append(context_callback) context_callbacks.append(context_callback)
+1 -1
View File
@@ -423,7 +423,7 @@ def resolve_timeframe_to_dates_inclusive(ref_dt, frame, timezone) -> Tuple[Optio
raise ValueError(f"Invalid timeframe '{frame}'") raise ValueError(f"Invalid timeframe '{frame}'")
def resolve_timeframe_to_datetime_start_inclusive_end_exclusive(ref_dt, frame, timezone) -> Tuple[Optional[datetime], Optional[datetime]]: def resolve_timeframe_to_datetime_start_inclusive_end_exclusive(ref_dt, frame, timezone) -> Tuple[Optional[date], Optional[date]]:
""" """
Given a serialized timeframe, evaluate it relative to `ref_dt` and return a tuple of datetimes Given a serialized timeframe, evaluate it relative to `ref_dt` and return a tuple of datetimes
where the first element ist the first possible datetime within the timeframe and the second where the first element ist the first possible datetime within the timeframe and the second
+1 -3
View File
@@ -93,9 +93,7 @@ def timeline_for_event(event, subevent=None):
description=format_lazy( description=format_lazy(
'{} ({})', '{} ({})',
pgettext_lazy('timeline', 'End of ticket sales'), pgettext_lazy('timeline', 'End of ticket sales'),
pgettext_lazy('timeline', 'automatically because the event is over and no end of presale has been configured') pgettext_lazy('timeline', 'automatically because the event is over and no end of presale has been configured') if not ev.presale_end else ""
) if not ev.presale_end else (
pgettext_lazy('timeline', 'End of ticket sales')
), ),
edit_url=ev_edit_url + '#id_presale_end_0' edit_url=ev_edit_url + '#id_presale_end_0'
)) ))
-1
View File
@@ -95,7 +95,6 @@ class OrganizerSlugBanlistValidator(BanlistValidator):
'csp_report', 'csp_report',
'widget', 'widget',
'lead', 'lead',
'scheduling',
] ]
+3 -2
View File
@@ -36,8 +36,9 @@ class DownloadView(TemplateView):
def object(self) -> CachedFile: def object(self) -> CachedFile:
try: try:
o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True) o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True)
if not o.allowed_for_session(self.request): if o.session_key:
raise Http404() if o.session_key != self.request.session.session_key:
raise Http404()
return o return o
except (ValueError, ValidationError): # Invalid URLs except (ValueError, ValidationError): # Invalid URLs
raise Http404() raise Http404()
+5 -51
View File
@@ -20,17 +20,15 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import pycountry import pycountry
from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext, pgettext, pgettext_lazy from django.utils.translation import pgettext
from django_countries.fields import Country from django_countries.fields import Country
from django_scopes import scope from django_scopes import scope
from pretix.base.addressvalidation import ( from pretix.base.addressvalidation import (
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED, COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED,
) )
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import Organizer from pretix.base.models import Organizer
from pretix.base.models.tax import VAT_ID_COUNTRIES from pretix.base.models.tax import VAT_ID_COUNTRIES
@@ -38,28 +36,6 @@ from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, 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): def _info(cc):
info = { info = {
@@ -71,12 +47,7 @@ def _info(cc):
'required': 'if_any' if cc in COUNTRIES_WITH_STATE_IN_ADDRESS else False, 'required': 'if_any' if cc in COUNTRIES_WITH_STATE_IN_ADDRESS else False,
'label': COUNTRY_STATE_LABEL.get(cc, pgettext('address', 'State')), 'label': COUNTRY_STATE_LABEL.get(cc, pgettext('address', 'State')),
}, },
'vat_id': { 'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False},
'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: if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
return {'data': [], **info} return {'data': [], **info}
@@ -84,14 +55,14 @@ def _info(cc):
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types] statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
return { return {
'data': [ 'data': [
{'name': gettext(s.name), 'code': s.code[3:]} {'name': s.name, 'code': s.code[3:]}
for s in sorted(statelist, key=lambda s: s.name) for s in sorted(statelist, key=lambda s: s.name)
], ],
**info, **info,
} }
def _address_form(request): def address_form(request):
cc = request.GET.get("country", "DE") cc = request.GET.get("country", "DE")
info = _info(cc) info = _info(cc)
@@ -111,7 +82,7 @@ def _address_form(request):
for t in get_transmission_types(): for t in get_transmission_types():
if t.is_available(event=event, country=country, is_business=is_business): if t.is_available(event=event, country=country, is_business=is_business):
result = {"name": str(t.public_name), "code": t.identifier} result = {"name": str(t.public_name), "code": t.identifier}
if t.is_exclusive(event=event, country=country, is_business=is_business): if t.exclusive:
info["transmission_types"] = [result] info["transmission_types"] = [result]
break break
else: else:
@@ -153,21 +124,4 @@ def _address_form(request):
"required": transmission_type.identifier == selected_transmission_type and k in required "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 info
def address_form(request):
locale = request.GET.get('locale')
if locale in dict(settings.LANGUAGES):
with language(locale):
info = _address_form(request)
else:
info = _address_form(request)
return JsonResponse(info) return JsonResponse(info)
+6 -32
View File
@@ -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.db.models import Prefetch, Q, prefetch_related_objects
from django.forms import formset_factory, inlineformset_factory from django.forms import formset_factory, inlineformset_factory
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property, lazy from django.utils.functional import cached_property
from django.utils.html import escape, format_html from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone_name 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_countries.fields import LazyTypedChoiceField
from django_scopes.forms import SafeModelMultipleChoiceField from django_scopes.forms import SafeModelMultipleChoiceField
from i18nfield.forms import ( from i18nfield.forms import (
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput, I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput,
) )
from pytz import common_timezones from pytz import common_timezones
@@ -207,7 +207,6 @@ class EventWizardBasicsForm(I18nModelForm):
'Sample Conference Center\nHeidelberg, Germany' 'Sample Conference Center\nHeidelberg, Germany'
) )
self.fields['slug'].widget.prefix = build_absolute_uri(self.organizer, 'presale:organizer.index') 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: if self.has_subevents:
del self.fields['presale_start'] del self.fields['presale_start']
del self.fields['presale_end'] del self.fields['presale_end']
@@ -867,11 +866,6 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
"The gross price of some products may be changed to ensure correct rounding, while the net " "The gross price of some products may be changed to ensure correct rounding, while the net "
"prices will be kept as configured. This may cause the actual payment amount to differ." "prices will be kept as configured. This may cause the actual payment amount to differ."
), ),
"sum_by_net_only_business": _(
"Same as above, but only applied to business customers. Line-based rounding will be used for consumers. "
"Recommended when e-invoicing is only used for business customers and consumers do not receive "
"invoices. This can cause the payment amount to change when the invoice address is changed."
),
"sum_by_net_keep_gross": _( "sum_by_net_keep_gross": _(
"Recommended for e-invoicing when you primarily sell to consumers. " "Recommended for e-invoicing when you primarily sell to consumers. "
"The gross or net price of some products may be changed automatically to ensure correct " "The gross or net price of some products may be changed automatically to ensure correct "
@@ -933,7 +927,6 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
'invoice_address_asked', 'invoice_address_asked',
'invoice_address_required', 'invoice_address_required',
'invoice_address_vatid', 'invoice_address_vatid',
'invoice_address_vatid_required_countries',
'invoice_address_company_required', 'invoice_address_company_required',
'invoice_address_beneficiary', 'invoice_address_beneficiary',
'invoice_address_custom_field', 'invoice_address_custom_field',
@@ -944,7 +937,6 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
'invoice_show_payments', 'invoice_show_payments',
'invoice_reissue_after_modify', 'invoice_reissue_after_modify',
'invoice_generate', 'invoice_generate',
'invoice_generate_only_business',
'invoice_period', 'invoice_period',
'invoice_attendee_name', 'invoice_attendee_name',
'invoice_event_location', 'invoice_event_location',
@@ -1317,17 +1309,9 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
mail_text_order_invoice = I18nFormField( mail_text_order_invoice = I18nFormField(
label=_("Text"), label=_("Text"),
required=False, required=False,
widget=I18nTextarea, # no Markdown supported widget=I18nMarkdownTextarea,
help_text=lazy( help_text=_("This will only be used if the invoice is sent to a different email address or at a different time "
lambda: str(_( "than the order confirmation."),
"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( mail_subject_download_reminder = I18nFormField(
label=_("Subject sent to order contact address"), label=_("Subject sent to order contact address"),
@@ -1495,9 +1479,6 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
'mail_subject_resend_all_links': ['event', 'orders'], 'mail_subject_resend_all_links': ['event', 'orders'],
'mail_attach_ical_description': ['event', 'event_or_subevent'], 'mail_attach_ical_description': ['event', 'event_or_subevent'],
} }
plain_rendering = {
'mail_text_order_invoice',
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.event = event = kwargs.get('obj') self.event = event = kwargs.get('obj')
@@ -1516,7 +1497,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
self.event.meta_values_cached = self.event.meta_values.select_related('property').all() self.event.meta_values_cached = self.event.meta_values.select_related('property').all()
for k, v in self.base_context.items(): for k, v in self.base_context.items():
self._set_field_placeholders(k, v, rich=k.startswith('mail_text_') and k not in self.plain_rendering) self._set_field_placeholders(k, v, rich=k.startswith('mail_text_'))
for k, v in list(self.fields.items()): for k, v in list(self.fields.items()):
if k.endswith('_attendee') and not event.settings.attendee_emails_asked: if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
@@ -1976,13 +1957,6 @@ class EventFooterLinkForm(I18nModelForm):
class Meta: class Meta:
model = EventFooterLink model = EventFooterLink
fields = ('label', 'url') fields = ('label', 'url')
widgets = {
"url": forms.URLInput(
attrs={
"placeholder": "https://..."
}
)
}
class BaseEventFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet): class BaseEventFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet):
+2 -188
View File
@@ -57,15 +57,10 @@ from pretix.base.forms.widgets import (
from pretix.base.models import ( from pretix.base.models import (
Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue, Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue,
Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition, Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
OrderRefund, Organizer, OutgoingMail, Question, QuestionAnswer, Quota, OrderRefund, Organizer, Question, QuestionAnswer, Quota, SalesChannel,
SalesChannel, SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher,
Voucher,
) )
from pretix.base.signals import register_payment_providers 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 import SplitDateTimeField
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
from pretix.control.signals import order_search_filter_q from pretix.control.signals import order_search_filter_q
@@ -1224,129 +1219,6 @@ class OrderPaymentSearchFilterForm(forms.Form):
return qs return qs
class QuestionAnswerFilterForm(forms.Form):
STATUS_VARIANTS = [
("", _("All orders")),
(Order.STATUS_PAID, _("Paid")),
(Order.STATUS_PAID + 'v', _("Paid or confirmed")),
(Order.STATUS_PENDING, _("Pending")),
(Order.STATUS_PENDING + Order.STATUS_PAID, _("Pending or paid")),
("o", _("Pending (overdue)")),
(Order.STATUS_EXPIRED, _("Expired")),
(Order.STATUS_PENDING + Order.STATUS_EXPIRED, _("Pending or expired")),
(Order.STATUS_CANCELED, _("Canceled"))
]
status = forms.ChoiceField(
choices=STATUS_VARIANTS,
required=False,
label=_("Order status"),
)
item = forms.ChoiceField(
choices=[],
required=False,
label=_("Products"),
)
subevent = forms.ModelChoiceField(
queryset=SubEvent.objects.none(),
required=False,
empty_label=pgettext_lazy('subevent', 'All dates'),
label=pgettext_lazy("subevent", "Date"),
)
date_range = DateFrameField(
required=False,
include_future_frames=True,
label=_('Event date'),
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
self.initial['status'] = Order.STATUS_PENDING + Order.STATUS_PAID
choices = [('', _('All products'))]
for i in self.event.items.prefetch_related('variations').all():
variations = list(i.variations.all())
if variations:
choices.append((str(i.pk), _('{product} Any variation').format(product=str(i))))
for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (str(i), v.value)))
else:
choices.append((str(i.pk), str(i)))
self.fields['item'].choices = choices
if self.event.has_subevents:
self.fields["subevent"].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'All dates')
}
)
self.fields['subevent'].widget.choices = self.fields['subevent'].choices
else:
del self.fields['subevent']
def clean(self):
cleaned_data = super().clean()
subevent = cleaned_data.get('subevent')
date_range = cleaned_data.get('date_range')
if subevent is not None and date_range is not None:
d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone)
if (
(d_start and not (d_start <= subevent.date_from)) or
(d_end and not (subevent.date_from < d_end))
):
self.add_error('subevent', pgettext_lazy('subevent', "Date doesn't start in selected date range."))
return cleaned_data
def filter_qs(self, opqs):
fdata = self.cleaned_data
subevent = fdata.get('subevent', None)
date_range = fdata.get('date_range', None)
if subevent is not None:
opqs = opqs.filter(subevent=subevent)
if date_range is not None:
d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone)
if d_start:
opqs = opqs.filter(subevent__date_from__gte=d_start)
if d_end:
opqs = opqs.filter(subevent__date_from__lt=d_end)
s = fdata.get("status", Order.STATUS_PENDING + Order.STATUS_PAID)
if s != "":
if s == Order.STATUS_PENDING:
opqs = opqs.filter(order__status=Order.STATUS_PENDING,
order__expires__lt=now().replace(hour=0, minute=0, second=0))
elif s == Order.STATUS_PENDING + Order.STATUS_PAID:
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == Order.STATUS_PAID + 'v':
opqs = opqs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
elif s == Order.STATUS_PENDING + Order.STATUS_EXPIRED:
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
else:
opqs = opqs.filter(order__status=s)
if s not in (Order.STATUS_CANCELED, ""):
opqs = opqs.filter(canceled=False)
if fdata.get("item", "") != "":
i = fdata.get("item", "")
opqs = opqs.filter(item_id__in=(i,))
return opqs
class SubEventFilterForm(FilterForm): class SubEventFilterForm(FilterForm):
orders = { orders = {
'date_from': 'date_from', 'date_from': 'date_from',
@@ -2816,61 +2688,3 @@ class DeviceFilterForm(FilterForm):
qs = qs.order_by('-device_id') qs = qs.order_by('-device_id')
return qs return qs
class OutgoingMailFilterForm(FilterForm):
orders = {
'date': 'created',
'-date': '-created',
}
query = forms.CharField(
label=_('Search email address or subject'),
widget=forms.TextInput(attrs={
'placeholder': _('Search email address or subject'),
}),
required=False
)
event = forms.ModelChoiceField(
queryset=Event.objects.none(),
label=_('Event'),
empty_label=_('All events'),
required=False,
)
status = forms.ChoiceField(
label=_('Status'),
choices=[
('', _('All')),
*OutgoingMail.STATUS_CHOICES,
],
required=False
)
def __init__(self, *args, **kwargs):
request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.fields['event'].queryset = request.organizer.events.all()
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(
Q(to__containsstring=query.lower())
| Q(cc__containsstring=query.lower())
| Q(bcc__containsstring=query.lower())
| Q(subject__icontains=query)
)
if fdata.get('event'):
qs = qs.filter(event=fdata['event'])
if fdata.get('status'):
qs = qs.filter(status=fdata['status'])
if fdata.get('ordering'):
qs = qs.order_by(self.get_order_by())
else:
qs = qs.order_by("-created", "-pk")
return qs
+87 -2
View File
@@ -47,6 +47,7 @@ from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape, format_html from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext as __, gettext_lazy as _ from django.utils.translation import gettext as __, gettext_lazy as _
from django_scopes.forms import ( from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField, SafeModelChoiceField, SafeModelMultipleChoiceField,
@@ -56,11 +57,14 @@ from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm
from pretix.base.forms.widgets import DatePickerWidget from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import ( from pretix.base.models import (
Item, ItemCategory, ItemProgramTime, ItemVariation, Question, Item, ItemCategory, ItemProgramTime, ItemVariation, Order, OrderPosition,
QuestionOption, Quota, Question, QuestionOption, Quota,
) )
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data from pretix.base.signals import item_copy_data
from pretix.base.subevent import (
SubeventSelectionField, SubEventSelectionWrapper,
)
from pretix.control.forms import ( from pretix.control.forms import (
ButtonGroupRadioSelect, ExtFileField, ItemMultipleChoiceField, ButtonGroupRadioSelect, ExtFileField, ItemMultipleChoiceField,
SalesChannelCheckboxSelectMultiple, SplitDateTimeField, SalesChannelCheckboxSelectMultiple, SplitDateTimeField,
@@ -272,6 +276,87 @@ class QuestionOptionForm(I18nModelForm):
] ]
class QuestionFilterForm(forms.Form):
STATUS_VARIANTS = [
("", _("All orders")),
("p", _("Paid")),
("pv", _("Paid or confirmed")),
("n", _("Pending")),
("np", _("Pending or paid")),
("o", _("Pending (overdue)")),
("e", _("Expired")),
("ne", _("Pending or expired")),
("c", _("Canceled"))
]
status = forms.ChoiceField(
choices=STATUS_VARIANTS,
widget=forms.Select(
attrs={
'class': 'form-control',
}
),
required=False,
label=_("Status"),
initial="np",
)
item = forms.ChoiceField(
choices=[],
widget=forms.Select(
attrs={'class': 'form-control'}
),
required=False,
label=_("Items")
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
if self.event.has_subevents:
self.fields['subevent_selection'] = SubeventSelectionField(
event=self.event,
label=_("Subevents"),
help_text=_("Select the subevents that should be included in the statistics either by subevent or by the timerange in which they occur."),
)
self.fields['item'].choices = [('', _('All products'))] + [(item.id, item.name) for item in
Item.objects.filter(event=self.event)]
def filter_qs(self):
fdata = self.cleaned_data
opqs = OrderPosition.objects.filter(
order__event=self.event,
)
sub_event_qs = SubEventSelectionWrapper.from_string(fdata['subevent_selection']).get_queryset(self.event)
opqs = opqs.filter(subevent__in=sub_event_qs)
s = fdata.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 fdata.get("item", "") != "":
i = fdata.get("item", "")
opqs = opqs.filter(item_id__in=(i,))
return opqs
class QuotaForm(I18nModelForm): class QuotaForm(I18nModelForm):
itemvars = forms.MultipleChoiceField( itemvars = forms.MultipleChoiceField(
label=_("Products"), label=_("Products"),
+2 -2
View File
@@ -974,7 +974,7 @@ class EventCancelForm(FormPlaceholderMixin, forms.Form):
self._set_field_placeholders('send_subject', ['event_or_subevent', 'refund_amount', 'position_or_address', self._set_field_placeholders('send_subject', ['event_or_subevent', 'refund_amount', 'position_or_address',
'order', 'event']) 'order', 'event'])
self._set_field_placeholders('send_message', ['event_or_subevent', 'refund_amount', 'position_or_address', self._set_field_placeholders('send_message', ['event_or_subevent', 'refund_amount', 'position_or_address',
'order', 'event'], rich=True) 'order', 'event'])
self.fields['send_waitinglist_subject'] = I18nFormField( self.fields['send_waitinglist_subject'] = I18nFormField(
label=_("Subject"), label=_("Subject"),
required=True, 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_subject', ['event_or_subevent', 'event'])
self._set_field_placeholders('send_waitinglist_message', ['event_or_subevent', 'event'], rich=True) self._set_field_placeholders('send_waitinglist_message', ['event_or_subevent', 'event'])
if self.event.has_subevents: if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all() self.fields['subevent'].queryset = self.event.subevents.all()
-24
View File
@@ -474,7 +474,6 @@ class OrganizerSettingsForm(SettingsForm):
'customer_accounts', 'customer_accounts',
'customer_accounts_native', 'customer_accounts_native',
'customer_accounts_link_by_email', 'customer_accounts_link_by_email',
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed', 'invoice_regenerate_allowed',
'contact_mail', 'contact_mail',
'imprint_url', 'imprint_url',
@@ -585,7 +584,6 @@ class MailSettingsForm(SettingsForm):
help_text=''.join([ help_text=''.join([
str(_("All emails will be sent to this address as a Bcc copy.")), str(_("All emails will be sent to this address as a Bcc copy.")),
str(_("You can specify multiple recipients separated by commas.")), str(_("You can specify multiple recipients separated by commas.")),
str(_("Sensitive emails like password resets will not be sent in Bcc.")),
]), ]),
validators=[multimail_validate], validators=[multimail_validate],
required=False, required=False,
@@ -635,16 +633,6 @@ class MailSettingsForm(SettingsForm):
required=False, required=False,
widget=I18nMarkdownTextarea, widget=I18nMarkdownTextarea,
) )
mail_subject_customer_security_notice = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
)
mail_text_customer_security_notice = I18nFormField(
label=_("Text"),
required=False,
widget=I18nMarkdownTextarea,
)
base_context = { base_context = {
'mail_text_customer_registration': ['customer', 'url'], 'mail_text_customer_registration': ['customer', 'url'],
@@ -653,8 +641,6 @@ class MailSettingsForm(SettingsForm):
'mail_subject_customer_email_change': ['customer', 'url'], 'mail_subject_customer_email_change': ['customer', 'url'],
'mail_text_customer_reset': ['customer', 'url'], 'mail_text_customer_reset': ['customer', 'url'],
'mail_subject_customer_reset': ['customer', 'url'], 'mail_subject_customer_reset': ['customer', 'url'],
'mail_text_customer_security_notice': ['customer', 'url', 'message'],
'mail_subject_customer_security_notice': ['customer', 'url', 'message'],
} }
def _get_sample_context(self, base_parameters): def _get_sample_context(self, base_parameters):
@@ -668,9 +654,6 @@ class MailSettingsForm(SettingsForm):
'presale:organizer.customer.activate' 'presale:organizer.customer.activate'
) + '?token=' + get_random_string(30) ) + '?token=' + get_random_string(30)
if 'message' in base_parameters:
placeholders['message'] = _('Your password has been changed.')
if 'customer' in base_parameters: if 'customer' in base_parameters:
placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe') placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe')
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme] name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
@@ -1041,13 +1024,6 @@ class OrganizerFooterLinkForm(I18nModelForm):
class Meta: class Meta:
model = OrganizerFooterLink model = OrganizerFooterLink
fields = ('label', 'url') fields = ('label', 'url')
widgets = {
"url": forms.URLInput(
attrs={
"placeholder": "https://..."
}
)
}
class BaseOrganizerFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet): class BaseOrganizerFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet):
+1 -89
View File
@@ -19,44 +19,17 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from django import forms
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django_scopes.forms import SafeModelChoiceField from django_scopes.forms import SafeModelChoiceField
from phonenumber_field.formfields import PhoneNumberField
from pretix.base.forms import I18nModelForm from pretix.base.forms import I18nModelForm
from pretix.base.forms.questions import (
NamePartsFormField, WrappedPhoneNumberPrefixWidget,
)
from pretix.base.models import WaitingListEntry from pretix.base.models import WaitingListEntry
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
class WaitingListEntryEditForm(I18nModelForm): class WaitingListEntryTransferForm(I18nModelForm):
itemvar = forms.ChoiceField(
error_messages={
'invalid_choice': _("Select a valid choice.")
}
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.instance = kwargs.get('instance', None)
initial = kwargs.get('initial', {})
choices = []
if self.instance and self.instance.pk and 'itemvar' not in initial:
if self.instance.variation is not None:
initial['itemvar'] = f'{self.instance.item.pk}-{self.instance.variation.pk}'
if self.instance.variation.active is False:
choices.append((initial['itemvar'], str(self.instance.variation)))
else:
initial['itemvar'] = self.instance.item.pk
if self.instance.item.active is False:
choices.append((initial['itemvar'], str(self.instance)))
kwargs['initial'] = initial
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.event.has_subevents: if self.event.has_subevents:
@@ -72,73 +45,12 @@ class WaitingListEntryEditForm(I18nModelForm):
} }
) )
self.fields['subevent'].widget.choices = self.fields['subevent'].choices self.fields['subevent'].widget.choices = self.fields['subevent'].choices
else:
del self.fields['subevent']
if self.event.settings.waiting_list_names_asked:
self.fields['name_parts'] = NamePartsFormField(
max_length=255,
required=self.event.settings.waiting_list_names_required,
scheme=self.event.organizer.settings.name_scheme,
titles=self.event.organizer.settings.name_scheme_titles,
label=_('Name'),
)
else:
del self.fields['name_parts']
if not self.event.settings.waiting_list_phones_asked:
del self.fields['phone']
items = self.event.items.filter(active=True).prefetch_related(
'variations'
)
for item in items:
if len(item.variations.all()) > 0:
for variation in item.variations.all():
if variation.active:
choices.append(
('{}-{}'.format(item.pk, variation.pk), '{} - {}'.format(str(item), str(variation)))
)
else:
choices.append(('{}'.format(item.pk), str(item)))
self.fields['itemvar'].label = _("Product")
self.fields['itemvar'].help_text = _("Only includes active products.")
self.fields['itemvar'].required = True
self.fields['itemvar'].choices = choices
def clean(self):
cleaned_data = super().clean()
if self.instance.voucher is not None:
raise forms.ValidationError(_('A voucher for this waiting list entry was already sent out.'))
itemvar = cleaned_data.get('itemvar')
if itemvar:
self.instance.item = self.event.items.get(pk=itemvar.split('-')[0])
if '-' in itemvar:
self.instance.variation = self.instance.item.variations.get(pk=itemvar.split('-')[1])
if ((self.instance.item and not self.instance.item.active) or
(self.instance.variation and not self.instance.variation.active)):
self.add_error('itemvar', _('The selected product is not active.'))
return cleaned_data
class Meta: class Meta:
model = WaitingListEntry model = WaitingListEntry
fields = [ fields = [
'email',
'name_parts',
'phone',
'subevent', 'subevent',
] ]
field_classes = { field_classes = {
'subevent': SafeModelChoiceField, 'subevent': SafeModelChoiceField,
'email': forms.EmailField,
'phone': PhoneNumberField,
}
widgets = {
'phone': WrappedPhoneNumberPrefixWidget,
} }
+1 -12
View File
@@ -170,12 +170,6 @@ class OrderFeeAdded(OrderChangeLogEntryType):
plain = _('A fee has been added') plain = _('A fee has been added')
@log_entry_types.new()
class OrderRecomputed(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.recomputed'
plain = _('Taxes and rounding have been recomputed')
@log_entry_types.new() @log_entry_types.new()
class OrderFeeChanged(OrderChangeLogEntryType): class OrderFeeChanged(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.feevalue' action_type = 'pretix.event.order.changed.feevalue'
@@ -518,7 +512,6 @@ def pretixcontrol_orderposition_blocked_display(sender: Event, orderposition, bl
'The order requires approval before it can continue to be processed.'), 'The order requires approval before it can continue to be processed.'),
'pretix.event.order.approved': _('The order has been approved.'), 'pretix.event.order.approved': _('The order has been approved.'),
'pretix.event.order.denied': _('The order has been denied (comment: "{comment}").'), 'pretix.event.order.denied': _('The order has been denied (comment: "{comment}").'),
'pretix.event.order.vatid.validated': _('The customer VAT ID has been verified.'),
'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" ' 'pretix.event.order.contact.changed': _('The email address has been changed from "{old_email}" '
'to "{new_email}".'), 'to "{new_email}".'),
'pretix.event.order.contact.confirmed': _( 'pretix.event.order.contact.confirmed': _(
@@ -706,8 +699,6 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'), 'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'),
'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'), 'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'),
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'), 'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.organizer.outgoingmails.retried': _('Failed emails have been scheduled to be retried.'),
'pretix.organizer.outgoingmails.aborted': _('Queued emails have been aborted.'),
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'), 'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'), 'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'), 'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'),
@@ -802,8 +793,6 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.giftcards.created': _('The gift card has been created.'), 'pretix.giftcards.created': _('The gift card has been created.'),
'pretix.giftcards.modified': _('The gift card has been changed.'), 'pretix.giftcards.modified': _('The gift card has been changed.'),
'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'), 'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'),
'pretix.giftcards.transaction.payment': _('A payment has been performed.'),
'pretix.giftcards.transaction.refund': _('A refund has been performed. '),
'pretix.team.token.created': _('The token "{name}" has been created.'), 'pretix.team.token.created': _('The token "{name}" has been created.'),
'pretix.team.token.deleted': _('The token "{name}" has been revoked.'), 'pretix.team.token.deleted': _('The token "{name}" has been revoked.'),
'pretix.event.checkin.reset': _('The check-in and print log state has been reset.') 'pretix.event.checkin.reset': _('The check-in and print log state has been reset.')
@@ -825,7 +814,7 @@ class OrganizerPluginStateLogEntryType(LogEntryType):
if app and hasattr(app, 'PretixPluginMeta'): if app and hasattr(app, 'PretixPluginMeta'):
return { return {
'href': reverse('control:organizer.settings.plugins', kwargs={ 'href': reverse('control:organizer.settings.plugins', kwargs={
'organizer': logentry.organizer.slug, 'organizer': logentry.event.organizer.slug,
}) + '#plugin_' + logentry.parsed_data['plugin'], }) + '#plugin_' + logentry.parsed_data['plugin'],
'val': app.PretixPluginMeta.name 'val': app.PretixPluginMeta.name
} }
-9
View File
@@ -679,15 +679,6 @@ def get_organizer_navigation(request):
'active': (url.url_name == 'organizer.datasync.failedjobs'), 'active': (url.url_name == 'organizer.datasync.failedjobs'),
}]) }])
nav.append({
'label': _('Outgoing emails'),
'url': reverse('control:organizer.outgoingmails', kwargs={
'organizer': request.organizer.slug,
}),
'active': 'organizer.outgoingmail' in url.url_name,
'icon': 'send',
})
merge_in(nav, sorted( merge_in(nav, sorted(
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)), sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
[]), []),
@@ -19,14 +19,6 @@
</ul> </ul>
<br> <br>
{% endif %} {% endif %}
{% if possible_cookie_problem %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
It looks like your browser is not accepting our cookie and you need to log in repeatedly. Please
check if your browser is set to block cookies, or delete all existing cookies and retry.
{% endblocktrans %}
</div>
{% endif %}
{% csrf_token %} {% csrf_token %}
{% bootstrap_form form %} {% bootstrap_form form %}
<div class="form-group buttons"> <div class="form-group buttons">

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