mirror of
https://github.com/pretix/pretix.git
synced 2026-08-03 09:37:51 +00:00
Compare commits
617
Commits
@@ -24,7 +24,7 @@ jobs:
|
|||||||
name: Packaging
|
name: Packaging
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.11"]
|
python-version: ["3.13"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
|||||||
@@ -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.11
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.13
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ jobs:
|
|||||||
name: Check gettext syntax
|
name: Check gettext syntax
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.13
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
@@ -49,10 +49,10 @@ jobs:
|
|||||||
name: Spellcheck
|
name: Spellcheck
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.13
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
|
|||||||
@@ -24,10 +24,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.11
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.13
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
@@ -44,10 +44,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.11
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.13
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
@@ -64,10 +64,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.11
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.13
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pip3 install licenseheaders
|
run: pip3 install licenseheaders
|
||||||
- name: Run licenseheaders
|
- name: Run licenseheaders
|
||||||
|
|||||||
@@ -23,13 +23,15 @@ jobs:
|
|||||||
name: Tests
|
name: Tests
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.9", "3.10", "3.11"]
|
python-version: ["3.11", "3.13", "3.14"]
|
||||||
database: [sqlite, postgres]
|
database: [sqlite, postgres]
|
||||||
exclude:
|
exclude:
|
||||||
- database: sqlite
|
|
||||||
python-version: "3.9"
|
|
||||||
- database: sqlite
|
- database: sqlite
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
|
- database: sqlite
|
||||||
|
python-version: "3.11"
|
||||||
|
- database: sqlite
|
||||||
|
python-version: "3.12"
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
@@ -81,4 +83,4 @@ jobs:
|
|||||||
file: src/coverage.xml
|
file: src/coverage.xml
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
fail_ci_if_error: false
|
fail_ci_if_error: false
|
||||||
if: matrix.database == 'postgres' && matrix.python-version == '3.11'
|
if: matrix.database == 'postgres' && matrix.python-version == '3.13'
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.11-bookworm
|
FROM python:3.13-trixie
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
|
|||||||
Vendored
+79
-95
@@ -6,10 +6,14 @@
|
|||||||
{%- 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>
|
||||||
<!--[if IE 8]><html class="no-js lt-ie9" lang="en" > <![endif]-->
|
<html class="writer-html5" lang="{{ lang_attr }}"{% if sphinx_version_info >= (7, 2) %} data-content_root="{{ content_root }}"{% endif %}>
|
||||||
<!--[if gt IE 8]><!--> <html class="no-js" lang="en" > <!--<![endif]-->
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
{{ metatags }}
|
{{ metatags }}
|
||||||
@@ -18,59 +22,50 @@
|
|||||||
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{#- CSS #}
|
||||||
{#- CSS #}
|
{%- for css_file in css_files %}
|
||||||
{%- for css in css_files %}
|
{%- if css_file|attr("filename") %}
|
||||||
{%- if css|attr("rel") %}
|
{{ css_tag(css_file) }}
|
||||||
<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, 1) }}" type="text/css" />
|
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
|
|
||||||
{%- for cssfile in extra_css_files %}
|
{#- FAVICON #}
|
||||||
<link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" />
|
{%- if favicon_url %}
|
||||||
{%- endfor -%}
|
<link rel="shortcut icon" href="{{ favicon_url }}"/>
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
{#- FAVICON
|
{#- CANONICAL URL (deprecated) #}
|
||||||
favicon_url is the only context var necessary since Sphinx 4.
|
{%- if theme_canonical_url and not pageurl %}
|
||||||
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 lt IE 9]>
|
{%- if not embedded %}
|
||||||
<script src="{{ pathto('_static/js/html5shiv.min.js', 1) }}"></script>
|
{%- for scriptfile in script_files %}
|
||||||
<![endif]-->
|
{{ js_tag(scriptfile) }}
|
||||||
{%- if not embedded %}
|
{%- endfor %}
|
||||||
{# 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') %}
|
||||||
@@ -123,23 +118,23 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
|
{%- block navigation %}
|
||||||
{% block menu %}
|
{#- 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') }}">
|
||||||
The singlehtml builder doesn't handle this toctree call when the
|
{%- block menu %}
|
||||||
toctree is empty. Skip building this for now.
|
{%- set toctree = toctree(maxdepth=theme_navigation_depth|int,
|
||||||
#}
|
collapse=theme_collapse_navigation|tobool,
|
||||||
{% if 'singlehtml' not in builder %}
|
includehidden=theme_includehidden|tobool,
|
||||||
{% set global_toc = toctree(maxdepth=theme_navigation_depth|int, collapse=theme_collapse_navigation, includehidden=True) %}
|
titles_only=theme_titles_only|tobool) %}
|
||||||
{% endif %}
|
{%- if toctree %}
|
||||||
{% if global_toc %}
|
{{ toctree }}
|
||||||
{{ global_toc }}
|
{%- else %}
|
||||||
{% 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 %}
|
||||||
@@ -158,53 +153,42 @@
|
|||||||
<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" role="navigation" aria-label="top navigation">
|
<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 %}
|
{%- 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('index') }}">{{ project }}</a>
|
<a href="{{ pathto(master_doc) }}">{{ project }}</a>
|
||||||
{% endblock %}
|
{%- endblock %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="wy-nav-content">
|
||||||
{# PAGE CONTENT #}
|
{%- block content %}
|
||||||
<div class="wy-nav-content">
|
{%- if theme_style_external_links|tobool %}
|
||||||
<div class="rst-content">
|
<div class="rst-content style-external-links">
|
||||||
{% include "breadcrumbs.html" %}
|
{%- else %}
|
||||||
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
|
<div class="rst-content">
|
||||||
<div itemprop="articleBody" class="section">
|
{%- endif %}
|
||||||
{% block body %}{% endblock %}
|
{% include "breadcrumbs.html" %}
|
||||||
</div>
|
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
|
||||||
<div class="articleComments">
|
{%- block document %}
|
||||||
{% block comments %}{% endblock %}
|
<div itemprop="articleBody">
|
||||||
</div>
|
{% block body %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
{% include "footer.html" %}
|
{%- if self.comments()|trim %}
|
||||||
|
<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>
|
||||||
@@ -214,7 +198,7 @@
|
|||||||
{% if theme_sticky_navigation %}
|
{% if theme_sticky_navigation %}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
jQuery(function () {
|
jQuery(function () {
|
||||||
SphinxRtdTheme.StickyNav.enable();
|
SphinxRtdTheme.Navigation.enable({{ 'true' if theme_sticky_navigation|tobool else 'false' }});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+184
-166
@@ -1,136 +1,86 @@
|
|||||||
{#
|
{# 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 ' »' 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 = " — "|safe + docstitle|e %}
|
{%- set titlesuffix = " — "|safe + docstitle|e %}
|
||||||
{%- else %}
|
{%- else %}
|
||||||
{%- set titlesuffix = "" %}
|
{%- set titlesuffix = "" %}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
|
{%- set lang_attr = 'en' if language == None else (language | replace('_', '-')) %}
|
||||||
|
|
||||||
{%- macro relbar() %}
|
{# Build sphinx_version_info tuple from sphinx_version string in pure Jinja #}
|
||||||
<div class="related">
|
{%- set (_ver_major, _ver_minor) = (sphinx_version.split('.') | list)[:2] | map('int') -%}
|
||||||
<h3>{{ _('Navigation') }}</h3>
|
{%- set sphinx_version_info = (_ver_major, _ver_minor, -1) -%}
|
||||||
<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 %}
|
|
||||||
|
|
||||||
{%- macro sidebar() %}
|
<!DOCTYPE html>
|
||||||
{%- if render_sidebar %}
|
<html class="writer-html5" lang="{{ lang_attr }}"{% if sphinx_version_info >= (7, 2) %} data-content_root="{{ content_root }}"{% endif %}>
|
||||||
<div class="sphinxsidebar">
|
<head>
|
||||||
<div class="sphinxsidebarwrapper">
|
<meta charset="utf-8" />
|
||||||
{%- block sidebarlogo %}
|
{%- if READTHEDOCS and not embedded %}
|
||||||
{%- if logo %}
|
<meta name="readthedocs-addons-api-version" content="1">
|
||||||
<p class="logo"><a href="{{ pathto(master_doc) }}">
|
{%- endif %}
|
||||||
<img class="logo" src="{{ pathto('_static/' + logo, 1) }}" alt="Logo"/>
|
{{- metatags }}
|
||||||
</a></p>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
{%- endif %}
|
{%- block htmltitle %}
|
||||||
{%- endblock %}
|
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
||||||
{%- if sidebars != None %}
|
{%- endblock -%}
|
||||||
{#- 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 %}
|
|
||||||
|
|
||||||
{%- macro script() %}
|
{#- CSS #}
|
||||||
<script type="text/javascript">
|
{%- for css_file in css_files %}
|
||||||
var DOCUMENTATION_OPTIONS = {
|
{%- if css_file|attr("filename") %}
|
||||||
URL_ROOT: '{{ url_root }}',
|
{{ css_tag(css_file) }}
|
||||||
VERSION: '{{ release|e }}',
|
{%- else %}
|
||||||
COLLAPSE_INDEX: false,
|
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
|
||||||
FILE_SUFFIX: '{{ '' if no_search_suffix else file_suffix }}',
|
{%- endif %}
|
||||||
HAS_SOURCE: {{ has_source|lower }},
|
{%- endfor %}
|
||||||
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
|
|
||||||
};
|
{#
|
||||||
</script>
|
"extra_css_files" is an undocumented Read the Docs theme specific option.
|
||||||
|
There is no need to check for ``|attr("filename")`` here because it's always a string.
|
||||||
|
Note that this option should be removed in favor of regular ``html_css_files``:
|
||||||
|
https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_css_files
|
||||||
|
#}
|
||||||
|
{%- for css_file in extra_css_files %}
|
||||||
|
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
|
||||||
|
{%- endfor -%}
|
||||||
|
|
||||||
|
{#- FAVICON #}
|
||||||
|
{%- if favicon_url %}
|
||||||
|
<link rel="shortcut icon" href="{{ favicon_url }}"/>
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{#- CANONICAL URL (deprecated) #}
|
||||||
|
{%- if theme_canonical_url and not pageurl %}
|
||||||
|
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{#- CANONICAL URL #}
|
||||||
|
{%- if pageurl %}
|
||||||
|
<link rel="canonical" href="{{ pageurl|e }}" />
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{#- JAVASCRIPTS #}
|
||||||
|
{%- block scripts %}
|
||||||
|
{%- if not embedded %}
|
||||||
{%- for scriptfile in script_files %}
|
{%- for scriptfile in script_files %}
|
||||||
<script type="text/javascript" src="{{ pathto(scriptfile, 1) }}"></script>
|
{{ js_tag(scriptfile) }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
{%- endmacro %}
|
<script src="{{ pathto('_static/js/theme.js', 1) }}"></script>
|
||||||
|
|
||||||
{%- macro css() %}
|
{%- if READTHEDOCS or DEBUG %}
|
||||||
<link rel="stylesheet" href="{{ pathto('_static/' + style, 1) }}" type="text/css" />
|
<script src="{{ pathto('_static/js/versions.js', 1) }}"></script>
|
||||||
<link rel="stylesheet" href="{{ pathto('_static/pygments.css', 1) }}" type="text/css" />
|
{%- endif %}
|
||||||
{%- for cssfile in css_files %}
|
|
||||||
<link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" />
|
|
||||||
{%- endfor %}
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
{#- OPENSEARCH #}
|
||||||
<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 %}
|
||||||
{%- if favicon %}
|
{%- endif %}
|
||||||
<link rel="shortcut icon" href="{{ pathto('_static/' + favicon, 1) }}"/>
|
{%- endblock %}
|
||||||
{%- endif %}
|
|
||||||
{%- if theme_canonical_url %}
|
{%- block linktags %}
|
||||||
<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 %}
|
||||||
@@ -143,67 +93,135 @@
|
|||||||
{%- 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 %}
|
|
||||||
{%- block extrahead %} {% endblock %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{%- block header %}{% endblock %}
|
|
||||||
|
|
||||||
{%- block relbar1 %}{{ relbar() }}{% endblock %}
|
|
||||||
|
|
||||||
{%- block content %}
|
|
||||||
{%- block sidebar1 %} {# possible location for sidebar #} {% endblock %}
|
|
||||||
|
|
||||||
<div class="document">
|
|
||||||
{%- block document %}
|
|
||||||
<div class="documentwrapper">
|
|
||||||
{%- if render_sidebar %}
|
|
||||||
<div class="bodywrapper">
|
|
||||||
{%- endif %}
|
|
||||||
<div class="body">
|
|
||||||
{% block body %} {% endblock %}
|
|
||||||
</div>
|
|
||||||
{%- if render_sidebar %}
|
|
||||||
</div>
|
|
||||||
{%- endif %}
|
|
||||||
</div>
|
|
||||||
{%- endblock %}
|
{%- endblock %}
|
||||||
|
{%- block extrahead %} {% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
{%- block sidebar2 %}{{ sidebar() }}{% endblock %}
|
<body class="wy-body-for-nav">
|
||||||
<div class="clearer"></div>
|
|
||||||
</div>
|
|
||||||
{%- endblock %}
|
|
||||||
|
|
||||||
{%- block relbar2 %}{{ relbar() }}{% endblock %}
|
{%- block extrabody %} {% endblock %}
|
||||||
|
<div class="wy-grid-for-nav">
|
||||||
|
{#- SIDE NAV, TOGGLES ON MOBILE #}
|
||||||
|
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
|
||||||
|
<div class="wy-side-scroll">
|
||||||
|
<div class="wy-side-nav-search" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}>
|
||||||
|
{%- block sidebartitle %}
|
||||||
|
|
||||||
|
{# the logo helper function was removed in Sphinx 6 and deprecated since Sphinx 4 #}
|
||||||
|
{# the master_doc variable was renamed to root_doc in Sphinx 4 (master_doc still exists in later Sphinx versions) #}
|
||||||
|
{%- set _logo_url = logo_url|default(pathto('_static/' + (logo or ""), 1)) %}
|
||||||
|
{%- set _root_doc = root_doc|default(master_doc) %}
|
||||||
|
<a href="{{ pathto(_root_doc) }}"{% if not theme_logo_only %} class="icon icon-home"{% endif %}>
|
||||||
|
{% if not theme_logo_only %}{{ project }}{% endif %}
|
||||||
|
{%- if logo or logo_url %}
|
||||||
|
<img src="{{ _logo_url }}" class="logo" alt="{{ _('Logo') }}"/>
|
||||||
|
{%- endif %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{%- if READTHEDOCS or DEBUG %}
|
||||||
|
{%- if theme_version_selector or theme_language_selector %}
|
||||||
|
<div class="switch-menus">
|
||||||
|
<div class="version-switch"></div>
|
||||||
|
<div class="language-switch"></div>
|
||||||
|
</div>
|
||||||
|
{%- endif %}
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- include "searchbox.html" %}
|
||||||
|
|
||||||
|
{%- endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{%- block navigation %}
|
||||||
|
{#- Translators: This is an ARIA section label for the main navigation menu -#}
|
||||||
|
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="{{ _('Navigation menu') }}">
|
||||||
|
{%- block menu %}
|
||||||
|
{%- set toctree = toctree(maxdepth=theme_navigation_depth|int,
|
||||||
|
collapse=theme_collapse_navigation|tobool,
|
||||||
|
includehidden=theme_includehidden|tobool,
|
||||||
|
titles_only=theme_titles_only|tobool) %}
|
||||||
|
{%- if toctree %}
|
||||||
|
{{ toctree }}
|
||||||
|
{%- else %}
|
||||||
|
<!-- Local TOC -->
|
||||||
|
<div class="local-toc">{{ toc }}</div>
|
||||||
|
{%- endif %}
|
||||||
|
{%- endblock %}
|
||||||
|
</div>
|
||||||
|
{%- endblock %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
|
||||||
|
|
||||||
|
{#- MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #}
|
||||||
|
{#- Translators: This is an ARIA section label for the navigation menu that is visible when viewing the page on mobile devices -#}
|
||||||
|
<nav class="wy-nav-top" aria-label="{{ _('Mobile navigation menu') }}" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}>
|
||||||
|
{%- block mobile_nav %}
|
||||||
|
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
|
||||||
|
<a href="{{ pathto(master_doc) }}">{{ project }}</a>
|
||||||
|
{%- endblock %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="wy-nav-content">
|
||||||
|
{%- block content %}
|
||||||
|
{%- if theme_style_external_links|tobool %}
|
||||||
|
<div class="rst-content style-external-links">
|
||||||
|
{%- else %}
|
||||||
|
<div class="rst-content">
|
||||||
|
{%- endif %}
|
||||||
|
{% include "breadcrumbs.html" %}
|
||||||
|
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
|
||||||
|
{%- block document %}
|
||||||
|
<div itemprop="articleBody">
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{%- if self.comments()|trim %}
|
||||||
|
<div class="articleComments">
|
||||||
|
{%- block comments %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{%- endif%}
|
||||||
|
</div>
|
||||||
|
{%- endblock %}
|
||||||
|
{% include "footer.html" %}
|
||||||
|
</div>
|
||||||
|
{%- endblock %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% include "versions.html" -%}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
jQuery(function () {
|
||||||
|
SphinxRtdTheme.Navigation.enable({{ 'true' if theme_sticky_navigation|tobool else 'false' }});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#- Do not conflict with RTD insertion of analytics script #}
|
||||||
|
{%- if not READTHEDOCS %}
|
||||||
|
{%- if theme_analytics_id %}
|
||||||
|
<!-- Theme Analytics -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id={{ theme_analytics_id }}"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
|
||||||
|
gtag('config', '{{ theme_analytics_id }}', {
|
||||||
|
'anonymize_ip': {{ 'true' if theme_analytics_anonymize_ip|tobool else 'false' }},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{%- block footer %}
|
|
||||||
<div class="footer">
|
|
||||||
{%- if show_copyright %}
|
|
||||||
{%- if hasdoc('copyright') %}
|
|
||||||
{% trans path=pathto('copyright'), copyright=copyright|e %}© <a href="{{ path }}">Copyright</a> {{ copyright }}.{% endtrans %}
|
|
||||||
{%- else %}
|
|
||||||
{% trans copyright=copyright|e %}© Copyright {{ copyright }}.{% endtrans %}
|
|
||||||
{%- endif %}
|
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- if last_updated %}
|
{%- endif %}
|
||||||
{% 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>
|
||||||
+6
-19
@@ -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 fatures such as working with reusable
|
The ``rsa_pubkey`` is optional any only required for certain features such as working with reusable
|
||||||
media and NFC cryptography.
|
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
|
||||||
@@ -197,10 +197,11 @@ Permissions & security profiles
|
|||||||
|
|
||||||
Device authentication is currently hardcoded to grant the following permissions:
|
Device authentication is currently hardcoded to grant the following permissions:
|
||||||
|
|
||||||
* View event meta data and products etc.
|
* Read event meta data and products etc.
|
||||||
* View orders
|
* Read and write orders
|
||||||
* Change orders
|
* Read and write gift cards
|
||||||
* Manage gift cards
|
* Read and write reusable media
|
||||||
|
* Read vouchers
|
||||||
|
|
||||||
Devices cannot change events or products and cannot access vouchers.
|
Devices cannot change events or products and cannot access vouchers.
|
||||||
|
|
||||||
@@ -208,20 +209,6 @@ 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
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
|||||||
@@ -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 the any object has changed in the meantime, you will receive back a full list
|
``If-Modified-Since`` header. If any object has changed in the meantime, you will receive back a full list
|
||||||
(if something it missing, this means the object has been deleted). If nothing happened, we'll send back a
|
(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.
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ software_brand string Device software
|
|||||||
software_version string Device software version (read-only)
|
software_version string Device software version (read-only)
|
||||||
created datetime Creation time
|
created datetime Creation time
|
||||||
initialized datetime Time of initialization (or ``null``)
|
initialized datetime Time of initialization (or ``null``)
|
||||||
initialization_token string Token for initialization
|
initialization_token string Token for initialization (field invisible without write permission)
|
||||||
revoked boolean Whether this device no longer has access
|
revoked boolean Whether this device no longer has access
|
||||||
security_profile string The name of a supported security profile restricting API access
|
security_profile string The name of a supported security profile restricting API access
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ Endpoints
|
|||||||
|
|
||||||
Returns a list of all events within a given organizer the authenticated user/token has access to.
|
Returns a list of all events within a given organizer the authenticated user/token has access to.
|
||||||
|
|
||||||
Permission required: "Can change event settings"
|
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -161,8 +159,6 @@ Endpoints
|
|||||||
|
|
||||||
Returns information on one event, identified by its slug.
|
Returns information on one event, identified by its slug.
|
||||||
|
|
||||||
Permission required: "Can change event settings"
|
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -234,8 +230,6 @@ Endpoints
|
|||||||
Please note that events cannot be created as 'live' using this endpoint. Quotas and payment must be added to the
|
Please note that events cannot be created as 'live' using this endpoint. Quotas and payment must be added to the
|
||||||
event before sales can go live.
|
event before sales can go live.
|
||||||
|
|
||||||
Permission required: "Can create events"
|
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -338,8 +332,6 @@ Endpoints
|
|||||||
Please note that you can only copy from events under the same organizer this way. Use the ``clone_from`` parameter
|
Please note that you can only copy from events under the same organizer this way. Use the ``clone_from`` parameter
|
||||||
when creating a new event for this instead.
|
when creating a new event for this instead.
|
||||||
|
|
||||||
Permission required: "Can create events"
|
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -433,8 +425,6 @@ Endpoints
|
|||||||
|
|
||||||
Updates an event
|
Updates an event
|
||||||
|
|
||||||
Permission required: "Can change event settings"
|
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -510,8 +500,6 @@ Endpoints
|
|||||||
|
|
||||||
Delete an event. Note that events with orders cannot be deleted to ensure data integrity.
|
Delete an event. Note that events with orders cannot be deleted to ensure data integrity.
|
||||||
|
|
||||||
Permission required: "Can change event settings"
|
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -561,8 +549,6 @@ organizer level.
|
|||||||
|
|
||||||
Get current values of event settings.
|
Get current values of event settings.
|
||||||
|
|
||||||
Permission required: "Can change event settings" (Exception: with device auth, *some* settings can always be *read*.)
|
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -615,6 +601,8 @@ organizer level.
|
|||||||
|
|
||||||
Updates event settings. Note that ``PUT`` is not allowed here, only ``PATCH``.
|
Updates event settings. Note that ``PUT`` is not allowed here, only ``PATCH``.
|
||||||
|
|
||||||
|
Permission "Can change event settings" is always required. Some keys require additional permissions.
|
||||||
|
|
||||||
.. warning::
|
.. warning::
|
||||||
|
|
||||||
Settings can be stored at different levels in pretix. If a value is not set on event level, a default setting
|
Settings can be stored at different levels in pretix. If a value is not set on event level, a default setting
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ cancellation_date datetime Time of order c
|
|||||||
reliable for orders that have been cancelled,
|
reliable for orders that have been cancelled,
|
||||||
reactivated and cancelled again.
|
reactivated and cancelled again.
|
||||||
plugin_data object Additional data added by plugins.
|
plugin_data object Additional data added by plugins.
|
||||||
|
use_gift_cards list of strings List of unique gift card secrets that are used to pay
|
||||||
|
for this order.
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
|
||||||
@@ -156,6 +158,10 @@ plugin_data object Additional data
|
|||||||
|
|
||||||
The ``tax_rounding_mode`` attribute has been added.
|
The ``tax_rounding_mode`` attribute has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 2026.03
|
||||||
|
|
||||||
|
The ``use_gift_cards`` attribute has been added.
|
||||||
|
|
||||||
.. _order-position-resource:
|
.. _order-position-resource:
|
||||||
|
|
||||||
Order position resource
|
Order position resource
|
||||||
@@ -987,8 +993,6 @@ Creating orders
|
|||||||
|
|
||||||
* does not support file upload questions
|
* does not support file upload questions
|
||||||
|
|
||||||
* does not support redeeming gift cards
|
|
||||||
|
|
||||||
* does not support or validate memberships
|
* does not support or validate memberships
|
||||||
|
|
||||||
|
|
||||||
@@ -1095,6 +1099,14 @@ Creating orders
|
|||||||
whether these emails are enabled for certain sales channels. If set to ``null``, behavior will be controlled by pretix'
|
whether these emails are enabled for certain sales channels. If set to ``null``, behavior will be controlled by pretix'
|
||||||
settings based on the sales channels (added in pretix 4.7). Defaults to ``false``.
|
settings based on the sales channels (added in pretix 4.7). Defaults to ``false``.
|
||||||
Used to be ``send_mail`` before pretix 3.14.
|
Used to be ``send_mail`` before pretix 3.14.
|
||||||
|
* ``use_gift_cards`` (optional) The provided gift cards will be used to pay for this order. They will be debited and
|
||||||
|
all the necessary payment records for these transactions will be created. The gift cards will be used in sequence to
|
||||||
|
pay for the order. Processing of the gift cards stops as soon as the order is payed for. All gift card transactions
|
||||||
|
are listed under ``payments`` in the response.
|
||||||
|
This option can only be used with orders that are in the pending state.
|
||||||
|
The ``use_gift_cards`` attribute can not be combined with ``payment_info`` and ``payment_provider`` fields. If the
|
||||||
|
order isn't completely paid after its creation with ``use_gift_cards``, then a subsequent request to the payment
|
||||||
|
endpoint is needed.
|
||||||
|
|
||||||
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
|
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
|
||||||
to incrementing integers starting with ``1``. Then, you can reference one of these
|
to incrementing integers starting with ``1``. Then, you can reference one of these
|
||||||
@@ -1719,6 +1731,56 @@ 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
|
||||||
-----------------------------
|
-----------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -110,8 +110,6 @@ Endpoints
|
|||||||
|
|
||||||
Updates an organizer. Currently only the ``plugins`` field may be updated.
|
Updates an organizer. Currently only the ``plugins`` field may be updated.
|
||||||
|
|
||||||
Permission required: "Can change organizer settings"
|
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -172,8 +170,6 @@ information about the properties.
|
|||||||
|
|
||||||
Get current values of organizer settings.
|
Get current values of organizer settings.
|
||||||
|
|
||||||
Permission required: "Can change organizer settings"
|
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ Endpoints
|
|||||||
.. http:post:: /api/v1/organizers/(organizer)/reusablemedia/lookup/
|
.. http:post:: /api/v1/organizers/(organizer)/reusablemedia/lookup/
|
||||||
|
|
||||||
Look up a new reusable medium by its identifier. In some cases, this might lead to the automatic creation of a new
|
Look up a new reusable medium by its identifier. In some cases, this might lead to the automatic creation of a new
|
||||||
medium behind the scenes.
|
medium behind the scenes, therefore this endpoint requires write permissions.
|
||||||
|
|
||||||
This endpoint, and this endpoint only, might return media from a different organizer if there is a cross-acceptance
|
This endpoint, and this endpoint only, might return media from a different organizer if there is a cross-acceptance
|
||||||
agreement. In this case, only linked gift cards will be returned, no order position or customer records,
|
agreement. In this case, only linked gift cards will be returned, no order position or customer records,
|
||||||
|
|||||||
@@ -154,8 +154,6 @@ Endpoints
|
|||||||
|
|
||||||
Creates a new subevent.
|
Creates a new subevent.
|
||||||
|
|
||||||
Permission required: "Can create events"
|
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -300,8 +298,6 @@ Endpoints
|
|||||||
provide all fields of the resource, other fields will be reset to default. With ``PATCH``, you only need to provide
|
provide all fields of the resource, other fields will be reset to default. With ``PATCH``, you only need to provide
|
||||||
the fields that you want to change.
|
the fields that you want to change.
|
||||||
|
|
||||||
Permission required: "Can change event settings"
|
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
@@ -373,8 +369,6 @@ Endpoints
|
|||||||
|
|
||||||
Delete a sub-event. Note that events with orders cannot be deleted to ensure data integrity.
|
Delete a sub-event. Note that events with orders cannot be deleted to ensure data integrity.
|
||||||
|
|
||||||
Permission required: "Can change event settings"
|
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
.. sourcecode:: http
|
.. sourcecode:: http
|
||||||
|
|||||||
+72
-15
@@ -24,21 +24,58 @@ all_events boolean Whether this te
|
|||||||
limit_events list List of event slugs this team has access to
|
limit_events list List of event slugs this team has access to
|
||||||
require_2fa boolean Whether members of this team are required to use
|
require_2fa boolean Whether members of this team are required to use
|
||||||
two-factor authentication
|
two-factor authentication
|
||||||
can_create_events boolean
|
all_event_permissions bool Whether members of this team are granted all event-level
|
||||||
can_change_teams boolean
|
permissions, including future additions
|
||||||
can_change_organizer_settings boolean
|
limit_event_permissions list of strings The event-level permissions team members are granted
|
||||||
can_manage_customers boolean
|
all_organizer_permissions bool Whether members of this team are granted all organizer-level
|
||||||
can_manage_reusable_media boolean
|
permissions, including future additions
|
||||||
can_manage_gift_cards boolean
|
all_organizer_permissions list of strings The organizer-level permissions team members are granted
|
||||||
can_change_event_settings boolean
|
can_create_events boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``.
|
||||||
can_change_items boolean
|
can_change_teams boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``.
|
||||||
can_view_orders boolean
|
can_change_organizer_settings boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``.
|
||||||
can_change_orders boolean
|
can_manage_customers boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``.
|
||||||
can_view_vouchers boolean
|
can_manage_reusable_media boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``.
|
||||||
can_change_vouchers boolean
|
can_manage_gift_cards boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``.
|
||||||
can_checkin_orders boolean
|
can_change_event_settings boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``.
|
||||||
|
can_change_items boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``.
|
||||||
|
can_view_orders boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``.
|
||||||
|
can_change_orders boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``.
|
||||||
|
can_view_vouchers boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``.
|
||||||
|
can_change_vouchers boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``.
|
||||||
|
can_checkin_orders boolean **DEPRECATED**. Legacy interface, use ``limit_event_permissions``.
|
||||||
===================================== ========================== =======================================================
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
Possible values for ``limit_organizer_permissions`` defined in the core pretix system (plugins might add more)::
|
||||||
|
|
||||||
|
organizer.events:create
|
||||||
|
organizer.settings.general:write
|
||||||
|
organizer.teams:write
|
||||||
|
organizer.seatingplans:write
|
||||||
|
organizer.giftcards:read
|
||||||
|
organizer.giftcards:write
|
||||||
|
organizer.customers:read
|
||||||
|
organizer.customers:write
|
||||||
|
organizer.reusablemedia:read
|
||||||
|
organizer.reusablemedia:write
|
||||||
|
organizer.devices:read
|
||||||
|
organizer.devices:write
|
||||||
|
organizer.outgoingmails:read
|
||||||
|
|
||||||
|
Possible values for ``limit_event_permissions`` defined in the core pretix system (plugins might add more)::
|
||||||
|
|
||||||
|
event.settings.general:write
|
||||||
|
event.settings.payment:write
|
||||||
|
event.settings.tax:write
|
||||||
|
event.settings.invoicing:write
|
||||||
|
event.subevents:write
|
||||||
|
event.items:write
|
||||||
|
event.orders:read
|
||||||
|
event.orders:write
|
||||||
|
event.orders:checkin
|
||||||
|
event.vouchers:read
|
||||||
|
event.vouchers:write
|
||||||
|
event:cancel
|
||||||
|
|
||||||
Team member resource
|
Team member resource
|
||||||
--------------------
|
--------------------
|
||||||
|
|
||||||
@@ -121,6 +158,10 @@ Team endpoints
|
|||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
"require_2fa": true,
|
"require_2fa": true,
|
||||||
|
"all_event_permissions": true,
|
||||||
|
"limit_event_permissions": [],
|
||||||
|
"all_organizer_permissions": true,
|
||||||
|
"limit_organizer_permissions": [],
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@@ -159,6 +200,10 @@ Team endpoints
|
|||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
"require_2fa": true,
|
"require_2fa": true,
|
||||||
|
"all_event_permissions": true,
|
||||||
|
"limit_event_permissions": [],
|
||||||
|
"all_organizer_permissions": true,
|
||||||
|
"limit_organizer_permissions": [],
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@@ -187,7 +232,10 @@ Team endpoints
|
|||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
"require_2fa": true,
|
"require_2fa": true,
|
||||||
"can_create_events": true,
|
"all_event_permissions": true,
|
||||||
|
"limit_event_permissions": [],
|
||||||
|
"all_organizer_permissions": true,
|
||||||
|
"limit_organizer_permissions": [],
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +253,10 @@ Team endpoints
|
|||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
"require_2fa": true,
|
"require_2fa": true,
|
||||||
|
"all_event_permissions": true,
|
||||||
|
"limit_event_permissions": [],
|
||||||
|
"all_organizer_permissions": true,
|
||||||
|
"limit_organizer_permissions": [],
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
@@ -232,7 +284,8 @@ Team endpoints
|
|||||||
Content-Length: 94
|
Content-Length: 94
|
||||||
|
|
||||||
{
|
{
|
||||||
"can_create_events": true
|
"all_organizer_permissions": false,
|
||||||
|
"limit_organizer_permissions": ["organizer.events:create"]
|
||||||
}
|
}
|
||||||
|
|
||||||
**Example response**:
|
**Example response**:
|
||||||
@@ -249,6 +302,10 @@ Team endpoints
|
|||||||
"all_events": true,
|
"all_events": true,
|
||||||
"limit_events": [],
|
"limit_events": [],
|
||||||
"require_2fa": true,
|
"require_2fa": true,
|
||||||
|
"all_event_permissions": true,
|
||||||
|
"limit_event_permissions": [],
|
||||||
|
"all_organizer_permissions": false,
|
||||||
|
"limit_organizer_permissions": ["organizer.events:create"],
|
||||||
"can_create_events": true,
|
"can_create_events": true,
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ 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``
|
||||||
|
|||||||
@@ -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 499.98). This becomes a problem when juristictions, data formats, or external systems expect this calculation
|
(instead of 500.00). This becomes a problem when juristictions, data formats, or external systems expect this calculation
|
||||||
to work on the level of the entire order. A prominent example is the EN 16931 standard for e-invoicing that
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -55,12 +55,12 @@ your views:
|
|||||||
)
|
)
|
||||||
|
|
||||||
class AdminView(EventPermissionRequiredMixin, View):
|
class AdminView(EventPermissionRequiredMixin, View):
|
||||||
permission = 'can_view_orders'
|
permission = 'event.orders:read'
|
||||||
|
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
@event_permission_required('can_view_orders')
|
@event_permission_required('event.orders:read')
|
||||||
def admin_view(request, organizer, event):
|
def admin_view(request, organizer, event):
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ event-related views, there is also a signal that allows you to add the view to t
|
|||||||
@receiver(nav_event, dispatch_uid='friends_tickets_nav')
|
@receiver(nav_event, dispatch_uid='friends_tickets_nav')
|
||||||
def navbar_info(sender, request, **kwargs):
|
def navbar_info(sender, request, **kwargs):
|
||||||
url = resolve(request.path_info)
|
url = resolve(request.path_info)
|
||||||
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_vouchers'):
|
if not request.user.has_event_permission(request.organizer, request.event, 'event.vouchers:read'):
|
||||||
return []
|
return []
|
||||||
return [{
|
return [{
|
||||||
'label': _('My plugin view'),
|
'label': _('My plugin view'),
|
||||||
@@ -118,7 +118,7 @@ for good integration. If you just want to display a form, you could do it like t
|
|||||||
|
|
||||||
class MySettingsView(EventSettingsViewMixin, EventSettingsFormView):
|
class MySettingsView(EventSettingsViewMixin, EventSettingsFormView):
|
||||||
model = Event
|
model = Event
|
||||||
permission = 'can_change_settings'
|
permission = 'event.settings.general:write'
|
||||||
form_class = MySettingsForm
|
form_class = MySettingsForm
|
||||||
template_name = 'my_plugin/settings.html'
|
template_name = 'my_plugin/settings.html'
|
||||||
|
|
||||||
@@ -204,13 +204,13 @@ In case of ``orga_router`` and ``event_router``, permission checking is done for
|
|||||||
in the control panel. However, you need to make sure on your own only to return the correct subset of data! ``request
|
in the control panel. However, you need to make sure on your own only to return the correct subset of data! ``request
|
||||||
.event`` and ``request.organizer`` are available as usual.
|
.event`` and ``request.organizer`` are available as usual.
|
||||||
|
|
||||||
To require a special permission like ``can_view_orders``, you do not need to inherit from a special ViewSet base
|
To require a special permission like ``event.orders:read``, you do not need to inherit from a special ViewSet base
|
||||||
class, you can just set the ``permission`` attribute on your viewset:
|
class, you can just set the ``permission`` attribute on your viewset:
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
class MyViewSet(ModelViewSet):
|
class MyViewSet(ModelViewSet):
|
||||||
permission = 'can_view_orders'
|
permission = 'event.orders:read'
|
||||||
...
|
...
|
||||||
|
|
||||||
If you want to check the permission only for some methods of your viewset, you have to do it yourself. Note here that
|
If you want to check the permission only for some methods of your viewset, you have to do it yourself. Note here that
|
||||||
@@ -220,7 +220,7 @@ following:
|
|||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user)
|
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user)
|
||||||
if perm_holder.has_event_permission(request.event.organizer, request.event, 'can_view_orders'):
|
if perm_holder.has_event_permission(request.event.organizer, request.event, 'event.orders:read'):
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -80,8 +80,24 @@ The exporter class
|
|||||||
|
|
||||||
.. autoattribute:: category
|
.. autoattribute:: category
|
||||||
|
|
||||||
|
.. autoattribute:: feature
|
||||||
|
|
||||||
.. autoattribute:: export_form_fields
|
.. autoattribute:: export_form_fields
|
||||||
|
|
||||||
|
.. autoattribute:: repeatable_read
|
||||||
|
|
||||||
.. automethod:: render
|
.. automethod:: render
|
||||||
|
|
||||||
This is an abstract method, you **must** override this!
|
This is an abstract method, you **must** override this!
|
||||||
|
|
||||||
|
.. automethod:: available_for_user
|
||||||
|
|
||||||
|
.. automethod:: get_required_event_permission
|
||||||
|
|
||||||
|
On organizer level, by default exporters are expected to handle on a *set of events* and the system will automatically
|
||||||
|
add a form field that allows the selection of events, limited to events the user has correct permissions for. If this
|
||||||
|
does not fit your organizer, because it is not related to events, you should **also** inherit from the following class:
|
||||||
|
|
||||||
|
.. class:: pretix.base.exporter.OrganizerLevelExportMixin
|
||||||
|
|
||||||
|
.. automethod:: get_required_organizer_permission
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ Core
|
|||||||
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification,
|
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification,
|
||||||
item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter,
|
item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter,
|
||||||
register_ticket_secret_generators, gift_card_transaction_display,
|
register_ticket_secret_generators, gift_card_transaction_display,
|
||||||
register_text_placeholders, register_mail_placeholders, device_info_updated
|
register_text_placeholders, register_mail_placeholders, device_info_updated,
|
||||||
|
register_event_permission_groups, register_organizer_permission_groups
|
||||||
|
|
||||||
Order events
|
Order events
|
||||||
""""""""""""
|
""""""""""""
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ A simple implementation could look like this:
|
|||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
class MyNotificationType(NotificationType):
|
class MyNotificationType(NotificationType):
|
||||||
required_permission = "can_view_orders"
|
required_permission = "event.orders:read"
|
||||||
action_type = "pretix.event.order.paid"
|
action_type = "pretix.event.order.paid"
|
||||||
verbose_name = _("Order has been paid")
|
verbose_name = _("Order has been paid")
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ Permissions
|
|||||||
===========
|
===========
|
||||||
|
|
||||||
pretix uses a fine-grained permission system to control who is allowed to control what parts of the system.
|
pretix uses a fine-grained permission system to control who is allowed to control what parts of the system.
|
||||||
The central concept here is the concept of *Teams*. You can read more on `configuring teams and permissions <user-teams>`_
|
The central concept here is the concept of *Teams*. You can read more on `configuring teams and permissions`_
|
||||||
and the :class:`pretix.base.models.Team` model in the respective parts of the documentation. The basic digest is:
|
and the :class:`pretix.base.models.Team` model in the respective parts of the documentation. The basic digest is:
|
||||||
An organizer account can have any number of teams, and any number of users can be part of a team. A team can be
|
An organizer account can have any number of teams, and any number of users can be part of a team. A team can be
|
||||||
assigned a set of permissions and connected to some or all of the events of the organizer.
|
assigned a set of permissions and connected to some or all of the events of the organizer.
|
||||||
@@ -25,8 +25,8 @@ permission level to access a view:
|
|||||||
|
|
||||||
|
|
||||||
class MyOrgaView(OrganizerPermissionRequiredMixin, View):
|
class MyOrgaView(OrganizerPermissionRequiredMixin, View):
|
||||||
permission = 'can_change_organizer_settings'
|
permission = 'organizer.settings.general:write'
|
||||||
# Only users with the permission ``can_change_organizer_settings`` on
|
# Only users with the permission ``organizer.settings.general:write`` on
|
||||||
# this organizer can access this
|
# this organizer can access this
|
||||||
|
|
||||||
|
|
||||||
@@ -35,9 +35,9 @@ permission level to access a view:
|
|||||||
# Only users with *any* permission on this organizer can access this
|
# Only users with *any* permission on this organizer can access this
|
||||||
|
|
||||||
|
|
||||||
@organizer_permission_required('can_change_organizer_settings')
|
@organizer_permission_required('organizer.settings.general:write')
|
||||||
def my_orga_view(request, organizer, **kwargs):
|
def my_orga_view(request, organizer, **kwargs):
|
||||||
# Only users with the permission ``can_change_organizer_settings`` on
|
# Only users with the permission ``organizer.settings.general:write`` on
|
||||||
# this organizer can access this
|
# this organizer can access this
|
||||||
|
|
||||||
|
|
||||||
@@ -56,8 +56,8 @@ Of course, the same is available on event level:
|
|||||||
|
|
||||||
|
|
||||||
class MyEventView(EventPermissionRequiredMixin, View):
|
class MyEventView(EventPermissionRequiredMixin, View):
|
||||||
permission = 'can_change_event_settings'
|
permission = 'event.settings.general:write'
|
||||||
# Only users with the permission ``can_change_event_settings`` on
|
# Only users with the permission ``event.settings.general:write`` on
|
||||||
# this event can access this
|
# this event can access this
|
||||||
|
|
||||||
|
|
||||||
@@ -65,13 +65,16 @@ Of course, the same is available on event level:
|
|||||||
permission = None
|
permission = None
|
||||||
# Only users with *any* permission on this event can access this
|
# Only users with *any* permission on this event can access this
|
||||||
|
|
||||||
|
class MyThirdEventView(EventPermissionRequiredMixin, View):
|
||||||
|
permission = AnyPermissionOf('event.settings.payment:write', 'event.settings.general:write')
|
||||||
|
# Only users with at least one of the specified permissions on this event
|
||||||
|
# can access this
|
||||||
|
|
||||||
@event_permission_required('can_change_event_settings')
|
@event_permission_required('event.settings.general:write')
|
||||||
def my_event_view(request, organizer, **kwargs):
|
def my_event_view(request, organizer, **kwargs):
|
||||||
# Only users with the permission ``can_change_event_settings`` on
|
# Only users with the permission ``event.settings.general:write`` on
|
||||||
# this event can access this
|
# this event can access this
|
||||||
|
|
||||||
|
|
||||||
@event_permission_required()
|
@event_permission_required()
|
||||||
def my_other_event_view(request, organizer, **kwargs):
|
def my_other_event_view(request, organizer, **kwargs):
|
||||||
# Only users with *any* permission on this event can access this
|
# Only users with *any* permission on this event can access this
|
||||||
@@ -121,7 +124,7 @@ When creating your own ``viewset`` using Django REST framework, you just need to
|
|||||||
and pretix will check it automatically for you::
|
and pretix will check it automatically for you::
|
||||||
|
|
||||||
class MyModelViewSet(viewsets.ReadOnlyModelViewSet):
|
class MyModelViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
permission = 'can_view_orders'
|
permission = 'event.orders:read'
|
||||||
|
|
||||||
Checking permission in code
|
Checking permission in code
|
||||||
---------------------------
|
---------------------------
|
||||||
@@ -136,12 +139,12 @@ Return all users that are in any team that is connected to this event::
|
|||||||
|
|
||||||
Return all users that are in a team with a specific permission for this event::
|
Return all users that are in a team with a specific permission for this event::
|
||||||
|
|
||||||
>>> event.get_users_with_permission('can_change_event_settings')
|
>>> event.get_users_with_permission('event.orders:read')
|
||||||
<QuerySet: …>
|
<QuerySet: …>
|
||||||
|
|
||||||
Determine if a user has a certain permission for a specific event::
|
Determine if a user has a certain permission for a specific event::
|
||||||
|
|
||||||
>>> user.has_event_permission(organizer, event, 'can_change_event_settings', request=request)
|
>>> user.has_event_permission(organizer, event, 'event.orders:read', request=request)
|
||||||
True
|
True
|
||||||
|
|
||||||
Determine if a user has any permission for a specific event::
|
Determine if a user has any permission for a specific event::
|
||||||
@@ -153,27 +156,27 @@ In the two previous commands, the ``request`` argument is optional, but required
|
|||||||
|
|
||||||
The same method exists for organizer-level permissions::
|
The same method exists for organizer-level permissions::
|
||||||
|
|
||||||
>>> user.has_organizer_permission(organizer, 'can_change_event_settings', request=request)
|
>>> user.has_organizer_permission(organizer, 'event.orders:read', request=request)
|
||||||
True
|
True
|
||||||
|
|
||||||
Sometimes, it might be more useful to get the set of permissions at once::
|
Sometimes, it might be more useful to get the set of permissions at once::
|
||||||
|
|
||||||
>>> user.get_event_permission_set(organizer, event)
|
>>> user.get_event_permission_set(organizer, event)
|
||||||
{'can_change_event_settings', 'can_view_orders', 'can_change_orders'}
|
{'event.settings.general:write', 'event.orders:read', 'event.orders:write'}
|
||||||
|
|
||||||
>>> user.get_organizer_permission_set(organizer, event)
|
>>> user.get_organizer_permission_set(organizer, event)
|
||||||
{'can_change_organizer_settings', 'can_create_events'}
|
{'organizer.settings.general:write', 'organizer.events:create'}
|
||||||
|
|
||||||
Within a view on the ``/control`` subpath, the results of these two methods are already available in the
|
Within a view on the ``/control`` subpath, the results of these two methods are already available in the
|
||||||
``request.eventpermset`` and ``request.orgapermset`` properties. This makes it convenient to query them in templates::
|
``request.eventpermset`` and ``request.orgapermset`` properties. This makes it convenient to query them in templates::
|
||||||
|
|
||||||
{% if "can_change_orders" in request.eventpermset %}
|
{% if "event.orders:write" in request.eventpermset %}
|
||||||
…
|
…
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
You can also do the reverse to get any events a user has access to::
|
You can also do the reverse to get any events a user has access to::
|
||||||
|
|
||||||
>>> user.get_events_with_permission('can_change_event_settings', request=request)
|
>>> user.get_events_with_permission('event.settings.general:write', request=request)
|
||||||
<QuerySet: …>
|
<QuerySet: …>
|
||||||
|
|
||||||
>>> user.get_events_with_any_permission(request=request)
|
>>> user.get_events_with_any_permission(request=request)
|
||||||
@@ -195,3 +198,53 @@ staff mode is active. You can check if a user is in staff mode using their sessi
|
|||||||
Staff mode has a hard time limit and during staff mode, a middleware will log all requests made by that user. Later,
|
Staff mode has a hard time limit and during staff mode, a middleware will log all requests made by that user. Later,
|
||||||
the user is able to also save a message to comment on what they did in their administrative session. This feature is
|
the user is able to also save a message to comment on what they did in their administrative session. This feature is
|
||||||
intended to help compliance with data protection rules as imposed e.g. by GDPR.
|
intended to help compliance with data protection rules as imposed e.g. by GDPR.
|
||||||
|
|
||||||
|
Adding permissions
|
||||||
|
------------------
|
||||||
|
|
||||||
|
Plugins can add permissions through the ``register_event_permission_groups`` and ``register_organizer_permission_groups``.
|
||||||
|
We recommend to use this only for very significant permissions, as the system will become less usable with too many
|
||||||
|
permission levels, also because the team page will show all permission options, even those of disabled plugins.
|
||||||
|
|
||||||
|
To register your permissions, you need to register a **permission group** (often representing an area of functionality
|
||||||
|
or a key model). Below that group, there are **actions**, which represent the actual permissions. Permissions will be
|
||||||
|
generated as ``<group_name>:<action>``. Then, you need to define **options** which are the valid combinations of the
|
||||||
|
actions that should be possible to select for a team. This two-step mechanism exists to provide a better user experience
|
||||||
|
and avoid useless combinations like "write but not read".
|
||||||
|
|
||||||
|
Example::
|
||||||
|
|
||||||
|
@receiver(register_event_permission_groups)
|
||||||
|
def register_plugin_event_permissions(sender, **kwargs):
|
||||||
|
return [
|
||||||
|
PermissionGroup(
|
||||||
|
name="pretix_myplugin.resource",
|
||||||
|
label=_("Resources"),
|
||||||
|
actions=["read", "write"],
|
||||||
|
options=[
|
||||||
|
PermissionOption(actions=tuple(), label=_("No access")),
|
||||||
|
PermissionOption(actions=("read",), label=_("View")),
|
||||||
|
PermissionOption(actions=("read", "write"), label=_("View and change")),
|
||||||
|
],
|
||||||
|
help_text=_("Some help text")
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(register_organizer_permission_groups)
|
||||||
|
def register_plugin_organizer_permissions(sender, **kwargs):
|
||||||
|
return [
|
||||||
|
PermissionGroup(
|
||||||
|
name="pretix_myplugin.resource",
|
||||||
|
label=_("Resources"),
|
||||||
|
actions=["read", "write"],
|
||||||
|
options=[
|
||||||
|
PermissionOption(actions=tuple(), label=_("No access")),
|
||||||
|
PermissionOption(actions=("read",), label=_("View")),
|
||||||
|
PermissionOption(actions=("read", "write"), label=_("View and change")),
|
||||||
|
],
|
||||||
|
help_text=_("Some help text")
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
.. _configuring teams and permissions: https://docs.pretix.eu/guides/teams/
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
sphinx==7.4.*
|
sphinx==9.1.*
|
||||||
jinja2==3.1.*
|
sphinx-rtd-theme~=3.1.0
|
||||||
sphinx-rtd-theme
|
sphinxcontrib-httpdomain~=2.0.0
|
||||||
sphinxcontrib-httpdomain
|
sphinxcontrib-images~=1.0.1
|
||||||
sphinxcontrib-images
|
sphinxcontrib-jquery~=4.1
|
||||||
sphinxcontrib-jquery
|
sphinxcontrib-spelling~=8.0.2
|
||||||
sphinxcontrib-spelling==8.*
|
sphinxemoji~=0.3.2
|
||||||
sphinxemoji
|
|
||||||
pyenchant==3.3.*
|
pyenchant==3.3.*
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
-e ../
|
-e ../
|
||||||
sphinx==7.4.*
|
sphinx==9.1.*
|
||||||
jinja2==3.1.*
|
sphinx-rtd-theme~=3.1.0
|
||||||
sphinx-rtd-theme
|
sphinxcontrib-httpdomain~=2.0.0
|
||||||
sphinxcontrib-httpdomain
|
sphinxcontrib-images~=1.0.1
|
||||||
sphinxcontrib-images
|
sphinxcontrib-jquery~=4.1
|
||||||
sphinxcontrib-jquery
|
sphinxcontrib-spelling~=8.0.2
|
||||||
sphinxcontrib-spelling==8.*
|
sphinxemoji~=0.3.2
|
||||||
sphinxemoji
|
|
||||||
pyenchant==3.3.*
|
pyenchant==3.3.*
|
||||||
|
|||||||
+30
-30
@@ -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.9"
|
requires-python = ">=3.11"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
keywords = ["tickets", "web", "shop", "ecommerce"]
|
keywords = ["tickets", "web", "shop", "ecommerce"]
|
||||||
authors = [
|
authors = [
|
||||||
@@ -19,29 +19,30 @@ classifiers = [
|
|||||||
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
||||||
"Environment :: Web Environment",
|
"Environment :: Web Environment",
|
||||||
"License :: OSI Approved :: GNU Affero General Public License v3",
|
"License :: OSI Approved :: GNU Affero General Public License v3",
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Framework :: Django :: 4.2",
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
|
"Framework :: Django :: 5.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
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.2.*",
|
"bleach==6.3.*",
|
||||||
"celery==5.5.*",
|
"celery==5.6.*",
|
||||||
"chardet==5.2.*",
|
"chardet==5.2.*",
|
||||||
"cryptography>=44.0.0",
|
"cryptography>=44.0.0",
|
||||||
"css-inline==0.18.*",
|
"css-inline==0.20.*",
|
||||||
"defusedcsv>=1.1.0",
|
"defusedcsv>=1.1.0",
|
||||||
"dnspython==2.*",
|
"dnspython==2.*",
|
||||||
"Django[argon2]==4.2.*,>=4.2.26",
|
"Django[argon2]==5.2.*",
|
||||||
"django-bootstrap3==25.2",
|
"django-bootstrap3==26.1",
|
||||||
"django-compressor==4.5.1",
|
"django-compressor==4.6.0",
|
||||||
"django-countries==7.6.*",
|
"django-countries==8.2.*",
|
||||||
"django-filter==25.1",
|
"django-filter==25.1",
|
||||||
"django-formset-js-improved==0.5.0.4",
|
"django-formset-js-improved==0.5.0.5",
|
||||||
"django-formtools==2.5.1",
|
"django-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 +51,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.6.*",
|
"django-otp==1.7.*",
|
||||||
"django-phonenumber-field==7.3.*",
|
"django-phonenumber-field==8.4.*",
|
||||||
"django-redis==6.0.*",
|
"django-redis==6.0.*",
|
||||||
"django-scopes==2.0.*",
|
"django-scopes==2.0.*",
|
||||||
"django-statici18n==2.6.*",
|
"django-statici18n==2.7.*",
|
||||||
"djangorestframework==3.16.*",
|
"djangorestframework==3.16.*",
|
||||||
"dnspython==2.7.*",
|
"dnspython==2.8.*",
|
||||||
"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==9.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||||
"isoweek",
|
"isoweek",
|
||||||
"jsonschema",
|
"jsonschema",
|
||||||
"kombu==5.5.*",
|
"kombu==5.6.*",
|
||||||
"libsass==0.23.*",
|
"libsass==0.23.*",
|
||||||
"lxml",
|
"lxml",
|
||||||
"markdown==3.9", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
"markdown==3.10.2", # 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,33 +74,32 @@ dependencies = [
|
|||||||
"packaging",
|
"packaging",
|
||||||
"paypalrestsdk==1.13.*",
|
"paypalrestsdk==1.13.*",
|
||||||
"paypal-checkout-serversdk==1.0.*",
|
"paypal-checkout-serversdk==1.0.*",
|
||||||
"PyJWT==2.10.*",
|
"PyJWT==2.12.*",
|
||||||
"phonenumberslite==9.0.*",
|
"phonenumberslite==9.0.*",
|
||||||
"Pillow==11.3.*",
|
"Pillow==12.2.*",
|
||||||
"pretix-plugin-build",
|
"pretix-plugin-build",
|
||||||
"protobuf==6.33.*",
|
"protobuf==7.34.*",
|
||||||
"psycopg2-binary",
|
"psycopg2-binary",
|
||||||
"pycountry",
|
"pycountry",
|
||||||
"pycparser==2.23",
|
"pycparser==3.0",
|
||||||
"pycryptodome==3.23.*",
|
"pycryptodome==3.23.*",
|
||||||
"pypdf==6.4.*",
|
"pypdf==6.5.*",
|
||||||
"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==6.4.*",
|
"redis==7.4.*",
|
||||||
"reportlab==4.4.*",
|
"reportlab==4.4.*",
|
||||||
"requests==2.32.*",
|
"requests==2.32.*",
|
||||||
"sentry-sdk==2.46.*",
|
"sentry-sdk==2.57.*",
|
||||||
"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.*"
|
||||||
@@ -111,10 +111,10 @@ dev = [
|
|||||||
"aiohttp==3.13.*",
|
"aiohttp==3.13.*",
|
||||||
"coverage",
|
"coverage",
|
||||||
"coveralls",
|
"coveralls",
|
||||||
"fakeredis==2.32.*",
|
"fakeredis==2.34.*",
|
||||||
"flake8==7.3.*",
|
"flake8==7.3.*",
|
||||||
"freezegun",
|
"freezegun",
|
||||||
"isort==6.1.*",
|
"isort==8.0.*",
|
||||||
"pep8-naming==0.15.*",
|
"pep8-naming==0.15.*",
|
||||||
"potypo",
|
"potypo",
|
||||||
"pytest-asyncio>=0.24",
|
"pytest-asyncio>=0.24",
|
||||||
@@ -124,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==8.4.*",
|
"pytest==9.0.*",
|
||||||
"responses",
|
"responses",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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__ = "2025.11.0.dev0"
|
__version__ = "2026.4.0.dev0"
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
|
|||||||
|
|
||||||
from pretix.api.models import OAuthAccessToken
|
from pretix.api.models import OAuthAccessToken
|
||||||
from pretix.base.models import Device, Event, User
|
from pretix.base.models import Device, Event, User
|
||||||
from pretix.base.models.auth import SuperuserPermissionSet
|
from pretix.base.models.auth import (
|
||||||
|
EventPermissionSet, OrganizerPermissionSet, SuperuserPermissionSet,
|
||||||
|
)
|
||||||
from pretix.base.models.organizer import TeamAPIToken
|
from pretix.base.models.organizer import TeamAPIToken
|
||||||
from pretix.helpers.security import (
|
from pretix.helpers.security import (
|
||||||
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
|
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
|
||||||
@@ -85,7 +87,7 @@ class EventPermission(BasePermission):
|
|||||||
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
|
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
|
||||||
request.eventpermset = SuperuserPermissionSet()
|
request.eventpermset = SuperuserPermissionSet()
|
||||||
else:
|
else:
|
||||||
request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
|
request.eventpermset = EventPermissionSet(perm_holder.get_event_permission_set(request.organizer, request.event))
|
||||||
|
|
||||||
if isinstance(required_permission, (list, tuple)):
|
if isinstance(required_permission, (list, tuple)):
|
||||||
if not any(p in request.eventpermset for p in required_permission):
|
if not any(p in request.eventpermset for p in required_permission):
|
||||||
@@ -100,7 +102,7 @@ class EventPermission(BasePermission):
|
|||||||
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
|
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
|
||||||
request.orgapermset = SuperuserPermissionSet()
|
request.orgapermset = SuperuserPermissionSet()
|
||||||
else:
|
else:
|
||||||
request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
|
request.orgapermset = OrganizerPermissionSet(perm_holder.get_organizer_permission_set(request.organizer))
|
||||||
|
|
||||||
if isinstance(required_permission, (list, tuple)):
|
if isinstance(required_permission, (list, tuple)):
|
||||||
if not any(p in request.eventpermset for p in required_permission):
|
if not any(p in request.eventpermset for p in required_permission):
|
||||||
@@ -124,12 +126,12 @@ class EventCRUDPermission(EventPermission):
|
|||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
if not super(EventCRUDPermission, self).has_permission(request, view):
|
if not super(EventCRUDPermission, self).has_permission(request, view):
|
||||||
return False
|
return False
|
||||||
elif view.action == 'create' and 'can_create_events' not in request.orgapermset:
|
elif view.action == 'create' and 'organizer.events:create' not in request.orgapermset:
|
||||||
return False
|
return False
|
||||||
elif view.action == 'destroy' and 'can_change_event_settings' not in request.eventpermset:
|
elif view.action == 'destroy' and 'event.settings.general:write' not in request.eventpermset:
|
||||||
return False
|
return False
|
||||||
elif view.action in ['update', 'partial_update'] \
|
elif view.action in ['update', 'partial_update'] \
|
||||||
and 'can_change_event_settings' not in request.eventpermset:
|
and 'event.settings.general:write' not in request.eventpermset:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
|||||||
def ignored_meta_properties(self):
|
def ignored_meta_properties(self):
|
||||||
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
|
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
|
||||||
else self.context['request'].user)
|
else self.context['request'].user)
|
||||||
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'can_change_organizer_settings', request=self.context['request']):
|
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'organizer.settings.general:write', request=self.context['request']):
|
||||||
return []
|
return []
|
||||||
return [k for k, p in self.meta_properties.items() if p.protected]
|
return [k for k, p in self.meta_properties.items() if p.protected]
|
||||||
|
|
||||||
@@ -445,7 +445,7 @@ class CloneEventSerializer(EventSerializer):
|
|||||||
date_admission = validated_data.pop('date_admission', None)
|
date_admission = validated_data.pop('date_admission', None)
|
||||||
new_event = super().create({**validated_data, 'plugins': None})
|
new_event = super().create({**validated_data, 'plugins': None})
|
||||||
|
|
||||||
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
|
event = self.context['event']
|
||||||
new_event.copy_data_from(event, skip_meta_data='meta_data' in validated_data)
|
new_event.copy_data_from(event, skip_meta_data='meta_data' in validated_data)
|
||||||
|
|
||||||
if plugins is not None:
|
if plugins is not None:
|
||||||
@@ -561,7 +561,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
|||||||
def ignored_meta_properties(self):
|
def ignored_meta_properties(self):
|
||||||
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
|
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
|
||||||
else self.context['request'].user)
|
else self.context['request'].user)
|
||||||
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'can_change_organizer_settings', request=self.context['request']):
|
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'organizer.settings.general:write', request=self.context['request']):
|
||||||
return []
|
return []
|
||||||
return [k for k, p in self.meta_properties.items() if p.protected]
|
return [k for k, p in self.meta_properties.items() if p.protected]
|
||||||
|
|
||||||
@@ -707,7 +707,10 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class EventSettingsSerializer(SettingsSerializer):
|
class EventSettingsSerializer(SettingsSerializer):
|
||||||
|
default_write_permission = 'event.settings.general:write'
|
||||||
default_fields = [
|
default_fields = [
|
||||||
|
# These are readable for all users with access to the events, therefore secrets stored in the settings store
|
||||||
|
# should not be included!
|
||||||
'imprint_url',
|
'imprint_url',
|
||||||
'checkout_email_helptext',
|
'checkout_email_helptext',
|
||||||
'presale_has_ended_text',
|
'presale_has_ended_text',
|
||||||
@@ -795,6 +798,7 @@ 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',
|
||||||
@@ -805,6 +809,7 @@ 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',
|
||||||
@@ -943,6 +948,7 @@ 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',
|
||||||
@@ -1077,16 +1083,16 @@ class SeatSerializer(I18nAwareModelSerializer):
|
|||||||
|
|
||||||
def prefetch_expanded_data(self, items, request, expand_fields):
|
def prefetch_expanded_data(self, items, request, expand_fields):
|
||||||
if 'orderposition' in expand_fields:
|
if 'orderposition' in expand_fields:
|
||||||
if 'can_view_orders' not in request.eventpermset:
|
if 'event.orders:read' not in request.eventpermset:
|
||||||
raise PermissionDenied('can_view_orders permission required for expand=orderposition')
|
raise PermissionDenied('event.orders:read permission required for expand=orderposition')
|
||||||
prefetch_by_id(items, OrderPosition.objects.prefetch_related('order'), 'orderposition_id', 'orderposition')
|
prefetch_by_id(items, OrderPosition.objects.prefetch_related('order'), 'orderposition_id', 'orderposition')
|
||||||
if 'cartposition' in expand_fields:
|
if 'cartposition' in expand_fields:
|
||||||
if 'can_view_orders' not in request.eventpermset:
|
if 'event.orders:read' not in request.eventpermset:
|
||||||
raise PermissionDenied('can_view_orders permission required for expand=cartposition')
|
raise PermissionDenied('event.orders:read permission required for expand=cartposition')
|
||||||
prefetch_by_id(items, CartPosition.objects, 'cartposition_id', 'cartposition')
|
prefetch_by_id(items, CartPosition.objects, 'cartposition_id', 'cartposition')
|
||||||
if 'voucher' in expand_fields:
|
if 'voucher' in expand_fields:
|
||||||
if 'can_view_vouchers' not in request.eventpermset:
|
if 'event.vouchers:read' not in request.eventpermset:
|
||||||
raise PermissionDenied('can_view_vouchers permission required for expand=voucher')
|
raise PermissionDenied('event.vouchers:read permission required for expand=voucher')
|
||||||
prefetch_by_id(items, Voucher.objects, 'voucher_id', 'voucher')
|
prefetch_by_id(items, Voucher.objects, 'voucher_id', 'voucher')
|
||||||
|
|
||||||
def __init__(self, instance, *args, **kwargs):
|
def __init__(self, instance, *args, **kwargs):
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ from rest_framework.exceptions import ValidationError
|
|||||||
|
|
||||||
from pretix.api.serializers.forms import form_field_to_serializer_field
|
from pretix.api.serializers.forms import form_field_to_serializer_field
|
||||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||||
from pretix.base.models import ScheduledEventExport, ScheduledOrganizerExport
|
from pretix.base.models import (
|
||||||
|
Event, ScheduledEventExport, ScheduledOrganizerExport,
|
||||||
|
)
|
||||||
from pretix.base.timeframes import SerializerDateFrameField
|
from pretix.base.timeframes import SerializerDateFrameField
|
||||||
|
|
||||||
|
|
||||||
@@ -54,20 +56,29 @@ class ExporterSerializer(serializers.Serializer):
|
|||||||
|
|
||||||
class JobRunSerializer(serializers.Serializer):
|
class JobRunSerializer(serializers.Serializer):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
ex = kwargs.pop('exporter')
|
ex = self.ex = kwargs.pop('exporter')
|
||||||
events = kwargs.pop('events', None)
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
if events is not None and not isinstance(ex, OrganizerLevelExportMixin):
|
if ex.is_multievent and not isinstance(ex, OrganizerLevelExportMixin):
|
||||||
self.fields["events"] = serializers.SlugRelatedField(
|
self.fields["all_events"] = serializers.BooleanField(
|
||||||
queryset=events,
|
|
||||||
required=False,
|
required=False,
|
||||||
allow_empty=False,
|
)
|
||||||
|
self.fields["events"] = serializers.SlugRelatedField(
|
||||||
|
queryset=ex.events,
|
||||||
|
required=False,
|
||||||
|
allow_empty=True,
|
||||||
slug_field='slug',
|
slug_field='slug',
|
||||||
many=True
|
many=True
|
||||||
)
|
)
|
||||||
for k, v in ex.export_form_fields.items():
|
for k, v in ex.export_form_fields.items():
|
||||||
self.fields[k] = form_field_to_serializer_field(v)
|
self.fields[k] = form_field_to_serializer_field(v)
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
# Translate between events as a list of slugs (API) and list of ints (database)
|
||||||
|
if self.ex.is_multievent and not isinstance(self.ex, OrganizerLevelExportMixin) and "events" in instance and isinstance(instance["events"], list):
|
||||||
|
instance["events"] = [e for e in self.ex.events.filter(pk__in=instance["events"])]
|
||||||
|
instance = super().to_representation(instance)
|
||||||
|
return instance
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
if isinstance(data, QueryDict):
|
if isinstance(data, QueryDict):
|
||||||
data = data.copy()
|
data = data.copy()
|
||||||
@@ -95,6 +106,14 @@ class JobRunSerializer(serializers.Serializer):
|
|||||||
data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}'
|
data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}'
|
||||||
|
|
||||||
data = super().to_internal_value(data)
|
data = super().to_internal_value(data)
|
||||||
|
|
||||||
|
# Translate between events as a list of slugs (API) and list of ints (database)
|
||||||
|
if self.ex.is_multievent and not isinstance(self.ex, OrganizerLevelExportMixin) and "events" in data and isinstance(data["events"], list):
|
||||||
|
if data["events"] and isinstance(data["events"][0], Event):
|
||||||
|
data["events"] = [e.pk for e in data["events"]]
|
||||||
|
elif data["events"] and isinstance(data["events"][0], str):
|
||||||
|
data["events"] = [e.pk for e in self.ex.events.filter(slug__in=data["events"]).only("pk")]
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def is_valid(self, raise_exception=False):
|
def is_valid(self, raise_exception=False):
|
||||||
@@ -131,13 +150,20 @@ class ScheduledExportSerializer(serializers.ModelSerializer):
|
|||||||
exporter = self.context['exporters'].get(identifier)
|
exporter = self.context['exporters'].get(identifier)
|
||||||
if exporter:
|
if exporter:
|
||||||
try:
|
try:
|
||||||
JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"])
|
attrs["export_form_data"] = JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"])
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
raise ValidationError({"export_form_data": e.detail})
|
raise ValidationError({"export_form_data": e.detail})
|
||||||
else:
|
else:
|
||||||
raise ValidationError({"export_identifier": ["Unknown exporter."]})
|
raise ValidationError({"export_identifier": ["Unknown exporter."]})
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
repr = super().to_representation(instance)
|
||||||
|
exporter = self.context['exporters'].get(instance.export_identifier)
|
||||||
|
if exporter:
|
||||||
|
repr["export_form_data"] = JobRunSerializer(exporter=exporter).to_representation(repr["export_form_data"])
|
||||||
|
return repr
|
||||||
|
|
||||||
def validate_mail_additional_recipients(self, value):
|
def validate_mail_additional_recipients(self, value):
|
||||||
d = value.replace(' ', '')
|
d = value.replace(' ', '')
|
||||||
if len(d.split(',')) > 25:
|
if len(d.split(',')) > 25:
|
||||||
|
|||||||
@@ -65,8 +65,9 @@ def form_field_to_serializer_field(field):
|
|||||||
if isinstance(field, m_from):
|
if isinstance(field, m_from):
|
||||||
return m_to(
|
return m_to(
|
||||||
required=field.required,
|
required=field.required,
|
||||||
allow_null=not field.required,
|
allow_null=not field.required and not isinstance(field, forms.BooleanField),
|
||||||
validators=field.validators,
|
validators=field.validators,
|
||||||
|
initial=field.initial,
|
||||||
**{kwarg: getattr(field, kwarg, None) for kwarg in m_kwargs}
|
**{kwarg: getattr(field, kwarg, None) for kwarg in m_kwargs}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
|
|
||||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||||
from pretix.api.serializers.order import OrderPositionSerializer
|
from pretix.api.serializers.order import OrderPositionSerializer
|
||||||
@@ -66,6 +66,9 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
|||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
|
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
|
||||||
|
if not self.context["can_read_giftcards"]:
|
||||||
|
raise PermissionDenied("No permission to access gift card details.")
|
||||||
|
|
||||||
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context)
|
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context)
|
||||||
if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'):
|
if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'):
|
||||||
self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
|
self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
|
||||||
@@ -77,6 +80,8 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
|
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
|
||||||
|
# No additional permission check performed, documented limitation of the permission system
|
||||||
|
# Would get to complex/unusable otherwise since the permission depends on the event
|
||||||
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
|
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
|
||||||
else:
|
else:
|
||||||
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
|
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
|
||||||
@@ -86,6 +91,9 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if 'customer' in self.context['request'].query_params.getlist('expand'):
|
if 'customer' in self.context['request'].query_params.getlist('expand'):
|
||||||
|
if not self.context["can_read_customers"]:
|
||||||
|
raise PermissionDenied("No permission to access customer details.")
|
||||||
|
|
||||||
self.fields['customer'] = CustomerSerializer(read_only=True)
|
self.fields['customer'] = CustomerSerializer(read_only=True)
|
||||||
else:
|
else:
|
||||||
self.fields['customer'] = serializers.SlugRelatedField(
|
self.fields['customer'] = serializers.SlugRelatedField(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
# 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
|
||||||
@@ -52,7 +53,7 @@ from pretix.base.decimal import round_decimal
|
|||||||
from pretix.base.i18n import language
|
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 (
|
from pretix.base.models import (
|
||||||
CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
|
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress,
|
||||||
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
|
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
|
||||||
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
|
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
|
||||||
Voucher,
|
Voucher,
|
||||||
@@ -61,6 +62,7 @@ from pretix.base.models.orders import (
|
|||||||
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
|
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||||
PrintLog, RevokedTicketSecret, Transaction,
|
PrintLog, RevokedTicketSecret, Transaction,
|
||||||
)
|
)
|
||||||
|
from pretix.base.payment import GiftCardPayment, PaymentException
|
||||||
from pretix.base.pdf import get_images, get_variables
|
from pretix.base.pdf import get_images, get_variables
|
||||||
from pretix.base.services.cart import error_messages
|
from pretix.base.services.cart import error_messages
|
||||||
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
|
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
|
||||||
@@ -191,7 +193,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.exclusive:
|
elif t.is_exclusive(self.context["request"].event, data.get("country"), data.get("is_business")):
|
||||||
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
|
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." % (
|
||||||
@@ -613,7 +615,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
|||||||
# /events/…/checkinlists/…/positions/
|
# /events/…/checkinlists/…/positions/
|
||||||
# We're unable to check this on this level if we're on /checkinrpc/, in which case we rely on the view
|
# We're unable to check this on this level if we're on /checkinrpc/, in which case we rely on the view
|
||||||
# layer to not set pdf_data=true in the first place.
|
# layer to not set pdf_data=true in the first place.
|
||||||
request and hasattr(request, 'eventpermset') and 'can_view_orders' not in request.eventpermset
|
request and hasattr(request, 'eventpermset') and 'event.orders:read' not in request.eventpermset
|
||||||
)
|
)
|
||||||
if ('pdf_data' in self.context and not self.context['pdf_data']) or pdf_data_forbidden:
|
if ('pdf_data' in self.context and not self.context['pdf_data']) or pdf_data_forbidden:
|
||||||
self.fields.pop('pdf_data', None)
|
self.fields.pop('pdf_data', None)
|
||||||
@@ -636,6 +638,14 @@ 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
|
||||||
@@ -704,6 +714,16 @@ 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
|
||||||
@@ -749,7 +769,11 @@ class PaymentDetailsField(serializers.Field):
|
|||||||
pp = value.payment_provider
|
pp = value.payment_provider
|
||||||
if not pp:
|
if not pp:
|
||||||
return {}
|
return {}
|
||||||
return pp.api_payment_details(value)
|
try:
|
||||||
|
return pp.api_payment_details(value)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to retrieve payment_details")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class OrderPaymentSerializer(I18nAwareModelSerializer):
|
class OrderPaymentSerializer(I18nAwareModelSerializer):
|
||||||
@@ -1181,6 +1205,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
)
|
)
|
||||||
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
|
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
|
||||||
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
|
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
|
||||||
|
use_gift_cards = serializers.ListField(child=serializers.CharField(required=False), required=False)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -1196,7 +1221,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||||
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
|
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
|
||||||
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
|
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
|
||||||
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode')
|
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode', 'use_gift_cards')
|
||||||
|
|
||||||
def validate_payment_provider(self, pp):
|
def validate_payment_provider(self, pp):
|
||||||
if pp is None:
|
if pp is None:
|
||||||
@@ -1205,6 +1230,18 @@ 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.')
|
||||||
@@ -1279,6 +1316,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
payment_date = validated_data.pop('payment_date', now())
|
payment_date = validated_data.pop('payment_date', now())
|
||||||
force = validated_data.pop('force', False)
|
force = validated_data.pop('force', False)
|
||||||
simulate = validated_data.pop('simulate', False)
|
simulate = validated_data.pop('simulate', False)
|
||||||
|
gift_card_secrets = validated_data.pop('use_gift_cards') if 'use_gift_cards' in validated_data else []
|
||||||
|
|
||||||
|
if (payment_provider is not None or payment_info != '{}') and len(gift_card_secrets) > 0:
|
||||||
|
raise ValidationError({"use_gift_cards": ['The attribute use_gift_cards is not compatible with payment_provider or payment_info']})
|
||||||
|
if validated_data.get('status') != Order.STATUS_PENDING and len(gift_card_secrets) > 0:
|
||||||
|
raise ValidationError({"use_gift_cards": ['The attribute use_gift_cards is only supported for orders that are created as pending']})
|
||||||
|
if len(set(gift_card_secrets)) != len(gift_card_secrets):
|
||||||
|
raise ValidationError({"use_gift_cards": ['Multiple copies of the same gift card secret are not allowed']})
|
||||||
|
|
||||||
if not validated_data.get("sales_channel"):
|
if not validated_data.get("sales_channel"):
|
||||||
validated_data["sales_channel"] = self.context['event'].organizer.sales_channels.get(identifier="web")
|
validated_data["sales_channel"] = self.context['event'].organizer.sales_channels.get(identifier="web")
|
||||||
@@ -1601,7 +1646,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,
|
||||||
bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
|
cp.addon_to, cp.is_bundled, pos._voucher_discount)
|
||||||
for cp in order_positions
|
for cp in order_positions
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -1733,6 +1778,7 @@ 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]
|
||||||
)
|
)
|
||||||
@@ -1762,6 +1808,45 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
if order.total != Decimal('0.00') and order.event.currency == "XXX":
|
if order.total != Decimal('0.00') and order.event.currency == "XXX":
|
||||||
raise ValidationError('Paid products not supported without a valid currency.')
|
raise ValidationError('Paid products not supported without a valid currency.')
|
||||||
|
|
||||||
|
for gift_card_secret in gift_card_secrets:
|
||||||
|
try:
|
||||||
|
if order.status != Order.STATUS_PAID:
|
||||||
|
gift_card_payment_provider = GiftCardPayment(event=order.event)
|
||||||
|
|
||||||
|
gc = order.event.organizer.accepted_gift_cards.get(
|
||||||
|
secret=gift_card_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
payment = order.payments.create(
|
||||||
|
amount=min(order.pending_sum, gc.value),
|
||||||
|
provider=gift_card_payment_provider.identifier,
|
||||||
|
info_data={
|
||||||
|
'gift_card': gc.pk,
|
||||||
|
'gift_card_secret': gc.secret,
|
||||||
|
'retry': True
|
||||||
|
},
|
||||||
|
state=OrderPayment.PAYMENT_STATE_CREATED
|
||||||
|
)
|
||||||
|
gift_card_payment_provider.execute_payment(request=None, payment=payment, is_early_special_case=True)
|
||||||
|
|
||||||
|
if order.pending_sum <= Decimal('0.00'):
|
||||||
|
order.status = Order.STATUS_PAID
|
||||||
|
|
||||||
|
except PaymentException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except GiftCard.DoesNotExist as e:
|
||||||
|
payment = order.payments.create(
|
||||||
|
amount=order.pending_sum,
|
||||||
|
provider=GiftCardPayment.identifier,
|
||||||
|
info_data={
|
||||||
|
'gift_card_secret': gift_card_secret,
|
||||||
|
},
|
||||||
|
state=OrderPayment.PAYMENT_STATE_CREATED
|
||||||
|
)
|
||||||
|
payment.fail(info={**payment.info_data, 'error': str(e)},
|
||||||
|
send_mail=False)
|
||||||
|
|
||||||
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID and not validated_data.get('require_approval'):
|
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID and not validated_data.get('require_approval'):
|
||||||
order.status = Order.STATUS_PAID
|
order.status = Order.STATUS_PAID
|
||||||
order.save()
|
order.save()
|
||||||
|
|||||||
@@ -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 OrderError
|
from pretix.base.services.orders import OrderChangeManager, 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 = self.context['ocm']
|
ocm: OrderChangeManager = self.context['ocm']
|
||||||
check_quotas = self.context.get('check_quotas', True)
|
check_quotas = self.context.get('check_quotas', True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ocm.add_position(
|
new_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 validated_data['order'].positions.order_by('-positionid').first()
|
return new_position.position
|
||||||
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 = self.context['ocm']
|
ocm: OrderChangeManager = 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 validated_data['order'].fees.order_by('-pk').first()
|
return f
|
||||||
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 = self.context['ocm']
|
ocm: OrderChangeManager = 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 = self.context['ocm']
|
ocm: OrderChangeManager = self.context['ocm']
|
||||||
value = validated_data.get('value', instance.value)
|
value = validated_data.get('value', instance.value)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -45,12 +45,19 @@ from pretix.base.models import (
|
|||||||
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
|
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
|
||||||
)
|
)
|
||||||
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
from pretix.base.models.seating import SeatingPlanLayoutValidator
|
||||||
|
from pretix.base.permissions import (
|
||||||
|
get_all_event_permission_groups, get_all_organizer_permission_groups,
|
||||||
|
)
|
||||||
from pretix.base.plugins import (
|
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 SendMailException, mail
|
from pretix.base.services.mail import mail
|
||||||
from pretix.base.settings import validate_organizer_settings
|
from pretix.base.settings import validate_organizer_settings
|
||||||
|
from pretix.helpers.permission_migration import (
|
||||||
|
OLD_TO_NEW_EVENT_COMPAT, OLD_TO_NEW_EVENT_MIGRATION,
|
||||||
|
OLD_TO_NEW_ORGANIZER_COMPAT, OLD_TO_NEW_ORGANIZER_MIGRATION,
|
||||||
|
)
|
||||||
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
|
||||||
|
|
||||||
@@ -306,23 +313,128 @@ class EventSlugField(serializers.SlugRelatedField):
|
|||||||
return self.context['organizer'].events.all()
|
return self.context['organizer'].events.all()
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionMultipleChoiceField(serializers.MultipleChoiceField):
|
||||||
|
def to_internal_value(self, data):
|
||||||
|
return {
|
||||||
|
p: True for p in super().to_internal_value(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_representation(self, value):
|
||||||
|
return [p for p, v in value.items() if v]
|
||||||
|
|
||||||
|
|
||||||
class TeamSerializer(serializers.ModelSerializer):
|
class TeamSerializer(serializers.ModelSerializer):
|
||||||
limit_events = EventSlugField(slug_field='slug', many=True)
|
limit_events = EventSlugField(slug_field='slug', many=True)
|
||||||
|
limit_event_permissions = PermissionMultipleChoiceField(choices=[], required=False, allow_null=False, allow_empty=True)
|
||||||
|
limit_organizer_permissions = PermissionMultipleChoiceField(choices=[], required=False, allow_null=False, allow_empty=True)
|
||||||
|
|
||||||
|
# Legacy fields, handled in to_representation and validate
|
||||||
|
can_change_event_settings = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
can_change_items = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
can_view_orders = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
can_change_orders = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
can_checkin_orders = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
can_view_vouchers = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
can_change_vouchers = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
can_create_events = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
can_change_organizer_settings = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
can_change_teams = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
can_manage_gift_cards = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
can_manage_customers = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
can_manage_reusable_media = serializers.BooleanField(required=False, write_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Team
|
model = Team
|
||||||
fields = (
|
fields = (
|
||||||
'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
|
'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'all_event_permissions', 'limit_event_permissions',
|
||||||
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
|
'all_organizer_permissions', 'limit_organizer_permissions', 'can_change_event_settings',
|
||||||
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
|
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_checkin_orders', 'can_view_vouchers',
|
||||||
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
|
'can_change_vouchers', 'can_create_events', 'can_change_organizer_settings', 'can_change_teams',
|
||||||
|
'can_manage_gift_cards', 'can_manage_customers', 'can_manage_reusable_media'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
event_perms_flattened = []
|
||||||
|
organizer_perms_flattened = []
|
||||||
|
for pg in get_all_event_permission_groups().values():
|
||||||
|
for action in pg.actions:
|
||||||
|
event_perms_flattened.append(f"{pg.name}:{action}")
|
||||||
|
for pg in get_all_organizer_permission_groups().values():
|
||||||
|
for action in pg.actions:
|
||||||
|
organizer_perms_flattened.append(f"{pg.name}:{action}")
|
||||||
|
|
||||||
|
self.fields['limit_event_permissions'].choices = [(p, p) for p in event_perms_flattened]
|
||||||
|
self.fields['limit_organizer_permissions'].choices = [(p, p) for p in organizer_perms_flattened]
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
r = super().to_representation(instance)
|
||||||
|
for old, new in OLD_TO_NEW_EVENT_COMPAT.items():
|
||||||
|
r[old] = instance.all_event_permissions or all(instance.limit_event_permissions.get(n) for n in new)
|
||||||
|
for old, new in OLD_TO_NEW_ORGANIZER_COMPAT.items():
|
||||||
|
r[old] = instance.all_organizer_permissions or all(instance.limit_organizer_permissions.get(n) for n in new)
|
||||||
|
return r
|
||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
old_data_set = any(k.startswith("can_") for k in data)
|
||||||
|
new_data_set = any(k in data for k in [
|
||||||
|
"all_event_permissions", "limit_event_permissions", "all_organizer_permissions", "limit_organizer_permissions"
|
||||||
|
])
|
||||||
|
if old_data_set and new_data_set:
|
||||||
|
raise ValidationError("You cannot set deprecated and current permission attributes at the same time.")
|
||||||
|
|
||||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||||
full_data.update(data)
|
full_data.update(data)
|
||||||
|
|
||||||
|
if new_data_set:
|
||||||
|
if full_data.get('limit_event_permissions') and full_data.get('all_event_permissions'):
|
||||||
|
raise ValidationError('Do not set both limit_event_permissions and all_event_permissions.')
|
||||||
|
if full_data.get('limit_organizer_permissions') and full_data.get('all_organizer_permissions'):
|
||||||
|
raise ValidationError('Do not set both limit_organizer_permissions and all_organizer_permissions.')
|
||||||
|
|
||||||
|
if old_data_set:
|
||||||
|
# Migrate with same logic as in migration 0297_pluggable_permissions
|
||||||
|
if all(full_data.get(k) is True for k in OLD_TO_NEW_EVENT_MIGRATION.keys() if k != "can_checkin_orders"):
|
||||||
|
data["all_event_permissions"] = True
|
||||||
|
data["limit_event_permissions"] = {}
|
||||||
|
else:
|
||||||
|
data["all_event_permissions"] = False
|
||||||
|
data["limit_event_permissions"] = {}
|
||||||
|
for k, v in OLD_TO_NEW_EVENT_MIGRATION.items():
|
||||||
|
if full_data.get(k) is True:
|
||||||
|
data["limit_event_permissions"].update({kk: True for kk in v})
|
||||||
|
if all(full_data.get(k) is True for k in OLD_TO_NEW_ORGANIZER_MIGRATION.keys() if k != "can_checkin_orders"):
|
||||||
|
data["all_organizer_permissions"] = True
|
||||||
|
data["limit_organizer_permissions"] = {}
|
||||||
|
else:
|
||||||
|
data["all_organizer_permissions"] = False
|
||||||
|
data["limit_organizer_permissions"] = {}
|
||||||
|
for k, v in OLD_TO_NEW_ORGANIZER_MIGRATION.items():
|
||||||
|
if full_data.get(k) is True:
|
||||||
|
data["limit_organizer_permissions"].update({kk: True for kk in v})
|
||||||
|
|
||||||
if full_data.get('limit_events') and full_data.get('all_events'):
|
if full_data.get('limit_events') and full_data.get('all_events'):
|
||||||
raise ValidationError('Do not set both limit_events and all_events.')
|
raise ValidationError('Do not set both limit_events and all_events.')
|
||||||
|
|
||||||
|
full_data.update(data)
|
||||||
|
for pg in get_all_event_permission_groups().values():
|
||||||
|
requested = ",".join(sorted(
|
||||||
|
a for a in pg.actions if self.instance and full_data["limit_event_permissions"].get(f"{pg.name}:{a}")
|
||||||
|
))
|
||||||
|
if requested not in (",".join(sorted(opt.actions)) for opt in pg.options):
|
||||||
|
possible = '\' or \''.join(','.join(opt.actions) for opt in pg.options)
|
||||||
|
raise ValidationError(f"For permission group {pg.name}, the valid combinations of actions are "
|
||||||
|
f"'{possible}' but you tried to set '{requested}'.")
|
||||||
|
for pg in get_all_organizer_permission_groups().values():
|
||||||
|
requested = ",".join(sorted(
|
||||||
|
a for a in pg.actions if self.instance and full_data["limit_organizer_permissions"].get(f"{pg.name}:{a}")
|
||||||
|
))
|
||||||
|
if requested not in (",".join(sorted(opt.actions)) for opt in pg.options):
|
||||||
|
possible = '\' or \''.join(','.join(opt.actions) for opt in pg.options)
|
||||||
|
raise ValidationError(f"For permission group {pg.name}, the valid combinations of actions are "
|
||||||
|
f"'{possible}' but you tried to set '{requested}'.")
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -339,7 +451,7 @@ class DeviceSerializer(serializers.ModelSerializer):
|
|||||||
created = serializers.DateTimeField(read_only=True)
|
created = serializers.DateTimeField(read_only=True)
|
||||||
revoked = serializers.BooleanField(read_only=True)
|
revoked = serializers.BooleanField(read_only=True)
|
||||||
initialized = serializers.DateTimeField(read_only=True)
|
initialized = serializers.DateTimeField(read_only=True)
|
||||||
initialization_token = serializers.DateTimeField(read_only=True)
|
initialization_token = serializers.CharField(read_only=True)
|
||||||
security_profile = serializers.ChoiceField(choices=[], required=False, default="full")
|
security_profile = serializers.ChoiceField(choices=[], required=False, default="full")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -353,6 +465,8 @@ class DeviceSerializer(serializers.ModelSerializer):
|
|||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['security_profile'].choices = [(k, v.verbose_name) for k, v in get_all_security_profiles().items()]
|
self.fields['security_profile'].choices = [(k, v.verbose_name) for k, v in get_all_security_profiles().items()]
|
||||||
|
if not self.context['can_see_tokens']:
|
||||||
|
del self.fields['initialization_token']
|
||||||
|
|
||||||
|
|
||||||
class TeamInviteSerializer(serializers.ModelSerializer):
|
class TeamInviteSerializer(serializers.ModelSerializer):
|
||||||
@@ -363,24 +477,22 @@ class TeamInviteSerializer(serializers.ModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _send_invite(self, instance):
|
def _send_invite(self, instance):
|
||||||
try:
|
mail(
|
||||||
mail(
|
instance.email,
|
||||||
instance.email,
|
_('Account invitation'),
|
||||||
_('pretix account invitation'),
|
'pretixcontrol/email/invitation.txt',
|
||||||
'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:
|
||||||
@@ -439,10 +551,14 @@ class TeamMemberSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class OrganizerSettingsSerializer(SettingsSerializer):
|
class OrganizerSettingsSerializer(SettingsSerializer):
|
||||||
|
default_write_permission = 'organizer.settings.general:write'
|
||||||
default_fields = [
|
default_fields = [
|
||||||
|
# These are readable for all users with access to the events, therefore secrets stored in the settings store
|
||||||
|
# should not be included!
|
||||||
'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',
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ logger = logging.getLogger(__name__)
|
|||||||
class SettingsSerializer(serializers.Serializer):
|
class SettingsSerializer(serializers.Serializer):
|
||||||
default_fields = []
|
default_fields = []
|
||||||
readonly_fields = []
|
readonly_fields = []
|
||||||
|
default_write_permission = 'organizer.settings.general:write'
|
||||||
|
write_permission_required = {}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.changed_data = []
|
self.changed_data = []
|
||||||
@@ -58,9 +60,17 @@ class SettingsSerializer(serializers.Serializer):
|
|||||||
f._label = str(form_kwargs.get('label', fname))
|
f._label = str(form_kwargs.get('label', fname))
|
||||||
f._help_text = str(form_kwargs.get('help_text'))
|
f._help_text = str(form_kwargs.get('help_text'))
|
||||||
f.parent = self
|
f.parent = self
|
||||||
|
|
||||||
|
self.write_permission_required[fname] = DEFAULTS[fname].get('write_permission', self.default_write_permission)
|
||||||
|
|
||||||
self.fields[fname] = f
|
self.fields[fname] = f
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
for k in attrs.keys():
|
||||||
|
p = self.write_permission_required.get(k, self.default_write_permission)
|
||||||
|
if p not in self.context["permissions"]:
|
||||||
|
raise ValidationError({k: f"Setting this field requires permission {p}"})
|
||||||
|
|
||||||
return {k: v for k, v in attrs.items() if k not in self.readonly_fields}
|
return {k: v for k, v in attrs.items() if k not in self.readonly_fields}
|
||||||
|
|
||||||
def update(self, instance: HierarkeyProxy, validated_data):
|
def update(self, instance: HierarkeyProxy, validated_data):
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ 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)
|
||||||
@@ -83,7 +84,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.OrderPositionViewSet)
|
event_router.register(r'orderpositions', order.EventOrderPositionViewSet)
|
||||||
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')
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
|
|||||||
ordering = ('datetime',)
|
ordering = ('datetime',)
|
||||||
ordering_fields = ('datetime', 'cart_id')
|
ordering_fields = ('datetime', 'cart_id')
|
||||||
lookup_field = 'id'
|
lookup_field = 'id'
|
||||||
permission = 'can_view_orders'
|
permission = 'event.orders:read'
|
||||||
write_permission = 'can_change_orders'
|
write_permission = 'event.orders:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return CartPosition.objects.filter(
|
return CartPosition.objects.filter(
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ from pretix.base.models import (
|
|||||||
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
|
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
|
||||||
)
|
)
|
||||||
from pretix.base.models.orders import PrintLog
|
from pretix.base.models.orders import PrintLog
|
||||||
|
from pretix.base.permissions import AnyPermissionOf
|
||||||
from pretix.base.services.checkin import (
|
from pretix.base.services.checkin import (
|
||||||
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
|
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
|
||||||
)
|
)
|
||||||
@@ -118,11 +119,11 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
def _get_permission_name(self, request):
|
def _get_permission_name(self, request):
|
||||||
if request.path.endswith('/failed_checkins/'):
|
if request.path.endswith('/failed_checkins/'):
|
||||||
return 'can_checkin_orders', 'can_change_orders'
|
return 'event.orders:checkin', 'event.orders:write'
|
||||||
elif request.method in SAFE_METHODS:
|
elif request.method in SAFE_METHODS:
|
||||||
return 'can_view_orders', 'can_checkin_orders',
|
return 'event.orders:read', 'event.orders:checkin',
|
||||||
else:
|
else:
|
||||||
return 'can_change_event_settings'
|
return 'event.settings.general:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = self.request.event.checkin_lists.prefetch_related(
|
qs = self.request.event.checkin_lists.prefetch_related(
|
||||||
@@ -188,11 +189,15 @@ 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(nonce=serializer.validated_data['nonce']).first()
|
prev = kwargs['position'].all_checkins.filter(
|
||||||
|
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
|
||||||
@@ -381,15 +386,21 @@ 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(
|
||||||
Prefetch(
|
# Don't add to list, we don't want to propagate to addons
|
||||||
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',
|
||||||
@@ -404,32 +415,39 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
))
|
))
|
||||||
).select_related(
|
|
||||||
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
|
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
qs = qs.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
lookup='checkins',
|
|
||||||
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
|
|
||||||
),
|
|
||||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
|
||||||
'answers', 'answers__options', 'answers__question',
|
|
||||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
|
||||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
|
|
||||||
|
|
||||||
if expand and 'subevent' in expand:
|
if expand and 'subevent' in expand:
|
||||||
qs = qs.prefetch_related(
|
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:
|
||||||
qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values',
|
prefetch_related += [
|
||||||
'item__variations').select_related('item__tax_rule')
|
'item', 'item__addons', 'item__bundles', 'item__meta_values',
|
||||||
|
'item__variations',
|
||||||
|
]
|
||||||
|
select_related.append('item__tax_rule')
|
||||||
|
|
||||||
if expand and 'variation' in expand:
|
if expand and 'variation' in expand:
|
||||||
qs = qs.prefetch_related('variation', 'variation__meta_values')
|
prefetch_related += [
|
||||||
|
'variation', 'variation__meta_values',
|
||||||
|
]
|
||||||
|
|
||||||
|
if expand and 'addons' in expand:
|
||||||
|
prefetch_related += [
|
||||||
|
Prefetch('addons', OrderPosition.objects.prefetch_related(*prefetch_related).select_related(*select_related)),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
prefetch_related += [
|
||||||
|
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||||
|
]
|
||||||
|
|
||||||
|
if pdf_data:
|
||||||
|
select_related.remove("order") # Don't need it twice on this queryset
|
||||||
|
|
||||||
|
qs = qs.prefetch_related(*prefetch_related).select_related(*select_related)
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -457,7 +475,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
|||||||
'event': op.order.event,
|
'event': op.order.event,
|
||||||
'pdf_data': pdf_data and (
|
'pdf_data': pdf_data and (
|
||||||
user if user and user.is_authenticated else auth
|
user if user and user.is_authenticated else auth
|
||||||
).has_event_permission(request.organizer, event, 'can_view_orders', request),
|
).has_event_permission(request.organizer, event, 'event.orders:read', request),
|
||||||
}
|
}
|
||||||
|
|
||||||
common_checkin_args = dict(
|
common_checkin_args = dict(
|
||||||
@@ -822,8 +840,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
}
|
}
|
||||||
|
|
||||||
filterset_class = CheckinOrderPositionFilter
|
filterset_class = CheckinOrderPositionFilter
|
||||||
permission = ('can_view_orders', 'can_checkin_orders')
|
permission = AnyPermissionOf('event.orders:read', 'event.orders:checkin')
|
||||||
write_permission = ('can_change_orders', 'can_checkin_orders')
|
write_permission = AnyPermissionOf('event.orders:write', 'event.orders:checkin')
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
@@ -854,7 +872,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
expand=self.request.query_params.getlist('expand'),
|
expand=self.request.query_params.getlist('expand'),
|
||||||
)
|
)
|
||||||
|
|
||||||
if 'pk' not in self.request.resolver_match.kwargs and 'can_view_orders' not in self.request.eventpermset \
|
if 'pk' not in self.request.resolver_match.kwargs and 'event.orders:read' not in self.request.eventpermset \
|
||||||
and len(self.request.query_params.get('search', '')) < 3:
|
and len(self.request.query_params.get('search', '')) < 3:
|
||||||
qs = qs.none()
|
qs = qs.none()
|
||||||
|
|
||||||
@@ -903,9 +921,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
class CheckinRPCRedeemView(views.APIView):
|
class CheckinRPCRedeemView(views.APIView):
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||||
events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders'))
|
events = self.request.auth.get_events_with_permission(('event.orders:write', 'event.orders:checkin'))
|
||||||
elif self.request.user.is_authenticated:
|
elif self.request.user.is_authenticated:
|
||||||
events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter(
|
events = self.request.user.get_events_with_permission(('event.orders:write', 'event.orders:checkin'), self.request).filter(
|
||||||
organizer=self.request.organizer
|
organizer=self.request.organizer
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -966,15 +984,16 @@ 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
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def lists(self):
|
def lists(self):
|
||||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||||
events = self.request.auth.get_events_with_permission(('can_view_orders', 'can_checkin_orders'))
|
events = self.request.auth.get_events_with_permission(('event.orders:read', 'event.orders:checkin'))
|
||||||
elif self.request.user.is_authenticated:
|
elif self.request.user.is_authenticated:
|
||||||
events = self.request.user.get_events_with_permission(('can_view_orders', 'can_checkin_orders'), self.request).filter(
|
events = self.request.user.get_events_with_permission(('event.orders:read', 'event.orders:checkin'), self.request).filter(
|
||||||
organizer=self.request.organizer
|
organizer=self.request.organizer
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -991,9 +1010,9 @@ class CheckinRPCSearchView(ListAPIView):
|
|||||||
@cached_property
|
@cached_property
|
||||||
def has_full_access_permission(self):
|
def has_full_access_permission(self):
|
||||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||||
events = self.request.auth.get_events_with_permission('can_view_orders')
|
events = self.request.auth.get_events_with_permission('event.orders:read')
|
||||||
elif self.request.user.is_authenticated:
|
elif self.request.user.is_authenticated:
|
||||||
events = self.request.user.get_events_with_permission('can_view_orders', self.request).filter(
|
events = self.request.user.get_events_with_permission('event.orders:read', self.request).filter(
|
||||||
organizer=self.request.organizer
|
organizer=self.request.organizer
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1020,9 +1039,9 @@ class CheckinRPCSearchView(ListAPIView):
|
|||||||
class CheckinRPCAnnulView(views.APIView):
|
class CheckinRPCAnnulView(views.APIView):
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||||
events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders'))
|
events = self.request.auth.get_events_with_permission(('event.orders:write', 'event.orders:checkin'))
|
||||||
elif self.request.user.is_authenticated:
|
elif self.request.user.is_authenticated:
|
||||||
events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter(
|
events = self.request.user.get_events_with_permission(('event.orders:write', 'event.orders:checkin'), self.request).filter(
|
||||||
organizer=self.request.organizer
|
organizer=self.request.organizer
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -1100,10 +1119,10 @@ class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
filterset_class = CheckinFilter
|
filterset_class = CheckinFilter
|
||||||
ordering = ('created', 'id')
|
ordering = ('created', 'id')
|
||||||
ordering_fields = ('created', 'datetime', 'id',)
|
ordering_fields = ('created', 'datetime', 'id',)
|
||||||
permission = 'can_view_orders'
|
permission = 'event.orders:read'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = Checkin.all.filter().select_related(
|
qs = Checkin.all.filter(list__event=self.request.event).select_related(
|
||||||
"position",
|
"position",
|
||||||
"device",
|
"device",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
ordering_fields = ('id', 'position')
|
ordering_fields = ('id', 'position')
|
||||||
ordering = ('position', 'id')
|
ordering = ('position', 'id')
|
||||||
permission = None
|
permission = None
|
||||||
write_permission = 'can_change_items'
|
write_permission = 'event.items:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.event.discounts.prefetch_related(
|
return self.request.event.discounts.prefetch_related(
|
||||||
|
|||||||
@@ -281,6 +281,11 @@ class EventViewSet(viewsets.ModelViewSet):
|
|||||||
new_event = serializer.save(organizer=self.request.organizer)
|
new_event = serializer.save(organizer=self.request.organizer)
|
||||||
|
|
||||||
if copy_from:
|
if copy_from:
|
||||||
|
perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken))
|
||||||
|
else self.request.user)
|
||||||
|
if not copy_from.allow_copy_data(self.request.organizer, perm_holder):
|
||||||
|
raise PermissionDenied("Not sufficient permission on source event to copy")
|
||||||
|
|
||||||
new_event.copy_data_from(copy_from, skip_meta_data='meta_data' in serializer.validated_data)
|
new_event.copy_data_from(copy_from, skip_meta_data='meta_data' in serializer.validated_data)
|
||||||
|
|
||||||
if plugins is not None:
|
if plugins is not None:
|
||||||
@@ -341,15 +346,24 @@ class CloneEventViewSet(viewsets.ModelViewSet):
|
|||||||
lookup_field = 'slug'
|
lookup_field = 'slug'
|
||||||
lookup_url_kwarg = 'event'
|
lookup_url_kwarg = 'event'
|
||||||
http_method_names = ['post']
|
http_method_names = ['post']
|
||||||
write_permission = 'can_create_events'
|
write_permission = 'event.settings.general:write'
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
ctx['event'] = self.kwargs['event']
|
ctx['event'] = Event.objects.get(slug=self.kwargs['event'], organizer=self.request.organizer)
|
||||||
ctx['organizer'] = self.request.organizer
|
ctx['organizer'] = self.request.organizer
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
|
# Weird edge case: Requires settings permission on the event (to read) but also on the organizer (two write)
|
||||||
|
perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken))
|
||||||
|
else self.request.user)
|
||||||
|
if not perm_holder.has_organizer_permission(self.request.organizer, "organizer.events:create", request=self.request):
|
||||||
|
raise PermissionDenied("No permission to create events")
|
||||||
|
|
||||||
|
if not serializer.context['event'].allow_copy_data(self.request.organizer, perm_holder):
|
||||||
|
raise PermissionDenied("Not sufficient permission on source event to copy")
|
||||||
|
|
||||||
serializer.save(organizer=self.request.organizer)
|
serializer.save(organizer=self.request.organizer)
|
||||||
|
|
||||||
serializer.instance.log_action(
|
serializer.instance.log_action(
|
||||||
@@ -426,7 +440,7 @@ with scopes_disabled():
|
|||||||
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||||
serializer_class = SubEventSerializer
|
serializer_class = SubEventSerializer
|
||||||
queryset = SubEvent.objects.none()
|
queryset = SubEvent.objects.none()
|
||||||
write_permission = 'can_change_event_settings'
|
write_permission = 'event.subevents:write'
|
||||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||||
ordering = ('date_from',)
|
ordering = ('date_from',)
|
||||||
ordering_fields = ('id', 'date_from', 'last_modified')
|
ordering_fields = ('id', 'date_from', 'last_modified')
|
||||||
@@ -546,7 +560,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||||
serializer_class = TaxRuleSerializer
|
serializer_class = TaxRuleSerializer
|
||||||
queryset = TaxRule.objects.none()
|
queryset = TaxRule.objects.none()
|
||||||
write_permission = 'can_change_event_settings'
|
write_permission = 'event.settings.tax:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.event.tax_rules.all()
|
return self.request.event.tax_rules.all()
|
||||||
@@ -589,7 +603,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
class ItemMetaPropertiesViewSet(viewsets.ModelViewSet):
|
class ItemMetaPropertiesViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = ItemMetaPropertiesSerializer
|
serializer_class = ItemMetaPropertiesSerializer
|
||||||
queryset = ItemMetaProperty.objects.none()
|
queryset = ItemMetaProperty.objects.none()
|
||||||
write_permission = 'can_change_event_settings'
|
write_permission = 'event.settings.general:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = self.request.event.item_meta_properties.all()
|
qs = self.request.event.item_meta_properties.all()
|
||||||
@@ -636,19 +650,18 @@ class ItemMetaPropertiesViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
class EventSettingsView(views.APIView):
|
class EventSettingsView(views.APIView):
|
||||||
permission = None
|
permission = None
|
||||||
write_permission = 'can_change_event_settings'
|
write_permission = 'event.settings.general:write'
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
if isinstance(request.auth, Device):
|
if isinstance(request.auth, Device):
|
||||||
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
||||||
'request': request
|
'request': request, 'permissions': request.eventpermset
|
||||||
})
|
|
||||||
elif 'can_change_event_settings' in request.eventpermset:
|
|
||||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
|
||||||
'request': request
|
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
raise PermissionDenied()
|
s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
||||||
|
'request': request, 'permissions': request.eventpermset,
|
||||||
|
})
|
||||||
|
|
||||||
if 'explain' in request.GET:
|
if 'explain' in request.GET:
|
||||||
return Response({
|
return Response({
|
||||||
fname: {
|
fname: {
|
||||||
@@ -662,7 +675,7 @@ class EventSettingsView(views.APIView):
|
|||||||
|
|
||||||
def patch(self, request, *wargs, **kwargs):
|
def patch(self, request, *wargs, **kwargs):
|
||||||
s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True,
|
s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True,
|
||||||
event=request.event, context={'request': request})
|
event=request.event, context={'request': request, 'permissions': request.eventpermset})
|
||||||
s.is_valid(raise_exception=True)
|
s.is_valid(raise_exception=True)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
s.save()
|
s.save()
|
||||||
@@ -674,7 +687,7 @@ class EventSettingsView(views.APIView):
|
|||||||
)
|
)
|
||||||
s = EventSettingsSerializer(
|
s = EventSettingsSerializer(
|
||||||
instance=request.event.settings, event=request.event, context={
|
instance=request.event.settings, event=request.event, context={
|
||||||
'request': request
|
'request': request, 'permissions': request.eventpermset
|
||||||
})
|
})
|
||||||
return Response(s.data)
|
return Response(s.data)
|
||||||
|
|
||||||
@@ -701,7 +714,7 @@ class SeatFilter(FilterSet):
|
|||||||
class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
|
class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||||
serializer_class = SeatSerializer
|
serializer_class = SeatSerializer
|
||||||
queryset = Seat.objects.none()
|
queryset = Seat.objects.none()
|
||||||
write_permission = 'can_change_event_settings'
|
write_permission = 'event.settings.general:write'
|
||||||
filter_backends = (DjangoFilterBackend, )
|
filter_backends = (DjangoFilterBackend, )
|
||||||
filterset_class = SeatFilter
|
filterset_class = SeatFilter
|
||||||
|
|
||||||
|
|||||||
@@ -40,12 +40,12 @@ from pretix.api.serializers.exporters import (
|
|||||||
)
|
)
|
||||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CachedFile, Device, Event, ScheduledEventExport, ScheduledOrganizerExport,
|
CachedFile, Device, ScheduledEventExport, ScheduledOrganizerExport,
|
||||||
TeamAPIToken,
|
TeamAPIToken,
|
||||||
)
|
)
|
||||||
from pretix.base.services.export import export, multiexport
|
from pretix.base.models.organizer import TeamQuerySet
|
||||||
from pretix.base.signals import (
|
from pretix.base.services.export import (
|
||||||
register_data_exporters, register_multievent_data_exporters,
|
export, init_event_exporters, init_organizer_exporters, multiexport,
|
||||||
)
|
)
|
||||||
from pretix.helpers.http import ChunkBasedFileResponse
|
from pretix.helpers.http import ChunkBasedFileResponse
|
||||||
|
|
||||||
@@ -74,6 +74,11 @@ 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")
|
||||||
@@ -106,10 +111,11 @@ class ExportersMixin:
|
|||||||
@action(detail=True, methods=['POST'])
|
@action(detail=True, methods=['POST'])
|
||||||
def run(self, *args, **kwargs):
|
def run(self, *args, **kwargs):
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
serializer = JobRunSerializer(exporter=instance, data=self.request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
cf = CachedFile(web_download=False)
|
cf = CachedFile(web_download=True)
|
||||||
|
cf.bind_to_session(self.request, "exporters-api")
|
||||||
cf.date = now()
|
cf.date = now()
|
||||||
cf.expires = now() + timedelta(hours=24)
|
cf.expires = now() + timedelta(hours=24)
|
||||||
cf.save()
|
cf.save()
|
||||||
@@ -130,27 +136,34 @@ class ExportersMixin:
|
|||||||
|
|
||||||
|
|
||||||
class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||||
permission = 'can_view_orders'
|
permission = None
|
||||||
|
|
||||||
def get_serializer_kwargs(self):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def exporters(self):
|
def exporters(self):
|
||||||
|
raw_exporters = list(init_event_exporters(
|
||||||
|
event=self.request.event,
|
||||||
|
user=self.request.user if self.request.user and self.request.user.is_authenticated else None,
|
||||||
|
token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None,
|
||||||
|
device=self.request.auth if isinstance(self.request.auth, Device) else None,
|
||||||
|
request=self.request,
|
||||||
|
))
|
||||||
exporters = []
|
exporters = []
|
||||||
responses = register_data_exporters.send(self.request.event)
|
|
||||||
raw_exporters = [response(self.request.event, self.request.organizer) for r, response in responses if response]
|
|
||||||
raw_exporters = [
|
|
||||||
ex for ex in raw_exporters
|
|
||||||
if ex.available_for_user(self.request.user if self.request.user and self.request.user.is_authenticated else None)
|
|
||||||
]
|
|
||||||
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
|
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
|
||||||
ex._serializer = JobRunSerializer(exporter=ex)
|
ex._serializer = JobRunSerializer(exporter=ex)
|
||||||
exporters.append(ex)
|
exporters.append(ex)
|
||||||
return exporters
|
return exporters
|
||||||
|
|
||||||
def do_export(self, cf, instance, data):
|
def do_export(self, cf, instance, data):
|
||||||
return export.apply_async(args=(self.request.event.id, str(cf.id), instance.identifier, data))
|
return export.apply_async(args=(
|
||||||
|
self.request.event.id,
|
||||||
|
), kwargs={
|
||||||
|
'user': self.request.user.pk if self.request.user and self.request.user.is_authenticated else None,
|
||||||
|
'token': self.request.auth.pk if isinstance(self.request.auth, TeamAPIToken) else None,
|
||||||
|
'device': self.request.auth.pk if isinstance(self.request.auth, Device) else None,
|
||||||
|
'fileid': str(cf.id),
|
||||||
|
'provider': instance.identifier,
|
||||||
|
'form_data': data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||||
@@ -158,47 +171,23 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def exporters(self):
|
def exporters(self):
|
||||||
|
raw_exporters = list(init_organizer_exporters(
|
||||||
|
organizer=self.request.organizer,
|
||||||
|
user=self.request.user if self.request.user and self.request.user.is_authenticated else None,
|
||||||
|
token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None,
|
||||||
|
device=self.request.auth if isinstance(self.request.auth, Device) else None,
|
||||||
|
request=self.request,
|
||||||
|
))
|
||||||
exporters = []
|
exporters = []
|
||||||
if isinstance(self.request.auth, (Device, TeamAPIToken)):
|
|
||||||
perm_holder = self.request.auth
|
|
||||||
else:
|
|
||||||
perm_holder = self.request.user
|
|
||||||
events = perm_holder.get_events_with_permission('can_view_orders', request=self.request).filter(
|
|
||||||
organizer=self.request.organizer
|
|
||||||
)
|
|
||||||
responses = register_multievent_data_exporters.send(self.request.organizer)
|
|
||||||
raw_exporters = [
|
|
||||||
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else events, self.request.organizer)
|
|
||||||
for r, response in responses
|
|
||||||
if response
|
|
||||||
]
|
|
||||||
raw_exporters = [
|
|
||||||
ex for ex in raw_exporters
|
|
||||||
if (
|
|
||||||
not isinstance(ex, OrganizerLevelExportMixin) or
|
|
||||||
perm_holder.has_organizer_permission(self.request.organizer, ex.organizer_required_permission, self.request)
|
|
||||||
) and ex.available_for_user(self.request.user if self.request.user and self.request.user.is_authenticated else None)
|
|
||||||
]
|
|
||||||
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
|
for ex in sorted(raw_exporters, key=lambda ex: str(ex.verbose_name)):
|
||||||
ex._serializer = JobRunSerializer(exporter=ex, events=events)
|
ex._serializer = JobRunSerializer(exporter=ex)
|
||||||
exporters.append(ex)
|
exporters.append(ex)
|
||||||
return exporters
|
return exporters
|
||||||
|
|
||||||
def get_serializer_kwargs(self):
|
|
||||||
if isinstance(self.request.auth, (Device, TeamAPIToken)):
|
|
||||||
perm_holder = self.request.auth
|
|
||||||
else:
|
|
||||||
perm_holder = self.request.user
|
|
||||||
return {
|
|
||||||
'events': perm_holder.get_events_with_permission('can_view_orders', request=self.request).filter(
|
|
||||||
organizer=self.request.organizer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
def do_export(self, cf, instance, data):
|
def do_export(self, cf, instance, data):
|
||||||
return multiexport.apply_async(kwargs={
|
return multiexport.apply_async(kwargs={
|
||||||
'organizer': self.request.organizer.id,
|
'organizer': self.request.organizer.id,
|
||||||
'user': self.request.user.id if self.request.user.is_authenticated else None,
|
'user': self.request.user.id if self.request.user and self.request.user.is_authenticated else None,
|
||||||
'token': self.request.auth.pk if isinstance(self.request.auth, TeamAPIToken) else None,
|
'token': self.request.auth.pk if isinstance(self.request.auth, TeamAPIToken) else None,
|
||||||
'device': self.request.auth.pk if isinstance(self.request.auth, Device) else None,
|
'device': self.request.auth.pk if isinstance(self.request.auth, Device) else None,
|
||||||
'fileid': str(cf.id),
|
'fileid': str(cf.id),
|
||||||
@@ -216,11 +205,11 @@ class ScheduledExportersViewSet(viewsets.ModelViewSet):
|
|||||||
class ScheduledEventExportViewSet(ScheduledExportersViewSet):
|
class ScheduledEventExportViewSet(ScheduledExportersViewSet):
|
||||||
serializer_class = ScheduledEventExportSerializer
|
serializer_class = ScheduledEventExportSerializer
|
||||||
queryset = ScheduledEventExport.objects.none()
|
queryset = ScheduledEventExport.objects.none()
|
||||||
permission = 'can_view_orders'
|
permission = None
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user
|
perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user
|
||||||
if not perm_holder.has_event_permission(self.request.organizer, self.request.event, 'can_change_event_settings',
|
if not perm_holder.has_event_permission(self.request.organizer, self.request.event, 'event.settings.general:write',
|
||||||
request=self.request):
|
request=self.request):
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
qs = self.request.event.scheduled_exports.filter(owner=self.request.user)
|
qs = self.request.event.scheduled_exports.filter(owner=self.request.user)
|
||||||
@@ -252,11 +241,28 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def exporters(self):
|
def exporters(self):
|
||||||
responses = register_data_exporters.send(self.request.event)
|
exporters = list(init_event_exporters(
|
||||||
exporters = [response(self.request.event, self.request.organizer) for r, response in responses if response]
|
event=self.request.event,
|
||||||
|
user=self.request.user if self.request.user and self.request.user.is_authenticated else None,
|
||||||
|
token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None,
|
||||||
|
device=self.request.auth if isinstance(self.request.auth, Device) else None,
|
||||||
|
request=self.request,
|
||||||
|
))
|
||||||
return {e.identifier: e for e in exporters}
|
return {e.identifier: e for e in exporters}
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
|
if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner:
|
||||||
|
# This is to prevent a possible privilege escalation where user A creates a scheduled export and
|
||||||
|
# user B has settings permission (= they can see the export configuration), but not enough permission
|
||||||
|
# to run the export themselves. Without this check, user B could modify the export and add themselves
|
||||||
|
# as a recipient. Thereby, user B would gain access to data they can't have.
|
||||||
|
exporter = self.exporters.get(serializer.instance.export_identifier)
|
||||||
|
if not exporter:
|
||||||
|
raise PermissionDenied("No access to exporter.")
|
||||||
|
perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user
|
||||||
|
if not perm_holder.has_event_permission(self.request.organizer, self.request.event, exporter.get_required_event_permission()):
|
||||||
|
raise PermissionDenied("No permission to edit exports you could not run.")
|
||||||
|
|
||||||
serializer.save(event=self.request.event)
|
serializer.save(event=self.request.event)
|
||||||
serializer.instance.compute_next_run()
|
serializer.instance.compute_next_run()
|
||||||
serializer.instance.error_counter = 0
|
serializer.instance.error_counter = 0
|
||||||
@@ -285,7 +291,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user
|
perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user
|
||||||
if not perm_holder.has_organizer_permission(self.request.organizer, 'can_change_organizer_settings',
|
if not perm_holder.has_organizer_permission(self.request.organizer, 'organizer.settings.general:write',
|
||||||
request=self.request):
|
request=self.request):
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
qs = self.request.organizer.scheduled_exports.filter(owner=self.request.user)
|
qs = self.request.organizer.scheduled_exports.filter(owner=self.request.user)
|
||||||
@@ -315,26 +321,55 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
|
|||||||
ctx['exporters'] = self.exporters
|
ctx['exporters'] = self.exporters
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def events(self):
|
|
||||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
|
||||||
return self.request.auth.get_events_with_permission('can_view_orders')
|
|
||||||
elif self.request.user.is_authenticated:
|
|
||||||
return self.request.user.get_events_with_permission('can_view_orders', self.request).filter(
|
|
||||||
organizer=self.request.organizer
|
|
||||||
)
|
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def exporters(self):
|
def exporters(self):
|
||||||
responses = register_multievent_data_exporters.send(self.request.organizer)
|
exporters = list(init_organizer_exporters(
|
||||||
exporters = [
|
organizer=self.request.organizer,
|
||||||
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else self.events,
|
user=self.request.user if self.request.user and self.request.user.is_authenticated else None,
|
||||||
self.request.organizer)
|
token=self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None,
|
||||||
for r, response in responses if response
|
device=self.request.auth if isinstance(self.request.auth, Device) else None,
|
||||||
]
|
request=self.request,
|
||||||
|
))
|
||||||
return {e.identifier: e for e in exporters}
|
return {e.identifier: e for e in exporters}
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
|
if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner:
|
||||||
|
# This is to prevent a possible privilege escalation where user A creates a scheduled export and
|
||||||
|
# user B has settings permission (= they can see the export configuration), but not enough permission
|
||||||
|
# to run the export themselves. Without this check, user B could modify the export and add themselves
|
||||||
|
# as a recipient. Thereby, user B would gain access to data they can't have.
|
||||||
|
exporter = self.exporters.get(serializer.instance.export_identifier)
|
||||||
|
if not exporter:
|
||||||
|
raise PermissionDenied("No access to exporter.")
|
||||||
|
perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken))
|
||||||
|
else self.request.user)
|
||||||
|
if isinstance(exporter, OrganizerLevelExportMixin):
|
||||||
|
if not perm_holder.has_organizer_permission(
|
||||||
|
self.request.organizer, exporter.get_required_organizer_permission(), request=self.request,
|
||||||
|
):
|
||||||
|
raise PermissionDenied("No permission to edit exports you could not run.")
|
||||||
|
else:
|
||||||
|
if serializer.instance.export_form_data.get("all_events", False):
|
||||||
|
if isinstance(self.request.auth, Device):
|
||||||
|
if not self.request.auth.all_events:
|
||||||
|
raise PermissionDenied("No permission to edit exports you could not run.")
|
||||||
|
elif isinstance(self.request.auth, TeamAPIToken):
|
||||||
|
if not self.request.auth.team.all_events:
|
||||||
|
raise PermissionDenied("No permission to edit exports you could not run.")
|
||||||
|
elif self.request.user.is_authenticated:
|
||||||
|
if not self.request.user.teams.filter(
|
||||||
|
TeamQuerySet.event_permission_q(exporter.get_required_event_permission()),
|
||||||
|
all_events=True,
|
||||||
|
).exists():
|
||||||
|
raise PermissionDenied("No permission to edit exports you could not run.")
|
||||||
|
else:
|
||||||
|
events_selected = serializer.instance.export_form_data.get("events", [])
|
||||||
|
events_permission = set(perm_holder.get_events_with_permission(
|
||||||
|
exporter.get_required_event_permission(), request=self.request
|
||||||
|
).values_list("pk", flat=True))
|
||||||
|
if not all(e in events_permission for e in events_selected):
|
||||||
|
raise PermissionDenied("No permission to edit exports you could not run.")
|
||||||
|
|
||||||
serializer.save(organizer=self.request.organizer)
|
serializer.save(organizer=self.request.organizer)
|
||||||
serializer.instance.compute_next_run()
|
serializer.instance.compute_next_run()
|
||||||
serializer.instance.error_counter = 0
|
serializer.instance.error_counter = 0
|
||||||
|
|||||||
@@ -99,14 +99,14 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
ordering = ('position', 'id')
|
ordering = ('position', 'id')
|
||||||
filterset_class = ItemFilter
|
filterset_class = ItemFilter
|
||||||
permission = None
|
permission = None
|
||||||
write_permission = 'can_change_items'
|
write_permission = 'event.items:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.event.items.select_related('tax_rule').prefetch_related(
|
return self.request.event.items.select_related('tax_rule').prefetch_related(
|
||||||
'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',
|
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times'
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
@@ -163,7 +163,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
|
|||||||
ordering_fields = ('id', 'position')
|
ordering_fields = ('id', 'position')
|
||||||
ordering = ('id',)
|
ordering = ('id',)
|
||||||
permission = None
|
permission = None
|
||||||
write_permission = 'can_change_items'
|
write_permission = 'event.items:write'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def item(self):
|
def item(self):
|
||||||
@@ -234,7 +234,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
|
|||||||
ordering_fields = ('id',)
|
ordering_fields = ('id',)
|
||||||
ordering = ('id',)
|
ordering = ('id',)
|
||||||
permission = None
|
permission = None
|
||||||
write_permission = 'can_change_items'
|
write_permission = 'event.items:write'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def item(self):
|
def item(self):
|
||||||
@@ -286,7 +286,7 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet):
|
|||||||
ordering_fields = ('id',)
|
ordering_fields = ('id',)
|
||||||
ordering = ('id',)
|
ordering = ('id',)
|
||||||
permission = None
|
permission = None
|
||||||
write_permission = 'can_change_items'
|
write_permission = 'event.items:write'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def item(self):
|
def item(self):
|
||||||
@@ -339,7 +339,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
|
|||||||
ordering_fields = ('id', 'position')
|
ordering_fields = ('id', 'position')
|
||||||
ordering = ('id',)
|
ordering = ('id',)
|
||||||
permission = None
|
permission = None
|
||||||
write_permission = 'can_change_items'
|
write_permission = 'event.items:write'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def item(self):
|
def item(self):
|
||||||
@@ -398,7 +398,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
ordering_fields = ('id', 'position')
|
ordering_fields = ('id', 'position')
|
||||||
ordering = ('position', 'id')
|
ordering = ('position', 'id')
|
||||||
permission = None
|
permission = None
|
||||||
write_permission = 'can_change_items'
|
write_permission = 'event.items:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.event.categories.all()
|
return self.request.event.categories.all()
|
||||||
@@ -453,7 +453,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
ordering_fields = ('id', 'position')
|
ordering_fields = ('id', 'position')
|
||||||
ordering = ('position', 'id')
|
ordering = ('position', 'id')
|
||||||
permission = None
|
permission = None
|
||||||
write_permission = 'can_change_items'
|
write_permission = 'event.items:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.event.questions.prefetch_related('options').all()
|
return self.request.event.questions.prefetch_related('options').all()
|
||||||
@@ -497,7 +497,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
|||||||
ordering_fields = ('id', 'position')
|
ordering_fields = ('id', 'position')
|
||||||
ordering = ('position',)
|
ordering = ('position',)
|
||||||
permission = None
|
permission = None
|
||||||
write_permission = 'can_change_items'
|
write_permission = 'event.items:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
|
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
|
||||||
@@ -564,10 +564,10 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
ordering_fields = ('id', 'size')
|
ordering_fields = ('id', 'size')
|
||||||
ordering = ('id',)
|
ordering = ('id',)
|
||||||
permission = None
|
permission = None
|
||||||
write_permission = 'can_change_items'
|
write_permission = 'event.items:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.event.quotas.all()
|
return self.request.event.quotas.select_related('subevent').prefetch_related('items', 'variations').all()
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
queryset = self.filter_queryset(self.get_queryset()).distinct()
|
queryset = self.filter_queryset(self.get_queryset()).distinct()
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ with scopes_disabled():
|
|||||||
class ReusableMediaViewSet(viewsets.ModelViewSet):
|
class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = ReusableMediaSerializer
|
serializer_class = ReusableMediaSerializer
|
||||||
queryset = ReusableMedium.objects.none()
|
queryset = ReusableMedium.objects.none()
|
||||||
permission = 'can_manage_reusable_media'
|
permission = 'organizer.reusablemedia:read'
|
||||||
write_permission = 'can_manage_reusable_media'
|
write_permission = 'organizer.reusablemedia:write'
|
||||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||||
ordering = ('-updated', '-id')
|
ordering = ('-updated', '-id')
|
||||||
ordering_fields = ('created', 'updated', 'identifier', 'type', 'id')
|
ordering_fields = ('created', 'updated', 'identifier', 'type', 'id')
|
||||||
@@ -95,6 +95,8 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
|||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
ctx['organizer'] = self.request.organizer
|
ctx['organizer'] = self.request.organizer
|
||||||
|
ctx['can_read_giftcards'] = 'organizer.giftcards:read' in self.request.orgapermset
|
||||||
|
ctx['can_read_customers'] = 'organizer.customers:read' in self.request.orgapermset
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ from pretix.api.serializers.order import (
|
|||||||
BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer,
|
BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer,
|
||||||
OrderPaymentCreateSerializer, OrderPaymentSerializer,
|
OrderPaymentCreateSerializer, OrderPaymentSerializer,
|
||||||
OrderPositionSerializer, OrderRefundCreateSerializer,
|
OrderPositionSerializer, OrderRefundCreateSerializer,
|
||||||
OrderRefundSerializer, OrderSerializer, OrganizerTransactionSerializer,
|
OrderRefundSerializer, OrderSerializer, OrganizerOrderPositionSerializer,
|
||||||
PriceCalcSerializer, PrintLogSerializer, RevokedTicketSecretSerializer,
|
OrganizerTransactionSerializer, PriceCalcSerializer, PrintLogSerializer,
|
||||||
SimulatedOrderSerializer, TransactionSerializer,
|
RevokedTicketSecretSerializer, SimulatedOrderSerializer,
|
||||||
|
TransactionSerializer,
|
||||||
)
|
)
|
||||||
from pretix.api.serializers.orderchange import (
|
from pretix.api.serializers.orderchange import (
|
||||||
BlockNameSerializer, OrderChangeOperationSerializer,
|
BlockNameSerializer, OrderChangeOperationSerializer,
|
||||||
@@ -90,7 +91,6 @@ 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,
|
||||||
@@ -317,7 +317,7 @@ class OrderViewSetMixin:
|
|||||||
|
|
||||||
class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
|
class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
def get_base_queryset(self):
|
def get_base_queryset(self):
|
||||||
perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
|
perm = "event.orders:read" if self.request.method in SAFE_METHODS else "event.orders:write"
|
||||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||||
return Order.objects.filter(
|
return Order.objects.filter(
|
||||||
event__organizer=self.request.organizer,
|
event__organizer=self.request.organizer,
|
||||||
@@ -338,8 +338,8 @@ class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||||
permission = 'can_view_orders'
|
permission = 'event.orders:read'
|
||||||
write_permission = 'can_change_orders'
|
write_permission = 'event.orders:write'
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
@@ -439,8 +439,6 @@ 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(
|
||||||
@@ -634,10 +632,7 @@ 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)
|
||||||
try:
|
order.resend_link(user=self.request.user, auth=self.request.auth)
|
||||||
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
|
||||||
@@ -1071,15 +1066,12 @@ with scopes_disabled():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class OrderPositionViewSet(viewsets.ModelViewSet):
|
class OrderPositionViewSetMixin:
|
||||||
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')
|
||||||
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
|
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
|
||||||
filterset_class = OrderPositionFilter
|
filterset_class = OrderPositionFilter
|
||||||
permission = 'can_view_orders'
|
|
||||||
write_permission = 'can_change_orders'
|
|
||||||
ordering_custom = {
|
ordering_custom = {
|
||||||
'attendee_name': {
|
'attendee_name': {
|
||||||
'_order': F('display_name').asc(nulls_first=True),
|
'_order': F('display_name').asc(nulls_first=True),
|
||||||
@@ -1093,8 +1085,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
ctx['event'] = self.request.event
|
ctx['pdf_data'] = False
|
||||||
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
|
||||||
|
|
||||||
@@ -1103,9 +1094,8 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
|||||||
qs = OrderPosition.all
|
qs = OrderPosition.all
|
||||||
else:
|
else:
|
||||||
qs = OrderPosition.objects
|
qs = OrderPosition.objects
|
||||||
|
qs = qs.filter(order__event__organizer=self.request.organizer)
|
||||||
qs = qs.filter(order__event=self.request.event)
|
if self.request.query_params.get('pdf_data', 'false').lower() == 'true' and getattr(self.request, 'event', None):
|
||||||
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],
|
||||||
@@ -1160,9 +1150,9 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
|||||||
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',
|
'answers', 'answers__options', 'answers__question', 'order__event', 'order__event__organizer'
|
||||||
).select_related(
|
).select_related(
|
||||||
'item', 'order', 'order__event', 'order__event__organizer', 'seat'
|
'item', 'order', 'seat'
|
||||||
)
|
)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -1174,6 +1164,49 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
|||||||
return prov
|
return prov
|
||||||
raise NotFound('Unknown output provider.')
|
raise NotFound('Unknown output provider.')
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizerOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
|
serializer_class = OrganizerOrderPositionSerializer
|
||||||
|
permission = None
|
||||||
|
write_permission = None
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = super().get_queryset()
|
||||||
|
|
||||||
|
perm = "event.orders:read" if self.request.method in SAFE_METHODS else "event.orders:write"
|
||||||
|
|
||||||
|
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
|
||||||
|
permission = 'event.orders:read'
|
||||||
|
write_permission = 'event.orders:write'
|
||||||
|
|
||||||
|
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):
|
||||||
"""
|
"""
|
||||||
@@ -1580,8 +1613,8 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
|
|||||||
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
serializer_class = OrderPaymentSerializer
|
serializer_class = OrderPaymentSerializer
|
||||||
queryset = OrderPayment.objects.none()
|
queryset = OrderPayment.objects.none()
|
||||||
permission = 'can_view_orders'
|
permission = 'event.orders:read'
|
||||||
write_permission = 'can_change_orders'
|
write_permission = 'event.orders:write'
|
||||||
lookup_field = 'local_id'
|
lookup_field = 'local_id'
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
@@ -1616,8 +1649,6 @@ 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)
|
||||||
|
|
||||||
@@ -1655,8 +1686,6 @@ 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'])
|
||||||
@@ -1757,8 +1786,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
serializer_class = OrderRefundSerializer
|
serializer_class = OrderRefundSerializer
|
||||||
queryset = OrderRefund.objects.none()
|
queryset = OrderRefund.objects.none()
|
||||||
permission = 'can_view_orders'
|
permission = 'event.orders:read'
|
||||||
write_permission = 'can_change_orders'
|
write_permission = 'event.orders:write'
|
||||||
lookup_field = 'local_id'
|
lookup_field = 'local_id'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@@ -1915,13 +1944,18 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
ordering = ('nr',)
|
ordering = ('nr',)
|
||||||
ordering_fields = ('nr', 'date')
|
ordering_fields = ('nr', 'date')
|
||||||
filterset_class = InvoiceFilter
|
filterset_class = InvoiceFilter
|
||||||
permission = 'can_view_orders'
|
|
||||||
lookup_url_kwarg = 'number'
|
lookup_url_kwarg = 'number'
|
||||||
lookup_field = 'nr'
|
lookup_field = 'nr'
|
||||||
write_permission = 'can_change_orders'
|
|
||||||
|
def _get_permission_name(self, request):
|
||||||
|
if 'event' in request.resolver_match.kwargs:
|
||||||
|
if request.method not in SAFE_METHODS:
|
||||||
|
return "event.orders:write"
|
||||||
|
return "event.orders:read"
|
||||||
|
return None # org-level is handled by event__in check
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
|
perm = "event.orders:read" if self.request.method in SAFE_METHODS else "event.orders:write"
|
||||||
if getattr(self.request, 'event', None):
|
if getattr(self.request, 'event', None):
|
||||||
qs = self.request.event.invoices
|
qs = self.request.event.invoices
|
||||||
elif isinstance(self.request.auth, (TeamAPIToken, Device)):
|
elif isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||||
@@ -2031,7 +2065,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 inv.order.status != Order.STATUS_CANCELED:
|
if invoice_qualified(order):
|
||||||
inv = generate_invoice(order)
|
inv = generate_invoice(order)
|
||||||
else:
|
else:
|
||||||
inv = c
|
inv = c
|
||||||
@@ -2062,8 +2096,8 @@ class RevokedSecretViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
ordering = ('-created',)
|
ordering = ('-created',)
|
||||||
ordering_fields = ('created', 'secret')
|
ordering_fields = ('created', 'secret')
|
||||||
filterset_class = RevokedSecretFilter
|
filterset_class = RevokedSecretFilter
|
||||||
permission = 'can_view_orders'
|
permission = 'event.orders:read'
|
||||||
write_permission = 'can_change_orders'
|
write_permission = 'event.orders:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return RevokedTicketSecret.objects.filter(event=self.request.event)
|
return RevokedTicketSecret.objects.filter(event=self.request.event)
|
||||||
@@ -2084,8 +2118,8 @@ class BlockedSecretViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||||
ordering = ('-updated', '-pk')
|
ordering = ('-updated', '-pk')
|
||||||
filterset_class = BlockedSecretFilter
|
filterset_class = BlockedSecretFilter
|
||||||
permission = 'can_view_orders'
|
permission = 'event.orders:read'
|
||||||
write_permission = 'can_change_orders'
|
write_permission = 'event.orders:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return BlockedTicketSecret.objects.filter(event=self.request.event)
|
return BlockedTicketSecret.objects.filter(event=self.request.event)
|
||||||
@@ -2120,7 +2154,7 @@ class TransactionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
ordering = ('datetime', 'pk')
|
ordering = ('datetime', 'pk')
|
||||||
ordering_fields = ('datetime', 'created', 'id',)
|
ordering_fields = ('datetime', 'created', 'id',)
|
||||||
filterset_class = TransactionFilter
|
filterset_class = TransactionFilter
|
||||||
permission = 'can_view_orders'
|
permission = 'event.orders:read'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Transaction.objects.filter(order__event=self.request.event).select_related("order")
|
return Transaction.objects.filter(order__event=self.request.event).select_related("order")
|
||||||
@@ -2137,11 +2171,11 @@ class OrganizerTransactionViewSet(TransactionViewSet):
|
|||||||
|
|
||||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
order__event__in=self.request.auth.get_events_with_permission("can_view_orders"),
|
order__event__in=self.request.auth.get_events_with_permission("event.orders:read"),
|
||||||
)
|
)
|
||||||
elif self.request.user.is_authenticated:
|
elif self.request.user.is_authenticated:
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
order__event__in=self.request.user.get_events_with_permission("can_view_orders", request=self.request)
|
order__event__in=self.request.user.get_events_with_permission("event.orders:read", request=self.request)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise PermissionDenied("Unknown authentication scheme")
|
raise PermissionDenied("Unknown authentication scheme")
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
filter_backends = (TotalOrderingFilter,)
|
filter_backends = (TotalOrderingFilter,)
|
||||||
ordering = ('slug',)
|
ordering = ('slug',)
|
||||||
ordering_fields = ('name', 'slug')
|
ordering_fields = ('name', 'slug')
|
||||||
write_permission = "can_change_organizer_settings"
|
write_permission = "organizer.settings.general:write"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
@@ -154,8 +154,8 @@ class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
class SeatingPlanViewSet(viewsets.ModelViewSet):
|
class SeatingPlanViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = SeatingPlanSerializer
|
serializer_class = SeatingPlanSerializer
|
||||||
queryset = SeatingPlan.objects.none()
|
queryset = SeatingPlan.objects.none()
|
||||||
permission = 'can_change_organizer_settings'
|
permission = None
|
||||||
write_permission = 'can_change_organizer_settings'
|
write_permission = 'organizer.seatingplans:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.organizer.seating_plans.order_by('name')
|
return self.request.organizer.seating_plans.order_by('name')
|
||||||
@@ -221,8 +221,8 @@ with scopes_disabled():
|
|||||||
class GiftCardViewSet(viewsets.ModelViewSet):
|
class GiftCardViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = GiftCardSerializer
|
serializer_class = GiftCardSerializer
|
||||||
queryset = GiftCard.objects.none()
|
queryset = GiftCard.objects.none()
|
||||||
permission = 'can_manage_gift_cards'
|
permission = 'organizer.giftcards:read'
|
||||||
write_permission = 'can_manage_gift_cards'
|
write_permission = 'organizer.giftcards:write'
|
||||||
filter_backends = (DjangoFilterBackend,)
|
filter_backends = (DjangoFilterBackend,)
|
||||||
filterset_class = GiftCardFilter
|
filterset_class = GiftCardFilter
|
||||||
|
|
||||||
@@ -249,12 +249,24 @@ 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.transactions.create(value=value, acceptor=self.request.organizer)
|
|
||||||
inst.log_action(
|
inst.log_action(
|
||||||
'pretix.giftcards.transaction.manual',
|
action='pretix.giftcards.created',
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
)
|
||||||
|
inst.transactions.create(value=value, acceptor=self.request.organizer)
|
||||||
|
inst.log_action(
|
||||||
|
action='pretix.giftcards.transaction.manual',
|
||||||
|
user=self.request.user,
|
||||||
|
auth=self.request.auth,
|
||||||
|
data=merge_dicts(
|
||||||
|
self.request.data,
|
||||||
|
{
|
||||||
|
'id': inst.pk,
|
||||||
|
'acceptor_id': self.request.organizer.id,
|
||||||
|
'acceptor_slug': self.request.organizer.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
@@ -269,7 +281,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(
|
||||||
'pretix.giftcards.modified',
|
action='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,
|
||||||
@@ -282,10 +294,14 @@ 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(
|
||||||
'pretix.giftcards.transaction.manual',
|
action='pretix.giftcards.transaction.manual',
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
data={'value': diff}
|
data={
|
||||||
|
'value': diff,
|
||||||
|
'acceptor_id': self.request.organizer.id,
|
||||||
|
'acceptor_slug': self.request.organizer.slug
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return inst
|
return inst
|
||||||
@@ -309,10 +325,15 @@ 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(
|
||||||
'pretix.giftcards.transaction.manual',
|
action='pretix.giftcards.transaction.manual',
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
data={'value': value, 'text': text}
|
data={
|
||||||
|
'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)
|
||||||
|
|
||||||
@@ -323,8 +344,8 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
|||||||
class GiftCardTransactionViewSet(viewsets.ReadOnlyModelViewSet):
|
class GiftCardTransactionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
serializer_class = GiftCardTransactionSerializer
|
serializer_class = GiftCardTransactionSerializer
|
||||||
queryset = GiftCardTransaction.objects.none()
|
queryset = GiftCardTransaction.objects.none()
|
||||||
permission = 'can_manage_gift_cards'
|
permission = 'organizer.giftcards:read'
|
||||||
write_permission = 'can_manage_gift_cards'
|
write_permission = 'organizer.giftcards:write'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def giftcard(self):
|
def giftcard(self):
|
||||||
@@ -341,8 +362,8 @@ class GiftCardTransactionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
class TeamViewSet(viewsets.ModelViewSet):
|
class TeamViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = TeamSerializer
|
serializer_class = TeamSerializer
|
||||||
queryset = Team.objects.none()
|
queryset = Team.objects.none()
|
||||||
permission = 'can_change_teams'
|
permission = 'organizer.teams:write'
|
||||||
write_permission = 'can_change_teams'
|
write_permission = 'organizer.teams:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.organizer.teams.order_by('pk')
|
return self.request.organizer.teams.order_by('pk')
|
||||||
@@ -381,8 +402,8 @@ class TeamViewSet(viewsets.ModelViewSet):
|
|||||||
class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
serializer_class = TeamMemberSerializer
|
serializer_class = TeamMemberSerializer
|
||||||
queryset = User.objects.none()
|
queryset = User.objects.none()
|
||||||
permission = 'can_change_teams'
|
permission = 'organizer.teams:write'
|
||||||
write_permission = 'can_change_teams'
|
write_permission = 'organizer.teams:write'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def team(self):
|
def team(self):
|
||||||
@@ -410,8 +431,8 @@ class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
serializer_class = TeamInviteSerializer
|
serializer_class = TeamInviteSerializer
|
||||||
queryset = TeamInvite.objects.none()
|
queryset = TeamInvite.objects.none()
|
||||||
permission = 'can_change_teams'
|
permission = 'organizer.teams:write'
|
||||||
write_permission = 'can_change_teams'
|
write_permission = 'organizer.teams:write'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def team(self):
|
def team(self):
|
||||||
@@ -447,8 +468,8 @@ class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyMo
|
|||||||
class TeamAPITokenViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
class TeamAPITokenViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
serializer_class = TeamAPITokenSerializer
|
serializer_class = TeamAPITokenSerializer
|
||||||
queryset = TeamAPIToken.objects.none()
|
queryset = TeamAPIToken.objects.none()
|
||||||
permission = 'can_change_teams'
|
permission = 'organizer.teams:write'
|
||||||
write_permission = 'can_change_teams'
|
write_permission = 'organizer.teams:write'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def team(self):
|
def team(self):
|
||||||
@@ -511,8 +532,8 @@ class DeviceViewSet(mixins.CreateModelMixin,
|
|||||||
GenericViewSet):
|
GenericViewSet):
|
||||||
serializer_class = DeviceSerializer
|
serializer_class = DeviceSerializer
|
||||||
queryset = Device.objects.none()
|
queryset = Device.objects.none()
|
||||||
permission = 'can_change_organizer_settings'
|
permission = 'organizer.devices:read'
|
||||||
write_permission = 'can_change_organizer_settings'
|
write_permission = 'organizer.devices:write'
|
||||||
lookup_field = 'device_id'
|
lookup_field = 'device_id'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@@ -521,6 +542,9 @@ class DeviceViewSet(mixins.CreateModelMixin,
|
|||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
ctx['organizer'] = self.request.organizer
|
ctx['organizer'] = self.request.organizer
|
||||||
|
ctx['can_see_tokens'] = (
|
||||||
|
self.request.user if self.request.user and self.request.user.is_authenticated else self.request.auth
|
||||||
|
).has_organizer_permission(self.request.organizer, 'organizer.devices:write', request=self.request)
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
@@ -547,11 +571,11 @@ class DeviceViewSet(mixins.CreateModelMixin,
|
|||||||
|
|
||||||
class OrganizerSettingsView(views.APIView):
|
class OrganizerSettingsView(views.APIView):
|
||||||
permission = None
|
permission = None
|
||||||
write_permission = 'can_change_organizer_settings'
|
write_permission = 'organizer.settings.general:write'
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
||||||
'request': request
|
'request': request, 'permissions': request.orgapermset
|
||||||
})
|
})
|
||||||
if 'explain' in request.GET:
|
if 'explain' in request.GET:
|
||||||
return Response({
|
return Response({
|
||||||
@@ -568,7 +592,7 @@ class OrganizerSettingsView(views.APIView):
|
|||||||
s = OrganizerSettingsSerializer(
|
s = OrganizerSettingsSerializer(
|
||||||
instance=request.organizer.settings, data=request.data, partial=True,
|
instance=request.organizer.settings, data=request.data, partial=True,
|
||||||
organizer=request.organizer, context={
|
organizer=request.organizer, context={
|
||||||
'request': request
|
'request': request, 'permissions': request.orgapermset
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
s.is_valid(raise_exception=True)
|
s.is_valid(raise_exception=True)
|
||||||
@@ -580,7 +604,7 @@ class OrganizerSettingsView(views.APIView):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
||||||
'request': request
|
'request': request, 'permissions': request.orgapermset
|
||||||
})
|
})
|
||||||
return Response(s.data)
|
return Response(s.data)
|
||||||
|
|
||||||
@@ -597,7 +621,8 @@ with scopes_disabled():
|
|||||||
class CustomerViewSet(viewsets.ModelViewSet):
|
class CustomerViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = CustomerSerializer
|
serializer_class = CustomerSerializer
|
||||||
queryset = Customer.objects.none()
|
queryset = Customer.objects.none()
|
||||||
permission = 'can_manage_customers'
|
permission = 'organizer.customers:read'
|
||||||
|
write_permission = 'organizer.customers:write'
|
||||||
lookup_field = 'identifier'
|
lookup_field = 'identifier'
|
||||||
filter_backends = (DjangoFilterBackend,)
|
filter_backends = (DjangoFilterBackend,)
|
||||||
filterset_class = CustomerFilter
|
filterset_class = CustomerFilter
|
||||||
@@ -657,7 +682,7 @@ class CustomerViewSet(viewsets.ModelViewSet):
|
|||||||
class MembershipTypeViewSet(viewsets.ModelViewSet):
|
class MembershipTypeViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = MembershipTypeSerializer
|
serializer_class = MembershipTypeSerializer
|
||||||
queryset = MembershipType.objects.none()
|
queryset = MembershipType.objects.none()
|
||||||
permission = 'can_change_organizer_settings'
|
permission = 'organizer.settings.general:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = self.request.organizer.membership_types.all()
|
qs = self.request.organizer.membership_types.all()
|
||||||
@@ -714,14 +739,15 @@ with scopes_disabled():
|
|||||||
class MembershipViewSet(viewsets.ModelViewSet):
|
class MembershipViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = MembershipSerializer
|
serializer_class = MembershipSerializer
|
||||||
queryset = Membership.objects.none()
|
queryset = Membership.objects.none()
|
||||||
permission = 'can_manage_customers'
|
permission = 'organizer.customers:read'
|
||||||
|
write_permission = 'organizer.customers:write'
|
||||||
filter_backends = (DjangoFilterBackend,)
|
filter_backends = (DjangoFilterBackend,)
|
||||||
filterset_class = MembershipFilter
|
filterset_class = MembershipFilter
|
||||||
|
|
||||||
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()
|
||||||
@@ -764,8 +790,8 @@ with scopes_disabled():
|
|||||||
class SalesChannelViewSet(viewsets.ModelViewSet):
|
class SalesChannelViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = SalesChannelSerializer
|
serializer_class = SalesChannelSerializer
|
||||||
queryset = SalesChannel.objects.none()
|
queryset = SalesChannel.objects.none()
|
||||||
permission = 'can_change_organizer_settings'
|
permission = 'organizer.settings.general:write'
|
||||||
write_permission = 'can_change_organizer_settings'
|
write_permission = 'organizer.settings.general:write'
|
||||||
filter_backends = (DjangoFilterBackend,)
|
filter_backends = (DjangoFilterBackend,)
|
||||||
filterset_class = SalesChannelFilter
|
filterset_class = SalesChannelFilter
|
||||||
lookup_field = 'identifier'
|
lookup_field = 'identifier'
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ class ShreddersMixin:
|
|||||||
|
|
||||||
|
|
||||||
class EventShreddersViewSet(ShreddersMixin, viewsets.ViewSet):
|
class EventShreddersViewSet(ShreddersMixin, viewsets.ViewSet):
|
||||||
permission = 'can_change_orders'
|
permission = 'event.orders:write'
|
||||||
|
|
||||||
def get_serializer_kwargs(self):
|
def get_serializer_kwargs(self):
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
# 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
|
||||||
@@ -61,11 +62,16 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
|||||||
ordering = ('id',)
|
ordering = ('id',)
|
||||||
ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value')
|
ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value')
|
||||||
filterset_class = VoucherFilter
|
filterset_class = VoucherFilter
|
||||||
permission = 'can_view_vouchers'
|
permission = 'event.vouchers:read'
|
||||||
write_permission = 'can_change_vouchers'
|
write_permission = 'event.vouchers:write'
|
||||||
|
|
||||||
|
@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 self.request.event.vouchers.select_related('seat').all()
|
return Voucher.annotate_budget_used(
|
||||||
|
self.request.event.vouchers
|
||||||
|
).select_related(
|
||||||
|
'item', 'quota', 'seat', 'variation'
|
||||||
|
)
|
||||||
|
|
||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ class WaitingListViewSet(viewsets.ModelViewSet):
|
|||||||
ordering = ('created', 'pk',)
|
ordering = ('created', 'pk',)
|
||||||
ordering_fields = ('id', 'created', 'email', 'item')
|
ordering_fields = ('id', 'created', 'email', 'item')
|
||||||
filterset_class = WaitingListFilter
|
filterset_class = WaitingListFilter
|
||||||
permission = 'can_view_orders'
|
permission = 'event.orders:read'
|
||||||
write_permission = 'can_change_orders'
|
write_permission = 'event.orders:write'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.event.waitinglistentries.all()
|
return self.request.event.waitinglistentries.all()
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ class WebhookFilter(FilterSet):
|
|||||||
class WebHookViewSet(viewsets.ModelViewSet):
|
class WebHookViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = WebHookSerializer
|
serializer_class = WebHookSerializer
|
||||||
queryset = WebHook.objects.none()
|
queryset = WebHook.objects.none()
|
||||||
permission = 'can_change_organizer_settings'
|
permission = 'organizer.settings.general:write'
|
||||||
write_permission = 'can_change_organizer_settings'
|
write_permission = 'organizer.settings.general:write'
|
||||||
filter_backends = (DjangoFilterBackend,)
|
filter_backends = (DjangoFilterBackend,)
|
||||||
filterset_class = WebhookFilter
|
filterset_class = WebhookFilter
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,38 @@ 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):
|
||||||
@@ -433,6 +465,18 @@ 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'),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ class HistoryPasswordValidator:
|
|||||||
).delete()
|
).delete()
|
||||||
|
|
||||||
|
|
||||||
def has_event_access_permission(request, permission='can_change_event_settings'):
|
def has_event_access_permission(request, permission='event.settings.general:write'):
|
||||||
return (
|
return (
|
||||||
request.user.is_authenticated and
|
request.user.is_authenticated and
|
||||||
request.user.has_event_permission(request.organizer, request.event, permission, request=request)
|
request.user.has_event_permission(request.organizer, request.event, permission, request=request)
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ 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
|
||||||
@@ -215,7 +216,10 @@ class OutboundSyncProvider:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
mapped_objects = self.sync_order(sq.order)
|
mapped_objects = self.sync_order(sq.order)
|
||||||
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()):
|
actions_taken = [res and res.sync_info.get("action", "") for res_list in mapped_objects.values() for res in res_list]
|
||||||
|
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": {
|
||||||
@@ -236,7 +240,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 {type(self).__name__} "
|
f"Could not sync order {sq.order.code} to {sq.sync_provider} "
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -281,7 +285,8 @@ 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)])
|
||||||
|
|
||||||
val = ",".join(val)
|
if self.list_field_joiner:
|
||||||
|
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]):
|
||||||
|
|||||||
@@ -71,15 +71,20 @@ def assign_properties(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _add_to_list(out, field_name, current_value, new_item, list_sep):
|
def _add_to_list(out, field_name, current_value, new_item_input, list_sep):
|
||||||
new_item = str(new_item)
|
|
||||||
if list_sep is not None:
|
if list_sep is not None:
|
||||||
new_item = new_item.replace(list_sep, "")
|
new_items = str(new_item_input).split(list_sep)
|
||||||
current_value = current_value.split(list_sep) if current_value else []
|
current_value = current_value.split(list_sep) if current_value else []
|
||||||
elif not isinstance(current_value, (list, tuple)):
|
else:
|
||||||
current_value = [str(current_value)]
|
new_items = [str(new_item_input)]
|
||||||
if new_item not in current_value:
|
if not isinstance(current_value, (list, tuple)):
|
||||||
new_list = current_value + [new_item]
|
current_value = [str(current_value)]
|
||||||
|
|
||||||
|
new_list = list(current_value)
|
||||||
|
for new_item in new_items:
|
||||||
|
if new_item not in current_value:
|
||||||
|
new_list.append(new_item)
|
||||||
|
if new_list != current_value:
|
||||||
if list_sep is not None:
|
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
|
||||||
|
|||||||
@@ -19,7 +19,10 @@
|
|||||||
# 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 ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import smtplib
|
||||||
|
import socket
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
from smtplib import SMTPResponseException
|
from smtplib import SMTPResponseException
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
@@ -39,7 +42,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 SafeFormatter, format_map
|
from pretix.helpers.format import FormattedString, 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,6 +144,7 @@ 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(
|
||||||
@@ -149,12 +153,13 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
|||||||
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||||
parse_email=True
|
parse_email=True
|
||||||
)
|
)
|
||||||
body_md = format_map(
|
if apply_format_map:
|
||||||
body_md,
|
body_md = format_map(
|
||||||
context=context,
|
body_md,
|
||||||
mode=SafeFormatter.MODE_RICH_TO_HTML,
|
context=context,
|
||||||
linkifier=linker
|
mode=SafeFormatter.MODE_RICH_TO_HTML,
|
||||||
)
|
linkifier=linker
|
||||||
|
)
|
||||||
htmlctx = {
|
htmlctx = {
|
||||||
'site': settings.PRETIX_INSTANCE_NAME,
|
'site': settings.PRETIX_INSTANCE_NAME,
|
||||||
'site_url': settings.SITE_URL,
|
'site_url': settings.SITE_URL,
|
||||||
@@ -235,3 +240,80 @@ def base_renderers(sender, **kwargs):
|
|||||||
|
|
||||||
def get_email_context(**kwargs):
|
def get_email_context(**kwargs):
|
||||||
return PlaceholderContext(**kwargs).render_all()
|
return PlaceholderContext(**kwargs).render_all()
|
||||||
|
|
||||||
|
|
||||||
|
def create_connection(address, timeout=socket.getdefaulttimeout(),
|
||||||
|
source_address=None, *, all_errors=False):
|
||||||
|
# Taken from the python stdlib, extended with a check for local ips
|
||||||
|
|
||||||
|
host, port = address
|
||||||
|
exceptions = []
|
||||||
|
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
|
||||||
|
af, socktype, proto, canonname, sa = res
|
||||||
|
|
||||||
|
if not settings.get("MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
|
||||||
|
ip_addr = ipaddress.ip_address(sa[0])
|
||||||
|
if ip_addr.is_multicast:
|
||||||
|
raise socket.error(f"Request to multicast address {sa[0]} blocked")
|
||||||
|
if ip_addr.is_loopback or ip_addr.is_link_local:
|
||||||
|
raise socket.error(f"Request to local address {sa[0]} blocked")
|
||||||
|
if ip_addr.is_private:
|
||||||
|
raise socket.error(f"Request to private address {sa[0]} blocked")
|
||||||
|
|
||||||
|
sock = None
|
||||||
|
try:
|
||||||
|
sock = socket.socket(af, socktype, proto)
|
||||||
|
if timeout is not socket.getdefaulttimeout():
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
if source_address:
|
||||||
|
sock.bind(source_address)
|
||||||
|
sock.connect(sa)
|
||||||
|
# Break explicitly a reference cycle
|
||||||
|
exceptions.clear()
|
||||||
|
return sock
|
||||||
|
|
||||||
|
except socket.error as exc:
|
||||||
|
if not all_errors:
|
||||||
|
exceptions.clear() # raise only the last error
|
||||||
|
exceptions.append(exc)
|
||||||
|
if sock is not None:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
if len(exceptions):
|
||||||
|
try:
|
||||||
|
if not all_errors:
|
||||||
|
raise exceptions[0]
|
||||||
|
raise ExceptionGroup("create_connection failed", exceptions)
|
||||||
|
finally:
|
||||||
|
# Break explicitly a reference cycle
|
||||||
|
exceptions.clear()
|
||||||
|
else:
|
||||||
|
raise socket.error("getaddrinfo returns an empty list")
|
||||||
|
|
||||||
|
|
||||||
|
class CheckPrivateNetworkMixin:
|
||||||
|
# _get_socket taken 1:1 from smtplib, just with a call to our own create_connection
|
||||||
|
def _get_socket(self, host, port, timeout):
|
||||||
|
# This makes it simpler for SMTP_SSL to use the SMTP connect code
|
||||||
|
# and just alter the socket connection bit.
|
||||||
|
if timeout is not None and not timeout:
|
||||||
|
raise ValueError('Non-blocking socket (timeout=0) is not supported')
|
||||||
|
if self.debuglevel > 0:
|
||||||
|
self._print_debug('connect: to', (host, port), self.source_address)
|
||||||
|
return create_connection((host, port), timeout, self.source_address)
|
||||||
|
|
||||||
|
|
||||||
|
class SMTP(CheckPrivateNetworkMixin, smtplib.SMTP):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# SMTP used here instead of mixin, because smtp.SMTP_SSL._get_socket calls super()._get_socket and then wraps this socket
|
||||||
|
# super()._get_socket needs to be our version from the mixin
|
||||||
|
class SMTP_SSL(smtplib.SMTP_SSL, SMTP): # noqa: N801
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CheckPrivateNetworkSmtpBackend(EmailBackend):
|
||||||
|
@property
|
||||||
|
def connection_class(self):
|
||||||
|
return SMTP_SSL if self.use_ssl else SMTP
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ from django.utils.formats import localize
|
|||||||
from django.utils.translation import gettext, gettext_lazy as _
|
from django.utils.translation import gettext, gettext_lazy as _
|
||||||
|
|
||||||
from pretix.base.models import Event
|
from pretix.base.models import Event
|
||||||
|
from pretix.base.models.auth import PermissionHolder
|
||||||
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
|
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
|
||||||
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
|
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
|
||||||
)
|
)
|
||||||
@@ -59,11 +60,20 @@ class BaseExporter:
|
|||||||
This is the base class for all data exporters
|
This is the base class for all data exporters
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, event, organizer, progress_callback=lambda v: None):
|
def __init__(self, event, organizer, permission_holder: PermissionHolder=None, progress_callback=lambda v: None):
|
||||||
|
"""
|
||||||
|
:param event: Event context, can also be a queryset of events for multi-event exports
|
||||||
|
:param organizer: Organizer context
|
||||||
|
:param user: The user who triggered the export (or None).
|
||||||
|
:param token: The API token that triggered the export (or None).
|
||||||
|
:param device: The device that triggered the export (or None)
|
||||||
|
:param progress_callback: Callback function with progress
|
||||||
|
"""
|
||||||
self.event = event
|
self.event = event
|
||||||
self.organizer = organizer
|
self.organizer = organizer
|
||||||
self.progress_callback = progress_callback
|
self.progress_callback = progress_callback
|
||||||
self.is_multievent = isinstance(event, QuerySet)
|
self.is_multievent = isinstance(event, QuerySet)
|
||||||
|
self.permission_holder = permission_holder
|
||||||
if isinstance(event, QuerySet):
|
if isinstance(event, QuerySet):
|
||||||
self.events = event
|
self.events = event
|
||||||
self.event = None
|
self.event = None
|
||||||
@@ -73,6 +83,9 @@ class BaseExporter:
|
|||||||
self.events = Event.objects.filter(pk=event.pk)
|
self.events = Event.objects.filter(pk=event.pk)
|
||||||
self.timezone = event.timezone
|
self.timezone = event.timezone
|
||||||
|
|
||||||
|
if hasattr(self, 'organizer_required_permission'):
|
||||||
|
raise TypeError("Deprecated attribute organizer_required_permission no longer supported.")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.identifier
|
return self.identifier
|
||||||
|
|
||||||
@@ -176,15 +189,30 @@ class BaseExporter:
|
|||||||
"""
|
"""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_required_event_permission(cls) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
The permission level required to use this exporter for events. For multi-event-exports, this will be used
|
||||||
|
to limit the selection of events. Will be ignored if the ``OrganizerLevelExportMixin`` mixin is used.
|
||||||
|
The default implementation returns ``"event.orders:read"``.
|
||||||
|
"""
|
||||||
|
return 'event.orders:read'
|
||||||
|
|
||||||
|
|
||||||
class OrganizerLevelExportMixin:
|
class OrganizerLevelExportMixin:
|
||||||
@property
|
@classmethod
|
||||||
def organizer_required_permission(self) -> str:
|
def get_required_event_permission(cls):
|
||||||
|
raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_required_organizer_permission(cls) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
The permission level required to use this exporter. Only useful for organizer-level exports,
|
The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to
|
||||||
not for event-level exports.
|
allow everyone with any access to the organizer.
|
||||||
|
|
||||||
|
``get_required_event_permission`` will be ignored on this class.
|
||||||
"""
|
"""
|
||||||
return 'can_view_orders'
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
class ListExporter(BaseExporter):
|
class ListExporter(BaseExporter):
|
||||||
|
|||||||
@@ -47,10 +47,13 @@ from ..signals import register_multievent_data_exporters
|
|||||||
class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
|
class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||||
identifier = 'customerlist'
|
identifier = 'customerlist'
|
||||||
verbose_name = gettext_lazy('Customer accounts')
|
verbose_name = gettext_lazy('Customer accounts')
|
||||||
organizer_required_permission = 'can_manage_customers'
|
|
||||||
category = pgettext_lazy('export_category', 'Customer accounts')
|
category = pgettext_lazy('export_category', 'Customer accounts')
|
||||||
description = gettext_lazy('Download a spreadsheet of all currently registered customer accounts.')
|
description = gettext_lazy('Download a spreadsheet of all currently registered customer accounts.')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_required_organizer_permission(cls) -> str:
|
||||||
|
return 'organizer.customers:write'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def additional_form_fields(self):
|
def additional_form_fields(self):
|
||||||
return OrderedDict(
|
return OrderedDict(
|
||||||
|
|||||||
@@ -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, F, IntegerField, Max, Min, OuterRef,
|
Case, CharField, Count, DateTimeField, Exists, F, IntegerField, Max, Min,
|
||||||
Q, Subquery, Sum, When,
|
OuterRef, 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,6 +144,18 @@ 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):
|
||||||
@@ -249,9 +261,17 @@ 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.get('paid_only'):
|
||||||
qs = qs.filter(status=Order.STATUS_PAID)
|
qs = qs.filter(status=Order.STATUS_PAID)
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -295,8 +315,9 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
for id, vn in payment_methods:
|
for id, vn in payment_methods:
|
||||||
headers.append(_('Paid by {method}').format(method=vn))
|
headers.append(_('Paid by {method}').format(method=vn))
|
||||||
|
|
||||||
# get meta_data labels from first cached event
|
if self.event_object_cache:
|
||||||
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
# get meta_data labels from first cached event if any
|
||||||
|
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||||
yield headers
|
yield headers
|
||||||
|
|
||||||
full_fee_sum_cache = {
|
full_fee_sum_cache = {
|
||||||
@@ -364,7 +385,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,
|
order.invoice_address.state_for_address,
|
||||||
order.invoice_address.custom_field,
|
order.invoice_address.custom_field,
|
||||||
order.invoice_address.vat_id,
|
order.invoice_address.vat_id,
|
||||||
]
|
]
|
||||||
@@ -437,9 +458,17 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
).annotate(
|
).annotate(
|
||||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||||
).select_related('order', 'order__invoice_address', 'order__customer', 'tax_rule')
|
).select_related('order', 'order__invoice_address', 'order__customer', 'tax_rule')
|
||||||
if form_data['paid_only']:
|
if form_data.get('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
|
||||||
|
|
||||||
@@ -475,8 +504,9 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
headers.append(_('External customer ID'))
|
headers.append(_('External customer ID'))
|
||||||
headers.append(_('Payment providers'))
|
headers.append(_('Payment providers'))
|
||||||
|
|
||||||
# get meta_data labels from first cached event
|
if self.event_object_cache:
|
||||||
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
# get meta_data labels from first cached event if any
|
||||||
|
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||||
yield headers
|
yield headers
|
||||||
|
|
||||||
yield self.ProgressSetTotal(total=qs.count())
|
yield self.ProgressSetTotal(total=qs.count())
|
||||||
@@ -515,7 +545,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,
|
order.invoice_address.state_for_address,
|
||||||
order.invoice_address.vat_id,
|
order.invoice_address.vat_id,
|
||||||
]
|
]
|
||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
@@ -532,9 +562,14 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
qs = OrderPosition.all.filter(
|
qs = OrderPosition.all.filter(
|
||||||
order__event__in=self.events,
|
order__event__in=self.events,
|
||||||
)
|
)
|
||||||
if form_data['paid_only']:
|
if form_data.get('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
|
||||||
|
|
||||||
@@ -617,6 +652,8 @@ 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'),
|
||||||
@@ -672,9 +709,9 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
_('Position order link')
|
_('Position order link')
|
||||||
]
|
]
|
||||||
|
|
||||||
# get meta_data labels from first cached event
|
|
||||||
meta_data_labels = next(iter(self.event_object_cache.values())).meta_data.keys()
|
|
||||||
if has_subevents:
|
if has_subevents:
|
||||||
|
# get meta_data labels from first cached event
|
||||||
|
meta_data_labels = next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||||
headers += meta_data_labels
|
headers += meta_data_labels
|
||||||
yield headers
|
yield headers
|
||||||
|
|
||||||
@@ -732,8 +769,10 @@ 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 or '',
|
op.state_for_address 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,
|
||||||
]
|
]
|
||||||
@@ -797,7 +836,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,
|
order.invoice_address.state_for_address,
|
||||||
order.invoice_address.vat_id,
|
order.invoice_address.vat_id,
|
||||||
]
|
]
|
||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
@@ -1200,11 +1239,14 @@ class QuotaListExporter(ListExporter):
|
|||||||
class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||||
identifier = 'giftcardtransactionlist'
|
identifier = 'giftcardtransactionlist'
|
||||||
verbose_name = gettext_lazy('Gift card transactions')
|
verbose_name = gettext_lazy('Gift card transactions')
|
||||||
organizer_required_permission = 'can_manage_gift_cards'
|
|
||||||
category = pgettext_lazy('export_category', 'Gift cards')
|
category = pgettext_lazy('export_category', 'Gift cards')
|
||||||
description = gettext_lazy('Download a spreadsheet of all gift card transactions.')
|
description = gettext_lazy('Download a spreadsheet of all gift card transactions.')
|
||||||
repeatable_read = False
|
repeatable_read = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_required_organizer_permission(cls) -> str:
|
||||||
|
return 'organizer.giftcards:read'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def additional_form_fields(self):
|
def additional_form_fields(self):
|
||||||
d = [
|
d = [
|
||||||
@@ -1307,10 +1349,13 @@ class GiftcardRedemptionListExporter(ListExporter):
|
|||||||
class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
|
class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||||
identifier = 'giftcardlist'
|
identifier = 'giftcardlist'
|
||||||
verbose_name = gettext_lazy('Gift cards')
|
verbose_name = gettext_lazy('Gift cards')
|
||||||
organizer_required_permission = 'can_manage_gift_cards'
|
|
||||||
category = pgettext_lazy('export_category', 'Gift cards')
|
category = pgettext_lazy('export_category', 'Gift cards')
|
||||||
description = gettext_lazy('Download a spreadsheet of all gift cards including their current value.')
|
description = gettext_lazy('Download a spreadsheet of all gift cards including their current value.')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_required_organizer_permission(cls) -> str:
|
||||||
|
return 'organizer.giftcards:read'
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def additional_form_fields(self):
|
def additional_form_fields(self):
|
||||||
return OrderedDict(
|
return OrderedDict(
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
|
|||||||
description = _('Download a spread sheet with the data of all reusable medias on your account.')
|
description = _('Download a spread sheet with the data of all reusable medias on your account.')
|
||||||
repeatable_read = False
|
repeatable_read = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_required_organizer_permission(cls) -> str:
|
||||||
|
return "organizer.reusablemedia:read"
|
||||||
|
|
||||||
def iterate_list(self, form_data):
|
def iterate_list(self, form_data):
|
||||||
media = ReusableMedium.objects.filter(
|
media = ReusableMedium.objects.filter(
|
||||||
organizer=self.organizer,
|
organizer=self.organizer,
|
||||||
|
|||||||
@@ -196,8 +196,7 @@ class RegistrationForm(forms.Form):
|
|||||||
def clean_password(self):
|
def clean_password(self):
|
||||||
password1 = self.cleaned_data.get('password', '')
|
password1 = self.cleaned_data.get('password', '')
|
||||||
user = User(email=self.cleaned_data.get('email'))
|
user = User(email=self.cleaned_data.get('email'))
|
||||||
if validate_password(password1, user=user) is not None:
|
validate_password(password1, user=user)
|
||||||
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
|
|
||||||
return password1
|
return password1
|
||||||
|
|
||||||
def clean_email(self):
|
def clean_email(self):
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import pycountry
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.gis.geoip2 import GeoIP2
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.core.validators import (
|
from django.core.validators import (
|
||||||
@@ -66,8 +65,10 @@ 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 NumberParseException, national_significant_number
|
from phonenumbers import (
|
||||||
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
|
COUNTRY_CODE_TO_REGION_CODE, REGION_CODE_FOR_NON_GEO_ENTITY,
|
||||||
|
NumberParseException, national_significant_number,
|
||||||
|
)
|
||||||
from PIL import ImageOps
|
from PIL import ImageOps
|
||||||
|
|
||||||
from pretix.base.forms.widgets import (
|
from pretix.base.forms.widgets import (
|
||||||
@@ -83,7 +84,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, validate_vat_id,
|
VATIDFinalError, VATIDTemporaryError, normalize_vat_id, 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,
|
||||||
@@ -100,6 +101,7 @@ from pretix.helpers.countries import (
|
|||||||
from pretix.helpers.escapejson import escapejson_attr
|
from pretix.helpers.escapejson import escapejson_attr
|
||||||
from pretix.helpers.http import get_client_ip
|
from pretix.helpers.http import get_client_ip
|
||||||
from pretix.helpers.i18n import get_format_without_seconds
|
from pretix.helpers.i18n import get_format_without_seconds
|
||||||
|
from pretix.helpers.security import get_geoip
|
||||||
from pretix.presale.signals import question_form_fields
|
from pretix.presale.signals import question_form_fields
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -305,7 +307,9 @@ 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
|
||||||
@@ -389,7 +393,7 @@ class WrappedPhoneNumberPrefixWidget(PhoneNumberPrefixWidget):
|
|||||||
|
|
||||||
def guess_country_from_request(request, event):
|
def guess_country_from_request(request, event):
|
||||||
if settings.HAS_GEOIP:
|
if settings.HAS_GEOIP:
|
||||||
g = GeoIP2()
|
g = get_geoip()
|
||||||
try:
|
try:
|
||||||
res = g.country(get_client_ip(request))
|
res = g.country(get_client_ip(request))
|
||||||
if res['country_code'] and len(res['country_code']) == 2:
|
if res['country_code'] and len(res['country_code']) == 2:
|
||||||
@@ -437,7 +441,9 @@ def guess_phone_prefix_from_request(request, event):
|
|||||||
|
|
||||||
|
|
||||||
def get_phone_prefix(country):
|
def get_phone_prefix(country):
|
||||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
if country == REGION_CODE_FOR_NON_GEO_ENTITY:
|
||||||
|
return None
|
||||||
|
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
|
||||||
if country in values:
|
if country in values:
|
||||||
return prefix
|
return prefix
|
||||||
return None
|
return None
|
||||||
@@ -884,18 +890,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:
|
||||||
@@ -933,18 +939,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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1165,13 +1171,11 @@ 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 seller’s country of residence.')),
|
'depending on your and the seller’s country of residence.')),
|
||||||
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
transmission_type_choices = [
|
transmission_type_choices = [
|
||||||
@@ -1358,13 +1362,24 @@ 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
|
pass # Skip re-validation if it is validated
|
||||||
elif self.validate_vat_id and data.get('is_business') and ask_for_vat_id(data.get('country')) and data.get('vat_id'):
|
elif self.validate_vat_id and vat_id_applicable:
|
||||||
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 = normalized_id
|
self.instance.vat_id = data['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
|
||||||
@@ -1372,6 +1387,9 @@ 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)
|
||||||
@@ -1397,9 +1415,10 @@ 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.exclusive:
|
elif transmission_type.is_exclusive(self.event, data.get("country"), data.get("is_business")):
|
||||||
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
|
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." % (
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ 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):
|
||||||
@@ -61,11 +63,18 @@ 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 = [
|
||||||
'<button type="button" class="content-placeholder" title="%s">{%s}</button>' % (escape(_("Sample: %s") % v) if v else "", escape(k))
|
format_placeholder_help_text(k, v)
|
||||||
for k, v in placeholders
|
for k, v in placeholders
|
||||||
]
|
]
|
||||||
return _('Available placeholders: {list}').format(
|
return _('Available placeholders: {list}').format(
|
||||||
|
|||||||
+50
-12
@@ -34,14 +34,13 @@
|
|||||||
|
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -51,6 +50,9 @@ 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
|
||||||
@@ -86,6 +88,8 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
@@ -105,14 +109,41 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
|
|||||||
|
|
||||||
|
|
||||||
def get_babel_locale():
|
def get_babel_locale():
|
||||||
babel_locale = 'en'
|
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal
|
||||||
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
|
# Also, this returns best-effort region information for number formatting etc
|
||||||
if translation.get_language():
|
current_language = translation.get_language()
|
||||||
if localedata.exists(translation.get_language()):
|
current_region = getattr(_active_region, "value", None)
|
||||||
babel_locale = translation.get_language()
|
|
||||||
elif localedata.exists(translation.get_language()[:2]):
|
# Babel only accepts locales that exist on the system. We try combinations in the following order:
|
||||||
babel_locale = translation.get_language()[:2]
|
# language-languageversion-region
|
||||||
return babel_locale
|
# language-region
|
||||||
|
# language-languageversion
|
||||||
|
# language
|
||||||
|
# fallback to system default
|
||||||
|
# fallback to english
|
||||||
|
|
||||||
|
try_locales = []
|
||||||
|
if current_language:
|
||||||
|
if "-" in current_language:
|
||||||
|
lng_parts = current_language.split("-")
|
||||||
|
if current_region:
|
||||||
|
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}_{current_region.upper()}")
|
||||||
|
try_locales.append(f"{lng_parts[0]}_{current_region.upper()}")
|
||||||
|
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].upper()}")
|
||||||
|
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}")
|
||||||
|
try_locales.append(f"{lng_parts[0]}")
|
||||||
|
else:
|
||||||
|
if current_region:
|
||||||
|
try_locales.append(f"{current_language}_{current_region.upper()}")
|
||||||
|
try_locales.append(f"{current_language}")
|
||||||
|
|
||||||
|
try_locales.append(settings.LANGUAGE_CODE)
|
||||||
|
|
||||||
|
for locale in try_locales:
|
||||||
|
if localedata.exists(locale):
|
||||||
|
return localedata.normalize_locale(locale)
|
||||||
|
|
||||||
|
return "en"
|
||||||
|
|
||||||
|
|
||||||
def get_language_without_region(lng=None):
|
def get_language_without_region(lng=None):
|
||||||
@@ -132,6 +163,10 @@ 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):
|
||||||
"""
|
"""
|
||||||
@@ -143,15 +178,18 @@ def language(lng, region=None):
|
|||||||
formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region``
|
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 = translation.get_language()
|
lng_before = 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)
|
translation.activate(lng_before)
|
||||||
|
_active_region.value = region_before
|
||||||
|
|
||||||
|
|
||||||
class LazyLocaleException(Exception):
|
class LazyLocaleException(Exception):
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ 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 SendMailException, mail, render_mail
|
from pretix.base.services.mail import mail
|
||||||
from pretix.helpers.format import format_map
|
|
||||||
|
|
||||||
|
|
||||||
@transmission_types.new()
|
@transmission_types.new()
|
||||||
@@ -133,41 +132,26 @@ 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)
|
||||||
|
|
||||||
try:
|
# Do not set to completed because that is done by the email sending task
|
||||||
# Do not set to completed because that is done by the email sending task
|
outgoing_mail = mail(
|
||||||
subject = format_map(subject, context)
|
[recipient],
|
||||||
email_content = render_mail(template, context)
|
subject,
|
||||||
mail(
|
template,
|
||||||
[recipient],
|
context=context,
|
||||||
subject,
|
event=invoice.order.event,
|
||||||
template,
|
locale=invoice.order.locale,
|
||||||
context=context,
|
order=invoice.order,
|
||||||
event=invoice.order.event,
|
invoices=[invoice],
|
||||||
locale=invoice.order.locale,
|
attach_tickets=False,
|
||||||
order=invoice.order,
|
auto_email=True,
|
||||||
invoices=[invoice],
|
attach_ical=False,
|
||||||
attach_tickets=False,
|
plain_text_only=True,
|
||||||
auto_email=True,
|
no_order_links=True,
|
||||||
attach_ical=False,
|
)
|
||||||
plain_text_only=True,
|
if outgoing_mail:
|
||||||
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={
|
data=outgoing_mail.log_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': [],
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,9 +36,11 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ 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
|
||||||
@@ -47,7 +46,6 @@ 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,
|
||||||
@@ -60,7 +58,8 @@ 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, reshaper,
|
FontFallbackParagraph, ThumbnailingImageReader, register_ttf_font_if_new,
|
||||||
|
reshaper,
|
||||||
)
|
)
|
||||||
from pretix.presale.style import get_fonts
|
from pretix.presale.style import get_fonts
|
||||||
|
|
||||||
@@ -149,6 +148,10 @@ 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.
|
||||||
@@ -235,25 +238,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
|
||||||
"""
|
"""
|
||||||
pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf')))
|
register_ttf_font_if_new('OpenSans', finders.find('fonts/OpenSans-Regular.ttf'))
|
||||||
pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf')))
|
register_ttf_font_if_new('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf'))
|
||||||
pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf')))
|
register_ttf_font_if_new('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf'))
|
||||||
pdfmetrics.registerFont(TTFont('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf')))
|
register_ttf_font_if_new('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():
|
||||||
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
register_ttf_font_if_new(family, finders.find(styles['regular']['truetype']))
|
||||||
if family == self.event.settings.invoice_renderer_font:
|
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:
|
||||||
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
|
register_ttf_font_if_new(family + ' I', finders.find(styles['italic']['truetype']))
|
||||||
if 'bold' in styles:
|
if 'bold' in styles:
|
||||||
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
|
register_ttf_font_if_new(family + ' B', finders.find(styles['bold']['truetype']))
|
||||||
if 'bolditalic' in styles:
|
if 'bolditalic' in styles:
|
||||||
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
|
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
|
||||||
|
|
||||||
def _normalize(self, text):
|
def _normalize(self, text):
|
||||||
# reportlab does not support unicode combination characters
|
# reportlab does not support unicode combination characters
|
||||||
@@ -1059,7 +1062,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
|
|
||||||
def fmt(val):
|
def fmt(val):
|
||||||
try:
|
try:
|
||||||
return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display)
|
return money_filter(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
|
||||||
|
|
||||||
|
|||||||
@@ -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": "0[0-9]{9}",
|
"0208": "[01][0-9]{9}",
|
||||||
"0209": ".*",
|
"0209": ".*",
|
||||||
"0210": "[A-Z0-9]+",
|
"0210": "[A-Z0-9]+",
|
||||||
"0211": "IT[0-9]{11}",
|
"0211": "IT[0-9]{11}",
|
||||||
@@ -73,6 +73,9 @@ 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}",
|
||||||
@@ -120,7 +123,6 @@ class PeppolIdValidator:
|
|||||||
"9951": ".*",
|
"9951": ".*",
|
||||||
"9952": ".*",
|
"9952": ".*",
|
||||||
"9953": ".*",
|
"9953": ".*",
|
||||||
"9954": ".*",
|
|
||||||
"9956": "0[0-9]{9}",
|
"9956": "0[0-9]{9}",
|
||||||
"9957": ".*",
|
"9957": ".*",
|
||||||
"9959": ".*",
|
"9959": ".*",
|
||||||
@@ -177,6 +179,12 @@ 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 {
|
||||||
@@ -196,6 +204,12 @@ 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")
|
||||||
|
|
||||||
|
|||||||
@@ -21,9 +21,10 @@
|
|||||||
#
|
#
|
||||||
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, InvoiceAddress
|
from pretix.base.models import Invoice
|
||||||
from pretix.base.signals import EventPluginRegistry, Registry
|
from pretix.base.signals import EventPluginRegistry, Registry
|
||||||
|
|
||||||
|
|
||||||
@@ -58,15 +59,6 @@ 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:
|
||||||
"""
|
"""
|
||||||
@@ -82,13 +74,22 @@ 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_address(self, ia: InvoiceAddress):
|
def validate_invoice_address_data(self, address_data: dict):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -106,6 +107,22 @@ 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.
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ from django.core.management.commands.makemigrations import Command as Parent
|
|||||||
|
|
||||||
from ._migrations import monkeypatch_migrations
|
from ._migrations import monkeypatch_migrations
|
||||||
|
|
||||||
monkeypatch_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
class Command(Parent):
|
class Command(Parent):
|
||||||
pass
|
|
||||||
|
def handle(self, *args, **kwargs):
|
||||||
|
monkeypatch_migrations()
|
||||||
|
return super().handle(*args, **kwargs)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class Command(BaseCommand):
|
|||||||
if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS:
|
if not periodic_task.receivers or periodic_task.sender_receivers_cache.get(self) is NO_RECEIVERS:
|
||||||
return
|
return
|
||||||
|
|
||||||
for receiver in periodic_task._live_receivers(self):
|
for receiver in periodic_task._live_receivers(self)[0]:
|
||||||
name = f'{receiver.__module__}.{receiver.__name__}'
|
name = f'{receiver.__module__}.{receiver.__name__}'
|
||||||
if options['list_tasks']:
|
if options['list_tasks']:
|
||||||
print(name)
|
print(name)
|
||||||
|
|||||||
@@ -294,14 +294,28 @@ 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:
|
||||||
llen = client.llen(q.name)
|
queue_lengths = []
|
||||||
lfirst = client.lindex(q.name, -1)
|
queue_delays = []
|
||||||
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = llen
|
for prio in priority_steps:
|
||||||
if lfirst:
|
if prio:
|
||||||
ldata = json.loads(lfirst)
|
qname = f"{q.name}{sep}{prio}"
|
||||||
dt = time.time() - ldata.get('created', 0)
|
else:
|
||||||
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = dt
|
qname = q.name
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from pretix.base.i18n import get_language_without_region, set_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,10 +92,14 @@ 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()
|
||||||
|
|||||||
@@ -41,16 +41,20 @@ class Migration(migrations.Migration):
|
|||||||
name='datetime',
|
name='datetime',
|
||||||
field=models.DateTimeField(),
|
field=models.DateTimeField(),
|
||||||
),
|
),
|
||||||
migrations.AlterIndexTogether(
|
migrations.AddIndex(
|
||||||
name='logentry',
|
'logentry',
|
||||||
index_together={('datetime', 'id')},
|
models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b1fe5a_idx"),
|
||||||
),
|
),
|
||||||
migrations.AlterIndexTogether(
|
migrations.AddIndex(
|
||||||
name='order',
|
'order',
|
||||||
index_together={('datetime', 'id'), ('last_modified', 'id')},
|
models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"),
|
||||||
),
|
),
|
||||||
migrations.AlterIndexTogether(
|
migrations.AddIndex(
|
||||||
name='transaction',
|
'order',
|
||||||
index_together={('datetime', 'id')},
|
models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
'transaction',
|
||||||
|
models.Index(fields=('datetime', 'id'), name="pretixbase__datetim_b20405_idx"),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -61,7 +61,10 @@ class Migration(migrations.Migration):
|
|||||||
options={
|
options={
|
||||||
'ordering': ('identifier', 'type', 'organizer'),
|
'ordering': ('identifier', 'type', 'organizer'),
|
||||||
'unique_together': {('identifier', 'type', 'organizer')},
|
'unique_together': {('identifier', 'type', 'organizer')},
|
||||||
'index_together': {('identifier', 'type', 'organizer'), ('updated', 'id')},
|
'indexes': [
|
||||||
|
models.Index(fields=('identifier', 'type', 'organizer'), name='reusable_medium_organizer_index'),
|
||||||
|
models.Index(fields=('updated', 'id'), name="pretixbase__updated_093277_idx")
|
||||||
|
],
|
||||||
},
|
},
|
||||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,31 +9,6 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RenameIndex(
|
|
||||||
model_name="logentry",
|
|
||||||
new_name="pretixbase__datetim_b1fe5a_idx",
|
|
||||||
old_fields=("datetime", "id"),
|
|
||||||
),
|
|
||||||
migrations.RenameIndex(
|
|
||||||
model_name="order",
|
|
||||||
new_name="pretixbase__datetim_66aff0_idx",
|
|
||||||
old_fields=("datetime", "id"),
|
|
||||||
),
|
|
||||||
migrations.RenameIndex(
|
|
||||||
model_name="order",
|
|
||||||
new_name="pretixbase__last_mo_4ebf8b_idx",
|
|
||||||
old_fields=("last_modified", "id"),
|
|
||||||
),
|
|
||||||
migrations.RenameIndex(
|
|
||||||
model_name="reusablemedium",
|
|
||||||
new_name="pretixbase__updated_093277_idx",
|
|
||||||
old_fields=("updated", "id"),
|
|
||||||
),
|
|
||||||
migrations.RenameIndex(
|
|
||||||
model_name="transaction",
|
|
||||||
new_name="pretixbase__datetim_b20405_idx",
|
|
||||||
old_fields=("datetime", "id"),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="attendeeprofile",
|
model_name="attendeeprofile",
|
||||||
name="id",
|
name="id",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Generated by Django 4.2.10 on 2024-04-02 15:16
|
# Generated by Django 4.2.10 on 2024-04-02 15:16
|
||||||
|
|
||||||
from django.db import migrations
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@@ -10,8 +10,8 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterIndexTogether(
|
migrations.RemoveIndex(
|
||||||
name="reusablemedium",
|
"reusablemedium",
|
||||||
index_together=set(),
|
'reusable_medium_organizer_index',
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 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",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from pretix.helpers.permission_migration import (
|
||||||
|
OLD_TO_NEW_EVENT_MIGRATION, OLD_TO_NEW_ORGANIZER_MIGRATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_teams_forward(apps, schema_editor):
|
||||||
|
Team = apps.get_model("pretixbase", "Team")
|
||||||
|
|
||||||
|
for team in Team.objects.iterator():
|
||||||
|
if all(getattr(team, k) for k in OLD_TO_NEW_EVENT_MIGRATION.keys() if k != "can_checkin_orders"):
|
||||||
|
team.all_event_permissions = True
|
||||||
|
team.limit_event_permissions = {}
|
||||||
|
else:
|
||||||
|
team.all_event_permissions = False
|
||||||
|
for k, v in OLD_TO_NEW_EVENT_MIGRATION.items():
|
||||||
|
if getattr(team, k):
|
||||||
|
team.limit_event_permissions.update({kk: True for kk in v})
|
||||||
|
|
||||||
|
# Prevent combinations that were possible previously but no longer make sense
|
||||||
|
if team.limit_event_permissions.get("event.orders:checkin") and team.limit_event_permissions.get("event.orders:write"):
|
||||||
|
team.limit_event_permissions.pop("event.orders:checkin")
|
||||||
|
if team.limit_event_permissions.get("event.orders:write") and not team.limit_event_permissions.get("event.orders:read"):
|
||||||
|
team.limit_event_permissions.pop("event.orders:write")
|
||||||
|
if team.limit_event_permissions.get("event.vouchers:write") and not team.limit_event_permissions.get("event.vouchers:read"):
|
||||||
|
team.limit_event_permissions.pop("event.vouchers:write")
|
||||||
|
|
||||||
|
if all(getattr(team, k) for k in OLD_TO_NEW_ORGANIZER_MIGRATION.keys()):
|
||||||
|
team.all_organizer_permissions = True
|
||||||
|
team.limit_organizer_permissions = {}
|
||||||
|
else:
|
||||||
|
team.all_organizer_permissions = False
|
||||||
|
for k, v in OLD_TO_NEW_ORGANIZER_MIGRATION.items():
|
||||||
|
if getattr(team, k):
|
||||||
|
team.limit_organizer_permissions.update({kk: True for kk in v})
|
||||||
|
|
||||||
|
team.save(update_fields=[
|
||||||
|
"all_event_permissions", "limit_event_permissions", "all_organizer_permissions", "limit_organizer_permissions"
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_teams_backward(apps, schema_editor):
|
||||||
|
Team = apps.get_model("pretixbase", "Team")
|
||||||
|
|
||||||
|
for team in Team.objects.iterator():
|
||||||
|
for k, v in OLD_TO_NEW_EVENT_MIGRATION.items():
|
||||||
|
setattr(team, k, team.all_event_permissions or all(team.limit_event_permissions.get(kk) for kk in v))
|
||||||
|
for k, v in OLD_TO_NEW_ORGANIZER_MIGRATION.items():
|
||||||
|
setattr(team, k, team.all_organizer_permissions or all(team.limit_organizer_permissions.get(kk) for kk in v))
|
||||||
|
team.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("pretixbase", "0297_outgoingmail"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="team",
|
||||||
|
name="all_event_permissions",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="team",
|
||||||
|
name="all_organizer_permissions",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="team",
|
||||||
|
name="limit_event_permissions",
|
||||||
|
field=models.JSONField(default=dict),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="team",
|
||||||
|
name="limit_organizer_permissions",
|
||||||
|
field=models.JSONField(default=dict),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
migrate_teams_forward,
|
||||||
|
migrate_teams_backward,
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_change_event_settings",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_change_items",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_change_orders",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_change_organizer_settings",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_change_teams",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_change_vouchers",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_checkin_orders",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_create_events",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_manage_customers",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_manage_gift_cards",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_manage_reusable_media",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_view_orders",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="team",
|
||||||
|
name="can_view_vouchers",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -47,6 +47,19 @@ 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)
|
||||||
@@ -57,6 +70,10 @@ def parse_csv(file, length=None, mode="strict", charset=None):
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
charset = file.charset
|
charset = file.charset
|
||||||
data = data.decode(charset or "utf-8", mode)
|
data = data.decode(charset or "utf-8", mode)
|
||||||
|
|
||||||
|
# remove stray linebreaks from the end of the file
|
||||||
|
data = data.rstrip("\n")
|
||||||
|
|
||||||
# If the file was modified on a Mac, it only contains \r as line breaks
|
# If the file was modified on a Mac, it only contains \r as line breaks
|
||||||
if '\r' in data and '\n' not in data:
|
if '\r' in data and '\n' not in data:
|
||||||
data = data.replace('\r', '\n')
|
data = data.replace('\r', '\n')
|
||||||
@@ -70,6 +87,7 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 mode')
|
verbose_name = gettext_lazy('Price effect')
|
||||||
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 mode, use one of {options}.").format(
|
raise ValidationError(_("Could not parse {value} as a price effect, 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 mode."))
|
raise ValidationError(_("It is pointless to set a value without a price effect."))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def assign(self, value, obj: Voucher, **kwargs):
|
def assign(self, value, obj: Voucher, **kwargs):
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ 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
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ import inspect
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
@@ -74,10 +76,14 @@ def _transactions_mark_order_dirty(order_id, using=None):
|
|||||||
if "PYTEST_CURRENT_TEST" in os.environ:
|
if "PYTEST_CURRENT_TEST" in os.environ:
|
||||||
# We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code
|
# We don't care about Order.objects.create() calls in test code so let's try to figure out if this is test code
|
||||||
# or not.
|
# or not.
|
||||||
for frame in inspect.stack():
|
for frame in inspect.stack()[1:]:
|
||||||
if 'pretix/base/models/orders' in frame.filename:
|
if (
|
||||||
|
'pretix/base/models/orders' in frame.filename
|
||||||
|
or Path(frame.filename).is_relative_to(Path(django.__file__).parent)
|
||||||
|
):
|
||||||
|
# Ignore model- and django-internal code
|
||||||
continue
|
continue
|
||||||
elif 'test_' in frame.filename or 'conftest.py in frame.filename':
|
elif 'test_' in frame.filename or 'conftest.py' in frame.filename:
|
||||||
return
|
return
|
||||||
elif 'pretix/' in frame.filename or 'pretix_' in frame.filename:
|
elif 'pretix/' in frame.filename or 'pretix_' in frame.filename:
|
||||||
# This went through non-test code, let's consider it non-test
|
# This went through non-test code, let's consider it non-test
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import operator
|
|||||||
import secrets
|
import secrets
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import (
|
from django.contrib.auth.models import (
|
||||||
@@ -49,11 +50,11 @@ from django.core.exceptions import BadRequest, PermissionDenied
|
|||||||
from django.db import IntegrityError, models, transaction
|
from django.db import IntegrityError, models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.crypto import get_random_string, salted_hmac
|
from django.utils.crypto import get_random_string, salted_hmac
|
||||||
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import now
|
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
|
||||||
@@ -67,6 +68,14 @@ class EmailAddressTakenError(IntegrityError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionHolder(Protocol):
|
||||||
|
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||||
|
...
|
||||||
|
|
||||||
|
def has_organizer_permission(self, organizer, perm_name=None, request=None):
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
class UserManager(BaseUserManager):
|
class UserManager(BaseUserManager):
|
||||||
"""
|
"""
|
||||||
This is the user manager for our custom user model. See the User
|
This is the user manager for our custom user model. See the User
|
||||||
@@ -213,6 +222,28 @@ class SuperuserPermissionSet:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class EventPermissionSet(set):
|
||||||
|
def __contains__(self, item):
|
||||||
|
from pretix.base.permissions import assert_valid_event_permission
|
||||||
|
|
||||||
|
if super().__contains__(item):
|
||||||
|
return True
|
||||||
|
|
||||||
|
assert_valid_event_permission(item, allow_tuple=False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizerPermissionSet(set):
|
||||||
|
def __contains__(self, item):
|
||||||
|
from pretix.base.permissions import assert_valid_organizer_permission
|
||||||
|
|
||||||
|
if super().__contains__(item):
|
||||||
|
return True
|
||||||
|
|
||||||
|
assert_valid_organizer_permission(item, allow_tuple=False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||||
"""
|
"""
|
||||||
This is the user model used by pretix for authentication.
|
This is the user model used by pretix for authentication.
|
||||||
@@ -335,27 +366,25 @@ 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 SendMailException, mail
|
from pretix.base.services.mail import mail
|
||||||
|
|
||||||
try:
|
with language(self.locale):
|
||||||
with language(self.locale):
|
msg = '- ' + '\n- '.join(str(m) for m in messages)
|
||||||
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,
|
},
|
||||||
user=self,
|
event=None,
|
||||||
locale=self.locale
|
user=self,
|
||||||
)
|
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):
|
||||||
"""
|
"""
|
||||||
@@ -395,6 +424,7 @@ 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,
|
||||||
@@ -434,6 +464,7 @@ 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)))
|
||||||
@@ -473,7 +504,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
:return: set
|
:return: set
|
||||||
"""
|
"""
|
||||||
teams = self._get_teams_for_event(organizer, event)
|
teams = self._get_teams_for_event(organizer, event)
|
||||||
sets = [t.permission_set() for t in teams]
|
sets = [t.event_permission_set() for t in teams]
|
||||||
if sets:
|
if sets:
|
||||||
return set.union(*sets)
|
return set.union(*sets)
|
||||||
else:
|
else:
|
||||||
@@ -487,7 +518,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
:return: set
|
:return: set
|
||||||
"""
|
"""
|
||||||
teams = self._get_teams_for_organizer(organizer)
|
teams = self._get_teams_for_organizer(organizer)
|
||||||
sets = [t.permission_set() for t in teams]
|
sets = [t.organizer_permission_set() for t in teams]
|
||||||
if sets:
|
if sets:
|
||||||
return set.union(*sets)
|
return set.union(*sets)
|
||||||
else:
|
else:
|
||||||
@@ -502,7 +533,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
|
|
||||||
:param organizer: The organizer of the event
|
:param organizer: The organizer of the event
|
||||||
:param event: The event to check
|
:param event: The event to check
|
||||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
:param perm_name: The permission, e.g. ``event.orders:read``
|
||||||
:param request: The current request (optional)
|
:param request: The current request (optional)
|
||||||
:param session_key: The current session key (optional)
|
:param session_key: The current session key (optional)
|
||||||
:return: bool
|
:return: bool
|
||||||
@@ -514,8 +545,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
if teams:
|
if teams:
|
||||||
self._teamcache['e{}'.format(event.pk)] = teams
|
self._teamcache['e{}'.format(event.pk)] = teams
|
||||||
if isinstance(perm_name, (tuple, list)):
|
if isinstance(perm_name, (tuple, list)):
|
||||||
return any([any(team.has_permission(p) for team in teams) for p in perm_name])
|
return any([any(team.has_event_permission(p) for team in teams) for p in perm_name])
|
||||||
if not perm_name or any([team.has_permission(perm_name) for team in teams]):
|
if not perm_name or any([team.has_event_permission(perm_name) for team in teams]):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -525,7 +556,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
to the organizer ``organizer``.
|
to the organizer ``organizer``.
|
||||||
|
|
||||||
:param organizer: The organizer to check
|
:param organizer: The organizer to check
|
||||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
:param perm_name: The permission, e.g. ``organizer.events:create``
|
||||||
:param request: The current request (optional). Required to detect staff sessions properly.
|
:param request: The current request (optional). Required to detect staff sessions properly.
|
||||||
:return: bool
|
:return: bool
|
||||||
"""
|
"""
|
||||||
@@ -534,8 +565,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
teams = self._get_teams_for_organizer(organizer)
|
teams = self._get_teams_for_organizer(organizer)
|
||||||
if teams:
|
if teams:
|
||||||
if isinstance(perm_name, (tuple, list)):
|
if isinstance(perm_name, (tuple, list)):
|
||||||
return any([any(team.has_permission(p) for team in teams) for p in perm_name])
|
return any([any(team.has_organizer_permission(p) for team in teams) for p in perm_name])
|
||||||
if not perm_name or any([team.has_permission(perm_name) for team in teams]):
|
if not perm_name or any([team.has_organizer_permission(perm_name) for team in teams]):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -566,14 +597,15 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
:return: Iterable of Events
|
:return: Iterable of Events
|
||||||
"""
|
"""
|
||||||
from .event import Event
|
from .event import Event
|
||||||
|
from .organizer import TeamQuerySet
|
||||||
|
|
||||||
if request and self.has_active_staff_session(request.session.session_key):
|
if request and self.has_active_staff_session(request.session.session_key):
|
||||||
return Event.objects.all()
|
return Event.objects.all()
|
||||||
|
|
||||||
if isinstance(permission, (tuple, list)):
|
if isinstance(permission, (tuple, list)):
|
||||||
q = reduce(operator.or_, [Q(**{p: True}) for p in permission])
|
q = reduce(operator.or_, [TeamQuerySet.event_permission_q(p) for p in permission])
|
||||||
else:
|
else:
|
||||||
q = Q(**{permission: True})
|
q = TeamQuerySet.event_permission_q(permission)
|
||||||
|
|
||||||
return Event.objects.filter(
|
return Event.objects.filter(
|
||||||
Q(organizer_id__in=self.teams.filter(q, all_events=True).values_list('organizer', flat=True))
|
Q(organizer_id__in=self.teams.filter(q, all_events=True).values_list('organizer', flat=True))
|
||||||
@@ -606,14 +638,13 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
:return: Iterable of Organizers
|
:return: Iterable of Organizers
|
||||||
"""
|
"""
|
||||||
from .event import Organizer
|
from .event import Organizer
|
||||||
|
from .organizer import TeamQuerySet
|
||||||
|
|
||||||
if request and self.has_active_staff_session(request.session.session_key):
|
if request and self.has_active_staff_session(request.session.session_key):
|
||||||
return Organizer.objects.all()
|
return Organizer.objects.all()
|
||||||
|
|
||||||
kwargs = {permission: True}
|
|
||||||
|
|
||||||
return Organizer.objects.filter(
|
return Organizer.objects.filter(
|
||||||
id__in=self.teams.filter(**kwargs).values_list('organizer', flat=True)
|
id__in=self.teams.filter(TeamQuerySet.organizer_permission_q(permission)).values_list('organizer', flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
def has_active_staff_session(self, session_key=None):
|
def has_active_staff_session(self, session_key=None):
|
||||||
@@ -668,6 +699,23 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
self.session_token = generate_session_token()
|
self.session_token = generate_session_token()
|
||||||
self.save(update_fields=['session_token'])
|
self.save(update_fields=['session_token'])
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
@scopes_disabled()
|
||||||
|
def is_in_any_teams(self):
|
||||||
|
return self.teams.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class UserWithStaffSession:
|
||||||
|
# Wrapper around a User object with a staff session, implementing the PermissionHolder Protocol
|
||||||
|
def __init__(self, user):
|
||||||
|
self.user = user
|
||||||
|
|
||||||
|
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def has_organizer_permission(self, organizer, perm_name=None, request=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class UserKnownLoginSource(models.Model):
|
class UserKnownLoginSource(models.Model):
|
||||||
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
|
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
|
||||||
@@ -708,6 +756,8 @@ 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']))
|
||||||
|
|
||||||
@@ -737,6 +787,8 @@ 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
|
||||||
|
|||||||
@@ -59,6 +59,37 @@ 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):
|
||||||
@@ -99,6 +130,8 @@ 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
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ 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
|
||||||
@@ -164,6 +165,28 @@ 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')
|
||||||
@@ -293,6 +316,7 @@ 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=[]):
|
||||||
@@ -349,7 +373,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
|
||||||
|
|||||||
@@ -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 {type(self).__name__} ({failure_mode})"
|
f"Could not sync order {self.order.code} to {self.sync_provider} ({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,
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django_scopes import ScopedManager, scopes_disabled
|
from django_scopes import ScopedManager, scopes_disabled
|
||||||
|
|
||||||
from pretix.base.models import LoggedModel
|
from pretix.base.models import LoggedModel
|
||||||
|
from pretix.base.permissions import (
|
||||||
|
AnyPermissionOf, assert_valid_event_permission,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@scopes_disabled()
|
@scopes_disabled()
|
||||||
@@ -189,13 +192,19 @@ class Device(LoggedModel):
|
|||||||
kwargs['update_fields'] = {'device_id'}.union(kwargs['update_fields'])
|
kwargs['update_fields'] = {'device_id'}.union(kwargs['update_fields'])
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def permission_set(self) -> set:
|
def _event_permission_set(self) -> set:
|
||||||
return {
|
return {
|
||||||
'can_view_orders',
|
'event.orders:read',
|
||||||
'can_change_orders',
|
'event.orders:write',
|
||||||
'can_view_vouchers',
|
'event.vouchers:read',
|
||||||
'can_manage_gift_cards',
|
}
|
||||||
'can_manage_reusable_media',
|
|
||||||
|
def _organizer_permission_set(self) -> set:
|
||||||
|
return {
|
||||||
|
'organizer.giftcards:read',
|
||||||
|
'organizer.giftcards:write',
|
||||||
|
'organizer.reusablemedia:read',
|
||||||
|
'organizer.reusablemedia:write',
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_event_permission_set(self, organizer, event) -> set:
|
def get_event_permission_set(self, organizer, event) -> set:
|
||||||
@@ -209,7 +218,7 @@ class Device(LoggedModel):
|
|||||||
has_event_access = (self.all_events and organizer == self.organizer) or (
|
has_event_access = (self.all_events and organizer == self.organizer) or (
|
||||||
event in self.limit_events.all()
|
event in self.limit_events.all()
|
||||||
)
|
)
|
||||||
return self.permission_set() if has_event_access else set()
|
return self._event_permission_set() if has_event_access else set()
|
||||||
|
|
||||||
def get_organizer_permission_set(self, organizer) -> set:
|
def get_organizer_permission_set(self, organizer) -> set:
|
||||||
"""
|
"""
|
||||||
@@ -218,25 +227,26 @@ class Device(LoggedModel):
|
|||||||
:param organizer: The organizer of the event
|
:param organizer: The organizer of the event
|
||||||
:return: set of permissions
|
:return: set of permissions
|
||||||
"""
|
"""
|
||||||
return self.permission_set() if self.organizer == organizer else set()
|
return self._organizer_permission_set() if self.organizer == organizer else set()
|
||||||
|
|
||||||
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
|
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||||
"""
|
"""
|
||||||
Checks if this token is part of a team that grants access of type ``perm_name``
|
Checks if this token is part of a team that grants access of type ``perm_name``
|
||||||
to the event ``event``.
|
to the event ``event``.
|
||||||
|
|
||||||
:param organizer: The organizer of the event
|
:param organizer: The organizer of the event
|
||||||
:param event: The event to check
|
:param event: The event to check
|
||||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
:param perm_name: The permission, e.g. ``event.orders:read``
|
||||||
:param request: This parameter is ignored and only defined for compatibility reasons.
|
:param request: This parameter is ignored and only defined for compatibility reasons.
|
||||||
|
:param session_key: This parameter is ignored and only defined for compatibility reasons.
|
||||||
:return: bool
|
:return: bool
|
||||||
"""
|
"""
|
||||||
has_event_access = (self.all_events and organizer == self.organizer) or (
|
has_event_access = (self.all_events and organizer == self.organizer) or (
|
||||||
event in self.limit_events.all()
|
event in self.limit_events.all()
|
||||||
)
|
)
|
||||||
if isinstance(perm_name, (tuple, list)):
|
if isinstance(perm_name, (tuple, list)):
|
||||||
return has_event_access and any(p in self.permission_set() for p in perm_name)
|
return has_event_access and any(p in self._event_permission_set() for p in perm_name)
|
||||||
return has_event_access and (not perm_name or perm_name in self.permission_set())
|
return has_event_access and (not perm_name or perm_name in self._event_permission_set())
|
||||||
|
|
||||||
def has_organizer_permission(self, organizer, perm_name=None, request=None):
|
def has_organizer_permission(self, organizer, perm_name=None, request=None):
|
||||||
"""
|
"""
|
||||||
@@ -244,13 +254,13 @@ class Device(LoggedModel):
|
|||||||
to the organizer ``organizer``.
|
to the organizer ``organizer``.
|
||||||
|
|
||||||
:param organizer: The organizer to check
|
:param organizer: The organizer to check
|
||||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
:param perm_name: The permission, e.g. ``organizer.events:create``
|
||||||
:param request: This parameter is ignored and only defined for compatibility reasons.
|
:param request: This parameter is ignored and only defined for compatibility reasons.
|
||||||
:return: bool
|
:return: bool
|
||||||
"""
|
"""
|
||||||
if isinstance(perm_name, (tuple, list)):
|
if isinstance(perm_name, (tuple, list)):
|
||||||
return organizer == self.organizer and any(p in self.permission_set() for p in perm_name)
|
return organizer == self.organizer and any(p in self._organizer_permission_set() for p in perm_name)
|
||||||
return organizer == self.organizer and (not perm_name or perm_name in self.permission_set())
|
return organizer == self.organizer and (not perm_name or perm_name in self._organizer_permission_set())
|
||||||
|
|
||||||
def get_events_with_any_permission(self):
|
def get_events_with_any_permission(self):
|
||||||
"""
|
"""
|
||||||
@@ -270,9 +280,10 @@ class Device(LoggedModel):
|
|||||||
:param request: Ignored, for compatibility with User model
|
:param request: Ignored, for compatibility with User model
|
||||||
:return: Iterable of Events
|
:return: Iterable of Events
|
||||||
"""
|
"""
|
||||||
|
assert_valid_event_permission(permission)
|
||||||
if (
|
if (
|
||||||
isinstance(permission, (list, tuple)) and any(p in self.permission_set() for p in permission)
|
isinstance(permission, (AnyPermissionOf, list, tuple)) and any(p in self._event_permission_set() for p in permission)
|
||||||
) or (isinstance(permission, str) and permission in self.permission_set()):
|
) or (isinstance(permission, str) and permission in self._event_permission_set()):
|
||||||
return self.get_events_with_any_permission()
|
return self.get_events_with_any_permission()
|
||||||
else:
|
else:
|
||||||
return self.organizer.events.none()
|
return self.organizer.events.none()
|
||||||
|
|||||||
@@ -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', 'is_addon_to',
|
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
|
||||||
'voucher_discount'])
|
'voucher_discount'])
|
||||||
|
|
||||||
|
|
||||||
@@ -279,6 +279,42 @@ 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
|
||||||
@@ -288,8 +324,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, -idx))
|
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
|
||||||
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
|
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
|
||||||
|
|
||||||
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
# 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
|
||||||
@@ -434,7 +470,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)
|
v.sort(key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
|
||||||
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
|
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
|
||||||
@@ -458,7 +494,7 @@ class Discount(LoggedModel):
|
|||||||
|
|
||||||
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
|
# 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)
|
candidates = sorted(candidates, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
|
||||||
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
|
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
|
||||||
candidate = candidates[0]
|
candidate = candidates[0]
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -843,6 +843,33 @@ class Event(EventMixin, LoggedModel):
|
|||||||
time(hour=23, minute=59, second=59)
|
time(hour=23, minute=59, second=59)
|
||||||
), tz)
|
), tz)
|
||||||
|
|
||||||
|
def allow_copy_data(self, new_organizer, auth) -> bool:
|
||||||
|
"""
|
||||||
|
Returns whether it is allowed to copy the event to the target organizer. Auth can be TeamAPIToken or User.
|
||||||
|
"""
|
||||||
|
from ..permissions import get_all_event_permissions
|
||||||
|
from .auth import User
|
||||||
|
|
||||||
|
if self.organizer == new_organizer:
|
||||||
|
# Copying in the same organizer is always okay with any read access, we just need to ensure it does not
|
||||||
|
# grant more permissions than I had before, but that is handled by the view logic
|
||||||
|
return auth.has_event_permission(self.organizer, self, None)
|
||||||
|
|
||||||
|
if isinstance(auth, User):
|
||||||
|
# Cross-organizer copying requires almost full permission of source to prevent settings extraction
|
||||||
|
required_permissions = get_all_event_permissions() - {
|
||||||
|
# We do not require these, as this data is not copied
|
||||||
|
"event.orders:read", "event.orders:write", "event.vouchers:read", "event.vouchers:write",
|
||||||
|
"event.subevents:write",
|
||||||
|
}
|
||||||
|
given_permission = auth.get_event_permission_set(self.organizer, self)
|
||||||
|
return all(p in given_permission for p in required_permissions if ":" in p)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Tokens or devices can never copy between organizers, as they are organizer-bound. Kept for future
|
||||||
|
# compatibility and easier calling
|
||||||
|
return False
|
||||||
|
|
||||||
def copy_data_from(self, other, skip_meta_data=False):
|
def copy_data_from(self, other, skip_meta_data=False):
|
||||||
from ..signals import event_copy_data
|
from ..signals import event_copy_data
|
||||||
from . import (
|
from . import (
|
||||||
@@ -1386,14 +1413,13 @@ class Event(EventMixin, LoggedModel):
|
|||||||
from .auth import User
|
from .auth import User
|
||||||
|
|
||||||
if permission:
|
if permission:
|
||||||
kwargs = {permission: True}
|
qs = Team.objects.with_event_permission(permission)
|
||||||
else:
|
else:
|
||||||
kwargs = {}
|
qs = Team.objects.all()
|
||||||
|
|
||||||
team_with_perm = Team.objects.filter(
|
team_with_perm = qs.filter(
|
||||||
members__pk=OuterRef('pk'),
|
members__pk=OuterRef('pk'),
|
||||||
organizer=self.organizer,
|
organizer=self.organizer,
|
||||||
**kwargs
|
|
||||||
).filter(
|
).filter(
|
||||||
Q(all_events=True) | Q(limit_events__pk=self.pk)
|
Q(all_events=True) | Q(limit_events__pk=self.pk)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -594,10 +594,11 @@ 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 "
|
||||||
"sold out. If combined with the option to hide sold-out products, this allows you to "
|
"no longer available. This will happen either because the other product has sold out or because "
|
||||||
"swap out products for more expensive ones once the cheaper option is sold out. There might "
|
"the time is outside of the sales window for the other product. If combined with the option "
|
||||||
"be a short period in which both products are visible while all tickets of the referenced "
|
"to hide sold-out products, this allows you to swap out products for more expensive ones once "
|
||||||
"product are reserved, but not yet sold.")
|
"the cheaper option is sold out. There might be a short period in which both products are visible "
|
||||||
|
"while all tickets of the referenced product are reserved, but not yet sold.")
|
||||||
)
|
)
|
||||||
hidden_if_item_available_mode = models.CharField(
|
hidden_if_item_available_mode = models.CharField(
|
||||||
choices=UNAVAIL_MODES,
|
choices=UNAVAIL_MODES,
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class LogEntry(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ('-datetime', '-id')
|
ordering = ('-datetime', '-id')
|
||||||
indexes = [models.Index(fields=["datetime", "id"])]
|
indexes = [models.Index(fields=["datetime", "id"], name="pretixbase__datetim_b1fe5a_idx")]
|
||||||
|
|
||||||
def display(self):
|
def display(self):
|
||||||
from pretix.base.logentrytype_registry import log_entry_types
|
from pretix.base.logentrytype_registry import log_entry_types
|
||||||
@@ -141,8 +141,9 @@ 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(self.event, meta['plugin']):
|
if is_app_active(sender, 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,
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
#
|
||||||
|
# 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,
|
||||||
|
}
|
||||||
@@ -122,7 +122,7 @@ class ReusableMedium(LoggedModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (("identifier", "type", "organizer"),)
|
unique_together = (("identifier", "type", "organizer"),)
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=("updated", "id")),
|
models.Index(fields=("updated", "id"), name="pretixbase__updated_093277_idx"),
|
||||||
]
|
]
|
||||||
ordering = "identifier", "type", "organizer"
|
ordering = "identifier", "type", "organizer"
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ 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 (
|
||||||
@@ -337,8 +336,8 @@ class Order(LockModel, LoggedModel):
|
|||||||
verbose_name_plural = _("Orders")
|
verbose_name_plural = _("Orders")
|
||||||
ordering = ("-datetime", "-pk")
|
ordering = ("-datetime", "-pk")
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["datetime", "id"]),
|
models.Index(fields=["datetime", "id"], name="pretixbase__datetim_66aff0_idx"),
|
||||||
models.Index(fields=["last_modified", "id"]),
|
models.Index(fields=["last_modified", "id"], name="pretixbase__last_mo_4ebf8b_idx"),
|
||||||
]
|
]
|
||||||
constraints = [
|
constraints = [
|
||||||
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
|
models.UniqueConstraint(fields=["organizer", "code"], name="order_organizer_code_uniq"),
|
||||||
@@ -591,7 +590,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
not kwargs.get('force_save_with_deferred_fields', None) and
|
not kwargs.get('force_save_with_deferred_fields', None) and
|
||||||
(not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields))
|
(not update_fields or ('require_approval' not in update_fields and 'status' not in update_fields))
|
||||||
):
|
):
|
||||||
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
|
_fail("It is unsafe to call save() on an Order with deferred fields since we can't check if you missed "
|
||||||
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
|
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
|
||||||
"this.")
|
"this.")
|
||||||
|
|
||||||
@@ -1167,9 +1166,7 @@ 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 (
|
from pretix.base.services.mail import mail
|
||||||
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
|
||||||
@@ -1179,34 +1176,19 @@ class Order(LockModel, LoggedModel):
|
|||||||
if position and position.attendee_email:
|
if position and position.attendee_email:
|
||||||
recipient = position.attendee_email
|
recipient = position.attendee_email
|
||||||
|
|
||||||
try:
|
outgoing_mail = mail(
|
||||||
email_content = render_mail(template, context)
|
recipient, subject, template, context,
|
||||||
subject = format_map(subject, context)
|
self.event, self.locale, self, headers=headers, sender=sender,
|
||||||
mail(
|
invoices=invoices, attach_tickets=attach_tickets,
|
||||||
recipient, subject, template, context,
|
position=position, auto_email=auto_email, attach_ical=attach_ical,
|
||||||
self.event, self.locale, self, headers=headers, sender=sender,
|
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
|
||||||
invoices=invoices, attach_tickets=attach_tickets,
|
)
|
||||||
position=position, auto_email=auto_email, attach_ical=attach_ical,
|
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={
|
data=outgoing_mail.log_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):
|
||||||
@@ -1675,7 +1657,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
|
||||||
@@ -2024,40 +2006,30 @@ 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)
|
||||||
try:
|
position.send_mail(
|
||||||
position.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.order_paid', user,
|
||||||
'pretix.event.order.email.order_paid', user,
|
invoices=[],
|
||||||
invoices=[],
|
attach_tickets=True,
|
||||||
attach_tickets=True,
|
attach_ical=self.order.event.settings.mail_attach_ical
|
||||||
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)
|
||||||
try:
|
self.order.send_mail(
|
||||||
self.order.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.order_paid', user,
|
||||||
'pretix.event.order.email.order_paid', user,
|
invoices=[invoice] if invoice else [],
|
||||||
invoices=[invoice] if invoice else [],
|
attach_tickets=True,
|
||||||
attach_tickets=True,
|
attach_ical=self.order.event.settings.mail_attach_ical
|
||||||
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):
|
||||||
@@ -2869,7 +2841,7 @@ class OrderPosition(AbstractPosition):
|
|||||||
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
|
if Transaction.key(self) != self.__initial_transaction_key or self.canceled != self.__initial_canceled or not self.pk:
|
||||||
_transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
|
_transactions_mark_order_dirty(self.order_id, using=kwargs.get('using', None))
|
||||||
elif not kwargs.get('force_save_with_deferred_fields', None):
|
elif not kwargs.get('force_save_with_deferred_fields', None):
|
||||||
_fail("It is unsafe to call save() on an OrderFee with deferred fields since we can't check if you missed "
|
_fail("It is unsafe to call save() on an OrderPosition with deferred fields since we can't check if you missed "
|
||||||
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
|
"creating a transaction. Call save(force_save_with_deferred_fields=True) if you really want to do "
|
||||||
"this.")
|
"this.")
|
||||||
|
|
||||||
@@ -2915,44 +2887,28 @@ 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 (
|
from pretix.base.services.mail import mail
|
||||||
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
|
||||||
try:
|
outgoing_mail = mail(
|
||||||
email_content = render_mail(template, context)
|
recipient, subject, template, context,
|
||||||
subject = format_map(subject, context)
|
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
|
||||||
mail(
|
position=self,
|
||||||
recipient, subject, template, context,
|
invoices=invoices,
|
||||||
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
|
attach_tickets=attach_tickets,
|
||||||
position=self,
|
attach_ical=attach_ical,
|
||||||
invoices=invoices,
|
attach_other_files=attach_other_files,
|
||||||
attach_tickets=attach_tickets,
|
)
|
||||||
attach_ical=attach_ical,
|
if outgoing_mail:
|
||||||
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={
|
data=outgoing_mail.log_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):
|
||||||
@@ -3124,7 +3080,7 @@ class Transaction(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
ordering = 'datetime', 'pk'
|
ordering = 'datetime', 'pk'
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['datetime', 'id'])
|
models.Index(fields=['datetime', 'id'], name="pretixbase__datetim_b20405_idx")
|
||||||
]
|
]
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
@@ -3480,7 +3436,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
|
||||||
@@ -3529,18 +3485,10 @@ 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))
|
||||||
form_data = t.transmission_info_to_form_data(self.transmission_info or {})
|
if self.transmission_info:
|
||||||
for k, f in t.invoice_address_form_fields.items():
|
data += t.describe_info(self.transmission_info, self.country, self.is_business)
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+149
-104
@@ -31,9 +31,10 @@
|
|||||||
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
|
||||||
# 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 operator
|
||||||
import string
|
import string
|
||||||
from datetime import date, datetime, time
|
from datetime import date, datetime, time
|
||||||
|
from functools import reduce
|
||||||
|
|
||||||
import pytz_deprecation_shim
|
import pytz_deprecation_shim
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -53,6 +54,10 @@ from i18nfield.strings import LazyI18nString
|
|||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
from pretix.base.validators import OrganizerSlugBanlistValidator
|
from pretix.base.validators import OrganizerSlugBanlistValidator
|
||||||
|
|
||||||
|
from ...helpers.permission_migration import (
|
||||||
|
OLD_TO_NEW_EVENT_COMPAT, OLD_TO_NEW_ORGANIZER_COMPAT,
|
||||||
|
LegacyPermissionProperty,
|
||||||
|
)
|
||||||
from ..settings import settings_hierarkey
|
from ..settings import settings_hierarkey
|
||||||
from .auth import User
|
from .auth import User
|
||||||
|
|
||||||
@@ -309,6 +314,44 @@ def generate_api_token():
|
|||||||
return get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
|
return get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamQuerySet(models.QuerySet):
|
||||||
|
@classmethod
|
||||||
|
def event_permission_q(cls, perm_name):
|
||||||
|
from ..permissions import assert_valid_event_permission
|
||||||
|
|
||||||
|
if perm_name is None:
|
||||||
|
return Q()
|
||||||
|
|
||||||
|
if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_EVENT_COMPAT: # legacy
|
||||||
|
return reduce(operator.and_, [cls.event_permission_q(p) for p in OLD_TO_NEW_EVENT_COMPAT[perm_name]])
|
||||||
|
assert_valid_event_permission(perm_name, allow_legacy=False)
|
||||||
|
return (
|
||||||
|
Q(all_event_permissions=True) |
|
||||||
|
Q(**{f'limit_event_permissions__{perm_name}': True})
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def organizer_permission_q(cls, perm_name):
|
||||||
|
from ..permissions import assert_valid_organizer_permission
|
||||||
|
|
||||||
|
if perm_name is None:
|
||||||
|
return Q()
|
||||||
|
|
||||||
|
if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_ORGANIZER_COMPAT: # legacy
|
||||||
|
return reduce(operator.and_, [cls.organizer_permission_q(p) for p in OLD_TO_NEW_ORGANIZER_COMPAT[perm_name]])
|
||||||
|
assert_valid_organizer_permission(perm_name, allow_legacy=False)
|
||||||
|
return (
|
||||||
|
Q(all_organizer_permissions=True) |
|
||||||
|
Q(**{f'limit_organizer_permissions__{perm_name}': True})
|
||||||
|
)
|
||||||
|
|
||||||
|
def with_event_permission(self, perm_name):
|
||||||
|
return self.filter(self.event_permission_q(perm_name))
|
||||||
|
|
||||||
|
def with_organizer_permission(self, perm_name):
|
||||||
|
return self.filter(self.organizer_permission_q(perm_name))
|
||||||
|
|
||||||
|
|
||||||
class Team(LoggedModel):
|
class Team(LoggedModel):
|
||||||
"""
|
"""
|
||||||
A team is a collection of people given certain access rights to one or more events of an organizer.
|
A team is a collection of people given certain access rights to one or more events of an organizer.
|
||||||
@@ -321,36 +364,10 @@ class Team(LoggedModel):
|
|||||||
:param all_events: Whether this team has access to all events of this organizer
|
:param all_events: Whether this team has access to all events of this organizer
|
||||||
:type all_events: bool
|
:type all_events: bool
|
||||||
:param limit_events: A set of events this team has access to. Irrelevant if ``all_events`` is ``True``.
|
:param limit_events: A set of events this team has access to. Irrelevant if ``all_events`` is ``True``.
|
||||||
:param can_create_events: Whether or not the members can create new events with this organizer account.
|
|
||||||
:type can_create_events: bool
|
|
||||||
:param can_change_teams: If ``True``, the members can change the teams of this organizer account.
|
|
||||||
:type can_change_teams: bool
|
|
||||||
:param can_manage_customers: If ``True``, the members can view and change organizer-level customer accounts.
|
|
||||||
:type can_manage_customers: bool
|
|
||||||
:param can_manage_reusable_media: If ``True``, the members can view and change organizer-level reusable media.
|
|
||||||
:type can_manage_reusable_media: bool
|
|
||||||
:param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account.
|
|
||||||
:type can_change_organizer_settings: bool
|
|
||||||
:param can_change_event_settings: If ``True``, the members can change the settings of the associated events.
|
|
||||||
:type can_change_event_settings: bool
|
|
||||||
:param can_change_items: If ``True``, the members can change and add items and related objects for the associated events.
|
|
||||||
:type can_change_items: bool
|
|
||||||
:param can_view_orders: If ``True``, the members can inspect details of all orders of the associated events.
|
|
||||||
:type can_view_orders: bool
|
|
||||||
:param can_change_orders: If ``True``, the members can change details of orders of the associated events.
|
|
||||||
:type can_change_orders: bool
|
|
||||||
:param can_checkin_orders: If ``True``, the members can perform check-in related actions.
|
|
||||||
:type can_checkin_orders: bool
|
|
||||||
:param can_view_vouchers: If ``True``, the members can inspect details of all vouchers of the associated events.
|
|
||||||
:type can_view_vouchers: bool
|
|
||||||
:param can_change_vouchers: If ``True``, the members can change and create vouchers for the associated events.
|
|
||||||
:type can_change_vouchers: bool
|
|
||||||
"""
|
"""
|
||||||
organizer = models.ForeignKey(Organizer, related_name="teams", on_delete=models.CASCADE)
|
organizer = models.ForeignKey(Organizer, related_name="teams", on_delete=models.CASCADE)
|
||||||
name = models.CharField(max_length=190, verbose_name=_("Team name"))
|
name = models.CharField(max_length=190, verbose_name=_("Team name"))
|
||||||
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members"))
|
members = models.ManyToManyField(User, related_name="teams", verbose_name=_("Team members"))
|
||||||
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
|
|
||||||
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
|
|
||||||
require_2fa = models.BooleanField(
|
require_2fa = models.BooleanField(
|
||||||
default=False, verbose_name=_("Require all members of this team to use two-factor authentication"),
|
default=False, verbose_name=_("Require all members of this team to use two-factor authentication"),
|
||||||
help_text=_("If you turn this on, all members of the team will be required to either set up two-factor "
|
help_text=_("If you turn this on, all members of the team will be required to either set up two-factor "
|
||||||
@@ -358,62 +375,33 @@ class Team(LoggedModel):
|
|||||||
"all users.")
|
"all users.")
|
||||||
)
|
)
|
||||||
|
|
||||||
can_create_events = models.BooleanField(
|
# Scope
|
||||||
default=False,
|
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
|
||||||
verbose_name=_("Can create events"),
|
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
|
||||||
)
|
|
||||||
can_change_teams = models.BooleanField(
|
# Permissions
|
||||||
default=False,
|
# We store them as {key: True} instead of [key] because otherwise not all lookups we need are supported on SQLite
|
||||||
verbose_name=_("Can change teams and permissions"),
|
all_event_permissions = models.BooleanField(default=False, verbose_name=_("All event permissions"))
|
||||||
)
|
limit_event_permissions = models.JSONField(default=dict, verbose_name=_("Event permissions"))
|
||||||
can_change_organizer_settings = models.BooleanField(
|
all_organizer_permissions = models.BooleanField(default=False, verbose_name=_("All organizer permissions"))
|
||||||
default=False,
|
limit_organizer_permissions = models.JSONField(default=dict, verbose_name=_("Organizer permissions"))
|
||||||
verbose_name=_("Can change organizer settings"),
|
|
||||||
help_text=_('Someone with this setting can get access to most data of all of your events, i.e. via privacy '
|
# Legacy lookups for plugin compatibility
|
||||||
'reports, so be careful who you add to this team!')
|
can_change_event_settings = LegacyPermissionProperty()
|
||||||
)
|
can_change_items = LegacyPermissionProperty()
|
||||||
can_manage_customers = models.BooleanField(
|
can_view_orders = LegacyPermissionProperty()
|
||||||
default=False,
|
can_change_orders = LegacyPermissionProperty()
|
||||||
verbose_name=_("Can manage customer accounts")
|
can_checkin_orders = LegacyPermissionProperty()
|
||||||
)
|
can_view_vouchers = LegacyPermissionProperty()
|
||||||
can_manage_reusable_media = models.BooleanField(
|
can_change_vouchers = LegacyPermissionProperty()
|
||||||
default=False,
|
can_create_events = LegacyPermissionProperty()
|
||||||
verbose_name=_("Can manage reusable media")
|
can_change_organizer_settings = LegacyPermissionProperty()
|
||||||
)
|
can_change_teams = LegacyPermissionProperty()
|
||||||
can_manage_gift_cards = models.BooleanField(
|
can_manage_gift_cards = LegacyPermissionProperty()
|
||||||
default=False,
|
can_manage_customers = LegacyPermissionProperty()
|
||||||
verbose_name=_("Can manage gift cards")
|
can_manage_reusable_media = LegacyPermissionProperty()
|
||||||
)
|
|
||||||
can_change_event_settings = models.BooleanField(
|
objects = TeamQuerySet.as_manager()
|
||||||
default=False,
|
|
||||||
verbose_name=_("Can change event settings")
|
|
||||||
)
|
|
||||||
can_change_items = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name=_("Can change product settings")
|
|
||||||
)
|
|
||||||
can_view_orders = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name=_("Can view orders")
|
|
||||||
)
|
|
||||||
can_change_orders = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name=_("Can change orders")
|
|
||||||
)
|
|
||||||
can_checkin_orders = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name=_("Can perform check-ins"),
|
|
||||||
help_text=_('This includes searching for attendees, which can be used to obtain personal information about '
|
|
||||||
'attendees. Users with "can change orders" can also perform check-ins.')
|
|
||||||
)
|
|
||||||
can_view_vouchers = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name=_("Can view vouchers")
|
|
||||||
)
|
|
||||||
can_change_vouchers = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name=_("Can change vouchers")
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return _("%(name)s on %(object)s") % {
|
return _("%(name)s on %(object)s") % {
|
||||||
@@ -421,21 +409,62 @@ class Team(LoggedModel):
|
|||||||
'object': str(self.organizer),
|
'object': str(self.organizer),
|
||||||
}
|
}
|
||||||
|
|
||||||
def permission_set(self) -> set:
|
def event_permission_set(self, include_legacy=True) -> set:
|
||||||
attribs = dir(self)
|
from ..permissions import get_all_event_permission_groups
|
||||||
return {
|
|
||||||
a for a in attribs if a.startswith('can_') and self.has_permission(a)
|
result = set()
|
||||||
}
|
for pg in get_all_event_permission_groups().values():
|
||||||
|
for action in pg.actions:
|
||||||
|
if self.all_event_permissions or self.limit_event_permissions.get(f"{pg.name}:{action}"):
|
||||||
|
result.add(f"{pg.name}:{action}")
|
||||||
|
|
||||||
|
if include_legacy:
|
||||||
|
# Add legacy permissions as well for plugin compatibility
|
||||||
|
for k, v in OLD_TO_NEW_EVENT_COMPAT.items():
|
||||||
|
if self.all_event_permissions or all(self.limit_event_permissions.get(kk) for kk in v):
|
||||||
|
result.add(k)
|
||||||
|
|
||||||
|
if "can_change_event_settings" in result:
|
||||||
|
result.add("can_change_settings")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def organizer_permission_set(self, include_legacy=True) -> set:
|
||||||
|
from ..permissions import get_all_organizer_permission_groups
|
||||||
|
|
||||||
|
result = set()
|
||||||
|
for pg in get_all_organizer_permission_groups().values():
|
||||||
|
for action in pg.actions:
|
||||||
|
if self.all_organizer_permissions or self.limit_organizer_permissions.get(f"{pg.name}:{action}"):
|
||||||
|
result.add(f"{pg.name}:{action}")
|
||||||
|
|
||||||
|
if include_legacy:
|
||||||
|
# Add legacy permissions as well for plugin compatibility
|
||||||
|
for k, v in OLD_TO_NEW_ORGANIZER_COMPAT.items():
|
||||||
|
if self.all_organizer_permissions or all(self.limit_organizer_permissions.get(kk) for kk in v):
|
||||||
|
result.add(k)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def can_change_settings(self): # Legacy compatiblilty
|
def can_change_settings(self): # Legacy compatibility
|
||||||
return self.can_change_event_settings
|
return self.can_change_event_settings
|
||||||
|
|
||||||
def has_permission(self, perm_name):
|
def has_event_permission(self, perm_name):
|
||||||
try:
|
from ..permissions import assert_valid_event_permission
|
||||||
|
|
||||||
|
if perm_name.startswith('can_') and hasattr(self, perm_name): # legacy
|
||||||
return getattr(self, perm_name)
|
return getattr(self, perm_name)
|
||||||
except AttributeError:
|
assert_valid_event_permission(perm_name, allow_legacy=False)
|
||||||
raise ValueError('Invalid required permission: %s' % perm_name)
|
return self.all_event_permissions or self.limit_event_permissions.get(perm_name, False)
|
||||||
|
|
||||||
|
def has_organizer_permission(self, perm_name):
|
||||||
|
from ..permissions import assert_valid_organizer_permission
|
||||||
|
|
||||||
|
if perm_name.startswith('can_') and hasattr(self, perm_name): # legacy
|
||||||
|
return getattr(self, perm_name)
|
||||||
|
assert_valid_organizer_permission(perm_name, allow_legacy=False)
|
||||||
|
return self.all_organizer_permissions or self.limit_organizer_permissions.get(perm_name, False)
|
||||||
|
|
||||||
def permission_for_event(self, event):
|
def permission_for_event(self, event):
|
||||||
if self.all_events:
|
if self.all_events:
|
||||||
@@ -447,6 +476,19 @@ class Team(LoggedModel):
|
|||||||
def active_tokens(self):
|
def active_tokens(self):
|
||||||
return self.tokens.filter(active=True)
|
return self.tokens.filter(active=True)
|
||||||
|
|
||||||
|
def save(self, **kwargs):
|
||||||
|
if not isinstance(self.limit_event_permissions, dict):
|
||||||
|
raise TypeError("Permissions must be a dictionary")
|
||||||
|
if not isinstance(self.limit_organizer_permissions, dict):
|
||||||
|
raise TypeError("Permissions must be a dictionary")
|
||||||
|
for k in self.limit_event_permissions.values():
|
||||||
|
if k is not True:
|
||||||
|
raise TypeError("Permissions must only contain True values")
|
||||||
|
for k in self.limit_organizer_permissions.values():
|
||||||
|
if k is not True:
|
||||||
|
raise TypeError("Permissions must only contain True values")
|
||||||
|
return super().save(**kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Team")
|
verbose_name = _("Team")
|
||||||
verbose_name_plural = _("Teams")
|
verbose_name_plural = _("Teams")
|
||||||
@@ -503,7 +545,7 @@ class TeamAPIToken(models.Model):
|
|||||||
has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
|
has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
|
||||||
event in self.team.limit_events.all()
|
event in self.team.limit_events.all()
|
||||||
)
|
)
|
||||||
return self.team.permission_set() if has_event_access else set()
|
return self.team.event_permission_set() if has_event_access else set()
|
||||||
|
|
||||||
def get_organizer_permission_set(self, organizer) -> set:
|
def get_organizer_permission_set(self, organizer) -> set:
|
||||||
"""
|
"""
|
||||||
@@ -512,25 +554,26 @@ class TeamAPIToken(models.Model):
|
|||||||
:param organizer: The organizer of the event
|
:param organizer: The organizer of the event
|
||||||
:return: set of permissions
|
:return: set of permissions
|
||||||
"""
|
"""
|
||||||
return self.team.permission_set() if self.team.organizer == organizer else set()
|
return self.team.organizer_permission_set() if self.team.organizer == organizer else set()
|
||||||
|
|
||||||
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
|
def has_event_permission(self, organizer, event, perm_name=None, request=None, session_key=None) -> bool:
|
||||||
"""
|
"""
|
||||||
Checks if this token is part of a team that grants access of type ``perm_name``
|
Checks if this token is part of a team that grants access of type ``perm_name``
|
||||||
to the event ``event``.
|
to the event ``event``.
|
||||||
|
|
||||||
:param organizer: The organizer of the event
|
:param organizer: The organizer of the event
|
||||||
:param event: The event to check
|
:param event: The event to check
|
||||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
:param perm_name: The permission, e.g. ``event.orders:read``
|
||||||
:param request: This parameter is ignored and only defined for compatibility reasons.
|
:param request: This parameter is ignored and only defined for compatibility reasons.
|
||||||
|
:param session_key: This parameter is ignored and only defined for compatibility reasons.
|
||||||
:return: bool
|
:return: bool
|
||||||
"""
|
"""
|
||||||
has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
|
has_event_access = (self.team.all_events and organizer == self.team.organizer) or (
|
||||||
event in self.team.limit_events.all()
|
event in self.team.limit_events.all()
|
||||||
)
|
)
|
||||||
if isinstance(perm_name, (tuple, list)):
|
if isinstance(perm_name, (tuple, list)):
|
||||||
return has_event_access and any(self.team.has_permission(p) for p in perm_name)
|
return has_event_access and any(self.team.has_event_permission(p) for p in perm_name)
|
||||||
return has_event_access and (not perm_name or self.team.has_permission(perm_name))
|
return has_event_access and (not perm_name or self.team.has_event_permission(perm_name))
|
||||||
|
|
||||||
def has_organizer_permission(self, organizer, perm_name=None, request=None):
|
def has_organizer_permission(self, organizer, perm_name=None, request=None):
|
||||||
"""
|
"""
|
||||||
@@ -538,13 +581,13 @@ class TeamAPIToken(models.Model):
|
|||||||
to the organizer ``organizer``.
|
to the organizer ``organizer``.
|
||||||
|
|
||||||
:param organizer: The organizer to check
|
:param organizer: The organizer to check
|
||||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
:param perm_name: The permission, e.g. ``organizer.events:create``
|
||||||
:param request: This parameter is ignored and only defined for compatibility reasons.
|
:param request: This parameter is ignored and only defined for compatibility reasons.
|
||||||
:return: bool
|
:return: bool
|
||||||
"""
|
"""
|
||||||
if isinstance(perm_name, (tuple, list)):
|
if isinstance(perm_name, (tuple, list)):
|
||||||
return organizer == self.team.organizer and any(self.team.has_permission(p) for p in perm_name)
|
return organizer == self.team.organizer and any(self.team.has_organizer_permission(p) for p in perm_name)
|
||||||
return organizer == self.team.organizer and (not perm_name or self.team.has_permission(perm_name))
|
return organizer == self.team.organizer and (not perm_name or self.team.has_organizer_permission(perm_name))
|
||||||
|
|
||||||
def get_events_with_any_permission(self):
|
def get_events_with_any_permission(self):
|
||||||
"""
|
"""
|
||||||
@@ -564,9 +607,11 @@ class TeamAPIToken(models.Model):
|
|||||||
:param request: Ignored, for compatibility with User model
|
:param request: Ignored, for compatibility with User model
|
||||||
:return: Iterable of Events
|
:return: Iterable of Events
|
||||||
"""
|
"""
|
||||||
|
from pretix.base.permissions import AnyPermissionOf
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isinstance(permission, (list, tuple)) and any(getattr(self.team, p, False) for p in permission)
|
isinstance(permission, (AnyPermissionOf, list, tuple)) and any(self.team.has_event_permission(p) for p in permission)
|
||||||
) or (isinstance(permission, str) and getattr(self.team, permission, False)):
|
) or (isinstance(permission, str) and self.team.has_event_permission(permission)):
|
||||||
return self.get_events_with_any_permission()
|
return self.get_events_with_any_permission()
|
||||||
else:
|
else:
|
||||||
return self.team.organizer.events.none()
|
return self.team.organizer.events.none()
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
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
|
||||||
@@ -38,6 +37,8 @@ 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)
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ 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
|
||||||
@@ -298,6 +297,8 @@ 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)
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ class Voucher(LoggedModel):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
price_mode = models.CharField(
|
price_mode = models.CharField(
|
||||||
verbose_name=_("Price mode"),
|
verbose_name=_("Price effect"),
|
||||||
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_orders(cls, qs):
|
def annotate_budget_used(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_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=13, decimal_places=2)), Decimal('0.00')))
|
return qs.annotate(budget_used=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=13, decimal_places=2)), Decimal('0.00')))
|
||||||
|
|
||||||
def budget_used(self):
|
def budget_used(self):
|
||||||
ops = OrderPosition.objects.filter(
|
ops = OrderPosition.objects.filter(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user